#!/bin/sh # Kumo connect: points a coding tool at the Kumo gateway in one command. # # curl -fsSL https://kumorouter.com/connect.sh | KUMO_KEY='' sh -s -- [options] # curl -fsSL https://kumorouter.com/connect.sh | sh -s -- model (change the model, no key) # # Trust: «curl | sh» runs whatever this address serves, with your rights; read it first if you like. # This script is served by the Kumo site over HTTPS (kumorouter.com); its SHA-256 is shown beside the command. # A missing tool is installed with the vendor's own installer (claude.ai, chatgpt.com, opencode.ai, openclaw.ai, # pi.dev), which this script runs as published and does not vouch for. # # Clients: claude-code, codex, opencode, openclaw, pi; a comma list of them (claude-code,codex); or all. # An explicit list installs the named tools that are missing (each with its vendor's installer, as one tool does), then # connects each; all and --no-install only detect: they connect the installed tools and name the rest. # The key: KUMO_KEY in the environment of sh (the default: it stays out of the process list), --ask-key (typed in # the terminal), or the second argument (older commands; the process list shows it while the script runs). # Options: # --model optional: a model to start the tool on (advanced; by default none is chosen — # see «Models without a choice» below) # --models =,... optional, with a list or all: a model per client; claude-code.fable=, # claude-code.opus=, .sonnet=, .haiku= set Claude Code's slots there (as --opus …) # --fable|--opus|--sonnet|--haiku Claude Code's four model slots (default: --model) # --base the gateway origin (default: this site's gateway) # --openai-base the OpenAI base, when it is not /v1 # --ask-key ask for the key in the terminal (it stays out of the shell history) # --no-install do not install the tool when it is missing # --no-verify do not check the key against the gateway # --no-probe do not run the tool once at the end to prove it answers through Kumo # --ru speak Russian # # Models without a choice (owner 2026-11-08, #533): which models a tool may use is set in the KEY (its model scopes in # the console), so the script sets no default model where the tool runs without one, and asks the gateway's model list # WITH the key (GET /v1/models), so a key limited to some models registers only those (model_need): # claude-code slots — no «model»; its /model menu shows Fable, Opus, Sonnet and Haiku, so each of the four tier slots # (ANTHROPIC_DEFAULT_*_MODEL) gets the newest model of that family the key allows, a family it has none # of stays unset, and a slot you set yourself stays (owner follow-up 2026-11-08). # codex auto — Codex with a custom provider and no «model» stops («No models are available. Set model # explicitly», codex-cli 0.166.0), so the gateway's best proven Responses model is written. # opencode list — the provider's model list only, no «model»: OpenCode picks one itself, /models switches. # openclaw auto — OpenClaw's own default primary is another provider's model, so a Kumo primary is written. # pi auto — Pi is started with --provider kumo --model , so the launch line names one. # A model named with --model / --models is still honoured (advanced). Any model is checked against the list of the # gateway being configured: one it does not serve, has switched off, or has not proven with this tool's protocol is # replaced by the gateway's best proven one, and the script says so. An existing config file is backed up (.bak-, never over an earlier backup), merged # rather than replaced, written atomically (a temp file in the same folder, then mv) and left readable by you alone # (mode 600). A config that is a symlink is followed to its real file, which is replaced the same way, so the link # stays a link; a folder, a FIFO or a device where a config file belongs is refused. Every new config.toml is # validated before it replaces the old one (python3 tomllib or tomli when present, else a structural self-check); # anything the script cannot prove it understands is not touched: it leaves Kumo's part beside the file and shows # what to paste where. The key goes nowhere but the gateway. At the end it runs the tool once with the model id # kumo-connect-check (free: the gateway answers «no such model» before anything is billed) and says «Done» only when # that answer came back through Kumo. It says what changed, how to undo it and where to revoke the key. # The check trusts the tool found on your PATH (it names its path): a hostile program already on your PATH could fake # the answer, but such a program already runs as you, so that is outside what this script defends against. # m8: the reader's own shell options must not break the run — nounset off, and zsh run directly behaves as sh. set +u if [ -n "$ZSH_VERSION" ]; then emulate sh 2>/dev/null; fi KUMO_DEFAULT_ANTHROPIC='https://api.kumorouter.com' KUMO_DEFAULT_OPENAI='https://api.kumorouter.com/v1' KUMO_CONSOLE='https://console.kumorouter.com' KUMO_SITE='https://kumorouter.com' KUMO_RU=0 KUMO_WARNED=0 KUMO_CLEAN="" # A12-1 (round 13): the private staging folder of the key file being made, and its device:inode (stage_for). KUMO_STAGE="" KUMO_STAGE_ID="" # R12-M3 (round 13): the pid file of a running «pi --version» group (pi_version), for kumo_cleanup. KUMO_PI_PIDF="" # R13-M2 (round 14): the ownership mark of the running probe or «pi --version» (own_new) and the pids seen under it. KUMO_OWN_MARK="" KUMO_OWNED="" KUMO_OWN_FD=/dev/null KUMO_READ_LEFT=0 KUMO_READ_PID="" KUMO_READ_PIDF="" KUMO_READ_BIG=0 # R15-M1 (round 16): a probe read that failed or came back short (probe_read). Round 19: the tool's printed sign-in # sentences choose only a «foreign» wording, never a verdict (PROBE_AUTH_SAID_RE). KUMO_READ_FAIL=0 KUMO_PROBE_SAID_RE='unauthori[sz]ed|authentication (failed|required|error)|failed to authenticate|not authenticated|not (logged|signed) in|please (sign|log)[ -]?in|(sign|log)[ -]?in required|run /login' # Round 19 (probe-verdict-design.md §2.2): the ONLY evidence the probe's verdict admits — Kumo's answer to this run's # request (PROBE_MARKERS in the TypeScript module, the same texts connect.ps1's Probe-Tool matches). Case-sensitive ERE # (grep -E) or a fixed sentence (grep -F). KUMO_M_API='^([^ ]* )?\[[A-Za-z]+\] API error \(attempt [0-9]+/[0-9]+\): [0-9]{3} [0-9]{3} \{' KUMO_M_API_DENIED='^([^ ]* )?\[[A-Za-z]+\] API error \(attempt [0-9]+/[0-9]+\): (401 401|403 403) \{' KUMO_M_OK_CODE='"code" *: *"model_not_found"' KUMO_M_OK_TEXT='No live model answers to that name.' KUMO_M_PROBE_HEAD='"probe" *: *"kumo-connect-check-' KUMO_M_PROBE_TAIL=':[0-9a-f]{16}"' KUMO_M_SIGNIN_CODE='"code" *: *"unauthenticated"' KUMO_M_SIGNIN_TEXT='The request is not authenticated.' KUMO_M_REFUSED='"code" *: *"(key_paused|scope_not_permitted|organization_frozen|organization_not_active|wallet_closed|lifetime_limit_exceeded|max_request_limit_exceeded|package_frozen|platform_stopped)"' KUMO_M_KUMO_CODE='"code" *: *"(unauthenticated|model_not_found|protocol_not_supported|modality_not_supported|capability_not_supported|output_limit_not_enforceable|request_bounds_invalid|model_unavailable|upstream_tool_choice_ignored|tool_choice_unsupported|service_unavailable|internal_error|insufficient_package_balance|insufficient_wallet_balance|daily_limit_exceeded|monthly_limit_exceeded|concurrent_streams_limit_exceeded|requests_per_second_limit_exceeded|tokens_per_minute_limit_exceeded|ip_rate_limit_exceeded|ip_key_limit_exceeded|model_requests_per_minute_limit_exceeded|model_tokens_per_minute_limit_exceeded|key_paused|scope_not_permitted|organization_frozen|organization_not_active|wallet_closed|lifetime_limit_exceeded|max_request_limit_exceeded|package_frozen|platform_stopped|upstream_contract_violation|upstream_overloaded|request_limit_exceeded)"' # Round 19: the probe's nonce, the «foreign» sub-wording its Details line chose (elsewhere, signin) and the debug-file # look of the early stop that may still run (probe_peek; kumo_cleanup stops it through KUMO_READ_PID / KUMO_READ_PIDF). KUMO_NONCE="" KUMO_PROBE_NOTE="" KUMO_PEEK_PID="" # Sol R7-M1 (round 8): the bytes a registry record can never hold. A path with a line break, a carriage return or one # of the two separators is refused before anything is written (paths_guard), and the cleanup list is split on the # record separator alone, so a record is always a whole path. KUMO_NL=' ' KUMO_CR=$(printf '\r') KUMO_RS=$(printf '\036') KUMO_US=$(printf '\037') KUMO_PGROUP="" KUMO_UNDO="" say() { if [ "$KUMO_RU" = 1 ]; then printf '%s\n' "$2"; else printf '%s\n' "$1"; fi } warn() { KUMO_WARNED=1 if [ "$KUMO_RU" = 1 ]; then printf '! %s\n' "$2" >&2; else printf '! %s\n' "$1" >&2; fi } die() { code=$1 shift if [ "$KUMO_RU" = 1 ]; then printf 'kumo: %s\n' "$2" >&2; else printf 'kumo: %s\n' "$1" >&2; fi exit "$code" } usage() { say "Usage: curl -fsSL /connect.sh | KUMO_KEY= sh -s -- |all [--ru]" \ "Запуск: curl -fsSL /connect.sh | KUMO_KEY=<ключ> sh -s -- <клиент>|all [--ru]" say "Clients: claude-code, codex, opencode, openclaw, pi, a comma list of them (a missing one is installed), all (installed ones only)" \ "Клиенты: claude-code, codex, opencode, openclaw, pi, их список через запятую (недостающий будет установлен), all (только установленные)" say "Change the model later (no key): curl -fsSL /connect.sh | sh -s -- model " \ "Сменить модель позже (без ключа): curl -fsSL /connect.sh | sh -s -- model <клиент> " } # Temp files holding the key (the curl header file, half-written configs) are removed on every way out: a normal # end, an error, Ctrl-C, a closed terminal. Each record is one whole path, ended by the record separator (\036). # BUG_FIX_CONTEXT (Sol R7-M1): the list was one path per LINE, and «rm -rf» ran on each line — a HOME holding a line # break («…/newline-unrelated» LF «suffix») put a temp path on two lines, and the first one, a real sibling folder, was # removed with everything in it. A line break is now an ordinary byte inside a record: the list is split on \036 # alone (no globbing), a path holding \036 is never registered (its temp file is removed and the run stops), and # paths_guard refuses a line break in HOME and in every derived path before anything is written at all. clean_add() { if odd_path "$1"; then rm -f "$1" die 1 "A temporary path holds a line break or a control character, so the run stops here: $(sq "$1")" \ "Временный путь содержит перевод строки или управляющий символ, поэтому работа остановлена: $(sq "$1")" fi KUMO_CLEAN="$KUMO_CLEAN$KUMO_RS$1" } kumo_cleanup() { # R6-M3: a probe still running when the run is interrupted is stopped with its whole process group first. if [ -n "$KUMO_PGROUP" ]; then stop_group "$KUMO_PGROUP"; KUMO_PGROUP=""; fi # R12-M3 (round 13): so is a «pi --version» still running (pi_version) — its group, else the tree under its leader. if [ -n "$KUMO_PI_PIDF" ]; then kc_pp=$(cat "$KUMO_PI_PIDF" 2>/dev/null) KUMO_PI_PIDF="" case $kc_pp in ''|*[!0-9]*) ;; *) if kill -s 0 -- -"$kc_pp" 2>/dev/null; then stop_group "$kc_pp"; else kill_tree "$kc_pp"; fi ;; esac fi # R13-M2 (round 14): and everything else the run owns — a probe's or a «pi --version»'s descendants that left the # group (own_stop: the owned set and the holders of the ownership mark). if [ -n "$KUMO_OWN_MARK$KUMO_OWNED" ]; then own_stop; fi # R13-M1 (round 14): a bounded read still running (probe_read's awk, perhaps blocked on a FIFO) goes too. if [ -n "$KUMO_READ_PID" ]; then kill -s KILL "$KUMO_READ_PID" 2>/dev/null; KUMO_READ_PID=""; fi # A14-1 (round 15): and the dd under it, by the pid it wrote — the subshell's death does not stop a blocked dd. if [ -n "$KUMO_READ_PIDF" ]; then kc_rd=$(cat "$KUMO_READ_PIDF" 2>/dev/null) KUMO_READ_PIDF="" case $kc_rd in ''|*[!0-9]*) ;; *) kill -s KILL "$kc_rd" 2>/dev/null ;; esac fi # -r: the probe's working folder is on the list too. The split is on \036 only, in a subshell (IFS, set -f). ( set -f IFS=$KUMO_RS for f in $KUMO_CLEAN; do if [ -n "$f" ]; then rm -rf "$f"; fi done ) } # Sol R7-M1: does a path hold a byte no record can carry — a line break, a carriage return, \036 or \037? odd_path() { case $1 in *"$KUMO_NL"*|*"$KUMO_CR"*|*"$KUMO_RS"*|*"$KUMO_US"*) return 0 ;; esac return 1 } # Sol R7-M1 (round 8): before ANY file is written, HOME, the temp folder, the config folders the reader may name # (CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME) and every config path of the clients named ($@) — a link followed # to its real file — must be plain enough to be one record. Anything else stops the run with nothing changed. paths_guard() { for pv in HOME TMPDIR CLAUDE_CONFIG_DIR CODEX_HOME XDG_CONFIG_HOME; do eval "pval=\$$pv" if [ "$pv" = TMPDIR ]; then pval=$KUMO_TMP; fi if odd_path "$pval"; then die 1 "$pv holds a line break or a control character, so this script cannot use the paths under it safely, and nothing was changed. Run the command from a session where $pv is a plain folder path." \ "В $pv есть перевод строки или управляющий символ, поэтому скрипт не может безопасно работать с путями в нём, и ничего не изменено. Запустите команду там, где $pv — обычный путь к папке." fi done guard_client=$client for client in "$@"; do config_file if [ -L "$file" ]; then greal=$(real_path "$file") || die 1 "$file is a symlink this script cannot follow, so nothing was changed. Point it at a real file, or merge by hand." \ "$file — ссылка, по которой скрипт не может пройти, поэтому ничего не изменено. Направьте её на настоящий файл или перенесите настройки вручную." else greal=$file fi if odd_path "$file" || odd_path "$greal"; then die 1 "$(sq "$file") leads to a path with a line break or a control character, so this script cannot use it safely, and nothing was changed." \ "$(sq "$file") ведёт к пути с переводом строки или управляющим символом, поэтому скрипт не может безопасно с ним работать, и ничего не изменено." fi done client=$guard_client } # m4: a word the reader may paste back into a shell — single-quoted, a quote inside written '\'' — so a path with an # apostrophe (HOME=/Users/o'neil) still undoes what it says. sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } # A model id as a word of a printed command: bare when plainly safe, else single-quoted. sw() { case $1 in *[!A-Za-z0-9._:/@%+=,-]*|"") sq "$1" ;; *) printf '%s' "$1" ;; esac } # A JSON (and TOML basic) string literal. The values reaching it hold no newline or control character. js() { printf '"%s"' "$(jsraw "$1")" } # The inside of that literal: the value with \ and " escaped. jsraw() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } # The key's slot in a patch while it is built: a word no key or path holds. R7: the patch is built ONCE around it, # then filled twice — with the key for the file, and with the key's MASK for the screen — so what is shown is the # serialisation of the mask, never a substring search over the serialised key (which an escaped " or \ defeats). # #533 r2 (codex-533-script-r2.log #2): the word is drawn per run (kumo_main: 32 hex from /dev/urandom), so no file # can hold it in advance; this fixed value stands only until then. KUMO_KS=__KUMO_KEY_SLOT__ # stdin with every KUMO_KS replaced by $1 (already escaped for the format it lands in). # #533 r1 (codex-review-script-r1.md): the value reaches awk as the FIRST LINE OF ITS STDIN (printf is a shell # builtin, so no process carries it in argv), never in awk's environment — $1 is the key itself on its way into a file. # The value holds no newline (the key holds no control character). Only the public slot word rides in KUMO_S. # BUG_FIX_CONTEXT (#533 r1): KUMO_V=$1 put the serialised key into awk's environment, readable through the process # table (/proc//environ, ps -E) and inherited by anything a substituted awk starts. fill_key() { { printf '%s\n' "$1"; cat; } | KUMO_S=$KUMO_KS awk 'NR == 1 { v = $0; next } { s = ENVIRON["KUMO_S"]; out = ""; line = $0 while ((i = index(line, s)) > 0) { out = out substr(line, 1, i - 1) v; line = substr(line, i + length(s)) } print out line }' } # KUMO_PATCH (built around KUMO_KS) → KUMO_PATCH with the key, KUMO_SHOWN with its mask; KUMO_SLOTTED keeps the patch # still built around KUMO_KS — the form a merge engine is handed (merge_into), so no engine's environment holds the key. seal_patch() { KUMO_SLOTTED=$KUMO_PATCH KUMO_SHOWN=$(printf '%s\n' "$KUMO_PATCH" | fill_key "$(jsraw "$(mask "$key")")") KUMO_PATCH=$(printf '%s\n' "$KUMO_PATCH" | fill_key "$(jsraw "$key")") } has() { command -v "$1" >/dev/null 2>&1 } # The key as the screen may show it: the issuer prefix (kumo_sk_) and the last four characters. # #533 r1: the key reaches awk on its stdin (the builtin printf), never in its environment (was KUMO_M=$1). mask() { printf '%s\n' "$1" | awk 'NR == 1 { k = $0 if (length(k) <= 8) { print "…" substr(k, length(k) - 1); exit } if (match(k, /^[A-Za-z0-9]+_[A-Za-z0-9]+_/) && length(k) > RLENGTH + 4) { print substr(k, 1, RLENGTH) "…" substr(k, length(k) - 3); exit } print substr(k, 1, 4) "…" substr(k, length(k) - 3) exit }' } # The JSON engine: node, python3, or on a Mac without either the JavaScript for Automation that every macOS has. # A python3 that runs: /usr/bin/python3 on a Mac without the Command Line Tools is a stub that pops an installer. py_ok() { has python3 || return 1 if [ "$(uname -s 2>/dev/null)" = Darwin ] && [ "$(command -v python3)" = /usr/bin/python3 ]; then xcode-select -p >/dev/null 2>&1 || return 1 fi return 0 } pick_engine() { if has node; then echo node return fi if py_ok; then echo python3 return fi if [ "$(uname -s 2>/dev/null)" = Darwin ] && has osascript; then echo jxa return fi echo none } # The JavaScript programs below run on node or on macOS's JavaScript for Automation; each prelude gives them the # same five names: ENV (the KUMO_* variables), READ, EXISTS, OUT, EXIT. KUMO_JS_NODE='var ENV = process.env; var READ = function (p) { return require("fs").readFileSync(p, "utf8"); }; var EXISTS = function (p) { return require("fs").existsSync(p); }; var OUT = function (s) { process.stdout.write(s); }; var EXIT = function (c) { process.exit(c); }; ' KUMO_JS_JXA='ObjC.import("Foundation"); ObjC.import("stdlib"); var ENV = {}; (function () { var d = $.NSProcessInfo.processInfo.environment; ["KUMO_FILE", "KUMO_PATCH", "KUMO_DROP", "KUMO_PATH", "KUMO_CLIENT", "KUMO_CATALOG", "KUMO_MODEL", "KUMO_CODE", "KUMO_CTYPE", "KUMO_PROTO"].forEach(function (k) { var v = ObjC.unwrap(d.objectForKey(k)); if (typeof v === "string") ENV[k] = v; }); })(); var READ = function (p) { var s = $.NSString.stringWithContentsOfFileEncodingError(p, $.NSUTF8StringEncoding, null); if (s.isNil()) throw new Error("cannot read " + p); return ObjC.unwrap(s); }; var EXISTS = function (p) { return $.NSFileManager.defaultManager.fileExistsAtPath(p); }; var OUT = function (s) { $.NSFileHandle.fileHandleWithStandardOutput.writeData($(s).dataUsingEncoding($.NSUTF8StringEncoding)); }; var EXIT = function (c) { $.exit(c); }; ' # The JSON deep merge: KUMO_FILE (may be missing or empty) <- KUMO_PATCH; KUMO_DROP lists dotted paths to delete. # Prints the merged document. Exit 3: the existing file is not a JSON object (comments, JSON5, junk). Exit 5 (m6): it # holds a number a JavaScript double cannot keep exactly (an integer past 2^53, a decimal of more than 15 significant # digits) — writing it back would silently change it, so the file is left as it is. KUMO_JS_MERGE='var f = ENV.KUMO_FILE; var cur = {}; var lossy = function (t) { var re = /"(?:[^"\\]|\\.)*"|-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/g, m; while ((m = re.exec(t)) !== null) { var s = m[0]; if (s.charAt(0) === "\"") continue; if (/^-?[0-9]+$/.test(s)) { if (!Number.isSafeInteger(Number(s))) return true; } else if (s.replace(/^-/, "").replace(/[eE].*$/, "").replace(".", "").replace(/^0+/, "").length > 15) return true; } return false; }; if (EXISTS(f)) { var t = READ(f); if (t.trim() !== "") { try { cur = JSON.parse(t); } catch (e) { EXIT(3); } if (lossy(t)) EXIT(5); } } var isObj = function (v) { return v !== null && typeof v === "object" && !Array.isArray(v); }; if (!isObj(cur)) EXIT(3); var merge = function (a, b) { Object.keys(b).forEach(function (k) { if (isObj(a[k]) && isObj(b[k])) merge(a[k], b[k]); else a[k] = b[k]; }); }; merge(cur, JSON.parse(ENV.KUMO_PATCH)); (ENV.KUMO_DROP || "").split(" ").filter(Boolean).forEach(function (p) { var parts = p.split("."); var o = cur; for (var i = 0; i < parts.length - 1; i++) { if (!isObj(o[parts[i]])) return; o = o[parts[i]]; } delete o[parts[parts.length - 1]]; }); OUT(JSON.stringify(cur, null, 2) + "\n"); ' # The string at a dotted path of KUMO_FILE, "{}" for any other value there, nothing when absent or unreadable. KUMO_JS_GET='try { var o = JSON.parse(READ(ENV.KUMO_FILE)); ENV.KUMO_PATH.split(".").forEach(function (p) { o = (o !== null && typeof o === "object") ? o[p] : undefined; }); if (typeof o === "string") OUT(o); else if (o !== undefined && o !== null) OUT("{}"); } catch (e) {} ' # Every text chat model of the catalogue file, in the client shape of the manual recipe, the chosen model first. KUMO_JS_MODELS='var cat = JSON.parse(READ(ENV.KUMO_CATALOG)); var rows = (Array.isArray(cat) ? cat : (cat && cat.data) || []).filter(function (r) { return r && typeof r.id === "string" && /^[A-Za-z0-9._:\/@+-]{1,200}$/.test(r.id) && Array.isArray(r.modality_codes) && r.modality_codes.indexOf("text_generation") >= 0 && Array.isArray(r.protocol_codes) && r.protocol_codes.indexOf("chat_completions") >= 0; }); if (rows.length === 0) EXIT(4); if (ENV.KUMO_MODEL) { var chosen = rows.filter(function (r) { return r.id === ENV.KUMO_MODEL; })[0] || { id: ENV.KUMO_MODEL }; rows = [chosen].concat(rows.filter(function (r) { return r.id !== ENV.KUMO_MODEL; })); } var num = function (v) { return typeof v === "number" ? v : undefined; }; var clean = function (o) { Object.keys(o).forEach(function (k) { if (o[k] === undefined) delete o[k]; }); return o; }; var patch = JSON.parse(ENV.KUMO_PATCH); if (ENV.KUMO_CLIENT === "opencode") { var models = {}; rows.forEach(function (r) { var c = num(r.context_window_tokens), m = num(r.max_output_tokens); models[r.id] = clean({ name: r.display_name_en || r.id, limit: c === undefined && m === undefined ? undefined : { context: c || 0, output: m || 0 } }); }); patch.provider.kumo.models = models; } else if (ENV.KUMO_CLIENT === "openclaw") { patch.models.providers.kumo.models = rows.map(function (r) { return clean({ id: r.id, name: r.display_name_en || r.id, input: ["text"], contextWindow: num(r.context_window_tokens), maxTokens: num(r.max_output_tokens) }); }); } else { patch.providers.kumo.models = rows.map(function (r) { return clean({ id: r.id, input: ["text"], contextWindow: num(r.context_window_tokens), maxTokens: num(r.max_output_tokens) }); }); } OUT(rows.length + "\n" + JSON.stringify(patch, null, 2)); ' # What in a merged config would still send the tool past Kumo: one " = " line each. # OpenCode: an agent (or mode) with its own model; Claude Code: Bedrock or Vertex switched on. KUMO_JS_OVERRIDES='try { var o = JSON.parse(READ(ENV.KUMO_FILE)); var out = []; if (ENV.KUMO_CLIENT === "opencode") { ["agent", "mode"].forEach(function (s) { var t = o[s]; if (t === null || typeof t !== "object") return; Object.keys(t).forEach(function (n) { var m = t[n] !== null && typeof t[n] === "object" ? t[n].model : undefined; if (typeof m === "string" && m.indexOf("kumo/") !== 0) out.push(s + "." + n + ".model = " + m); }); }); } if (ENV.KUMO_CLIENT === "claude-code") { var e = o.env !== null && typeof o.env === "object" ? o.env : {}; ["CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"].forEach(function (k) { var v = e[k]; if (v !== undefined && v !== null && v !== "" && v !== "0" && v !== false) out.push("env." + k + " = " + v); }); } OUT(out.map(function (l) { return l + "\n"; }).join("")); } catch (e) {} ' run_js() { case $KUMO_ENGINE in node) node -e "$KUMO_JS_NODE$1" 15: lossy.append(s) return float(s) if os.path.exists(f): with open(f, encoding="utf-8") as h: t = h.read() if t.strip(): try: cur = json.loads(t, parse_float=exact_float) except Exception: sys.exit(3) if lossy: sys.exit(5) if not isinstance(cur, dict): sys.exit(3) def merge(a, b): for k, v in b.items(): if isinstance(a.get(k), dict) and isinstance(v, dict): merge(a[k], v) else: a[k] = v merge(cur, json.loads(os.environ["KUMO_PATCH"])) for p in os.environ.get("KUMO_DROP", "").split(): parts = p.split(".") o = cur ok = True for part in parts[:-1]: if not isinstance(o.get(part), dict): ok = False break o = o[part] if ok: o.pop(parts[-1], None) sys.stdout.write(json.dumps(cur, indent=2, ensure_ascii=False) + "\n") ' .bak-, then -2, -3… within the same second. Created # exclusively (noclobber), so a run that loses a race takes the next name instead of writing over the original. stamp() { date -u +%Y%m%dT%H%M%SZ } backup_of() { base="$1.bak-$(stamp)" try=$base n=1 while :; do if [ ! -e "$try" ] && ( set -C; : > "$try" ) 2>/dev/null; then printf '%s' "$try" return 0 fi n=$((n + 1)) if [ "$n" -gt 99 ]; then return 1; fi try="$base-$n" done } # What a JSON config held before this run, for the summary: prints the string at a dotted path, "{}" for any other # value there, nothing when it is absent, the file is not JSON, or there is no engine to read it. json_get() { if [ ! -s "$1" ]; then return 0; fi case $KUMO_ENGINE in node|jxa) KUMO_FILE=$1 KUMO_PATH=$2 run_js "$KUMO_JS_GET" 2>/dev/null ;; python3) KUMO_FILE=$1 KUMO_PATH=$2 python3 -c ' import json, os, sys try: with open(os.environ["KUMO_FILE"], encoding="utf-8") as h: o = json.load(h) for p in os.environ["KUMO_PATH"].split("."): o = o.get(p) if isinstance(o, dict) else None if isinstance(o, str): sys.stdout.write(o) elif o is not None: sys.stdout.write("{}") except Exception: pass ' /dev/null ;; esac } # The overrides of a written config (KUMO_JS_OVERRIDES), one per line. json_overrides() { if [ ! -s "$1" ]; then return 0; fi case $KUMO_ENGINE in node|jxa) KUMO_FILE=$1 KUMO_CLIENT=$client run_js "$KUMO_JS_OVERRIDES" 2>/dev/null ;; python3) KUMO_FILE=$1 KUMO_CLIENT=$client python3 -c ' import json, os, sys try: with open(os.environ["KUMO_FILE"], encoding="utf-8") as h: o = json.load(h) out = [] if os.environ["KUMO_CLIENT"] == "opencode": for s in ("agent", "mode"): t = o.get(s) if isinstance(t, dict): for n, v in t.items(): m = v.get("model") if isinstance(v, dict) else None if isinstance(m, str) and not m.startswith("kumo/"): out.append(s + "." + n + ".model = " + m) if os.environ["KUMO_CLIENT"] == "claude-code": e = o.get("env") if isinstance(o.get("env"), dict) else {} for k in ("CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"): v = e.get(k) if v is not None and v != "" and v != "0" and v is not False: out.append("env." + k + " = " + (v if isinstance(v, str) else json.dumps(v))) sys.stdout.write("".join(l + "\n" for l in out)) except Exception: pass ' /dev/null ;; esac } # Register every text model the gateway's list gives this run (fetch_catalog: asked with the key, so a key limited to # some models gets those only), in the shape the manual recipe writes for the client — in the list's own order, with a # model the command NAMED first (KUMO_NAMED; #533: none is named by the page any more). Rewrites KUMO_PATCH and sets # KUMO_MODELS_N; on no engine, no curl, a failed fetch or an empty list it leaves the patch with its one model alone. expand_models() { KUMO_MODELS_N=1 fetch_catalog if [ -z "$KUMO_CAT" ]; then return 0; fi catalog=$KUMO_CAT rc=0 if [ "$KUMO_ENGINE" = python3 ]; then out=$(KUMO_CATALOG=$catalog KUMO_CLIENT=$client KUMO_MODEL=$KUMO_NAMED KUMO_PATCH=$KUMO_PATCH python3 -c ' import json, os, re, sys env = os.environ with open(env["KUMO_CATALOG"], encoding="utf-8") as h: cat = json.load(h) rows = cat if isinstance(cat, list) else (cat.get("data") if isinstance(cat, dict) else None) or [] # R14-m1 (round 15): a code list must BE a list, and the code one of its elements — as node (Array.isArray) and # PowerShell read it; «"x" in "...x..."» on a string is substring containment, not membership. has = lambda r, f, v: isinstance(r.get(f), list) and v in r[f] rows = [r for r in rows if isinstance(r, dict) and isinstance(r.get("id"), str) and re.fullmatch(r"[A-Za-z0-9._:/@+-]{1,200}", r["id"]) is not None and has(r, "modality_codes", "text_generation") and has(r, "protocol_codes", "chat_completions")] if not rows: sys.exit(4) if env.get("KUMO_MODEL"): chosen = next((r for r in rows if r["id"] == env["KUMO_MODEL"]), {"id": env["KUMO_MODEL"]}) rows = [chosen] + [r for r in rows if r["id"] != env["KUMO_MODEL"]] def num(v): return v if isinstance(v, int) and not isinstance(v, bool) else None def clean(o): return {k: v for k, v in o.items() if v is not None} patch = json.loads(env["KUMO_PATCH"]) client = env["KUMO_CLIENT"] if client == "opencode": models = {} for r in rows: c, m = num(r.get("context_window_tokens")), num(r.get("max_output_tokens")) models[r["id"]] = clean({"name": r.get("display_name_en") or r["id"], "limit": None if c is None and m is None else {"context": c or 0, "output": m or 0}}) patch["provider"]["kumo"]["models"] = models elif client == "openclaw": patch["models"]["providers"]["kumo"]["models"] = [clean({"id": r["id"], "name": r.get("display_name_en") or r["id"], "input": ["text"], "contextWindow": num(r.get("context_window_tokens")), "maxTokens": num(r.get("max_output_tokens"))}) for r in rows] else: patch["providers"]["kumo"]["models"] = [clean({"id": r["id"], "input": ["text"], "contextWindow": num(r.get("context_window_tokens")), "maxTokens": num(r.get("max_output_tokens"))}) for r in rows] sys.stdout.write(str(len(rows)) + "\n" + json.dumps(patch, indent=2, ensure_ascii=False)) ' /dev/null) || rc=$? else out=$(KUMO_CATALOG=$catalog KUMO_CLIENT=$client KUMO_MODEL=$KUMO_NAMED KUMO_PATCH=$KUMO_PATCH run_js "$KUMO_JS_MODELS" 2>/dev/null) || rc=$? fi if [ "$rc" != 0 ] || [ -z "$out" ]; then return 0; fi KUMO_MODELS_N=$(printf '%s\n' "$out" | sed -n 1p) KUMO_PATCH=$(printf '%s\n' "$out" | sed 1d) } # P1: the model list of the gateway this run configures — GET /models — read ONCE per run (the page's list # may come from another gateway). KUMO_CAT is the file, or empty when it could not be read. # #533 (owner 2026-11-08): with a key in this run the list is asked WITH it (Authorization: Bearer, from a private header # file as verify_key sends it — the key never in an argument), so a key limited to some models is answered with those # only and no tool registers a model its key cannot use. A keyed read that fails leaves the list unread — it never # falls back to the public list (#533 final Codex P1: fail closed); only a run with no key (the «model» verb) reads the # public list. fetch_catalog() { if [ "$KUMO_CAT_TRIED" = 1 ]; then return 0; fi KUMO_CAT_TRIED=1 KUMO_CAT="" if [ "$KUMO_ENGINE" = none ] || ! has curl; then return 0; fi cat_file=$(mktemp "$KUMO_TMP/kumo-models.XXXXXX") || return 0 clean_add "$cat_file" if [ -n "$key" ]; then key_temp "$KUMO_TMP/kumo-hdr" cat_hdr=$KUMO_T key_write_or_stop "$cat_hdr" printf 'Authorization: Bearer %s\n' "$key" if curl -fsS --max-time 15 -H @"$cat_hdr" -o "$cat_file" "$obase/models" /dev/null 2>&1; then rm -f "$cat_hdr" KUMO_CAT=$cat_file return 0 fi rm -f "$cat_hdr" "$cat_file" # BUG_FIX_CONTEXT (#533 final Codex P1): a failed keyed read fell back to the PUBLIC list, so a tool could register # or start on a model outside the key's scope. With a key the list is the key's or none (fail closed): KUMO_CAT stays # empty, and every tool that needs the list is stopped by its caller (kumo_main / kumo_list, claude_auto). return 0 fi if curl -fsS --max-time 15 -o "$cat_file" "$obase/models" /dev/null 2>&1; then KUMO_CAT=$cat_file else rm -f "$cat_file" fi } # The protocol each tool speaks to Kumo — the page's TOOL_PROTOCOL (connect-recipes.ts), held equal by a unit test. client_proto() { case $1 in claude-code) echo anthropic_messages ;; codex) echo responses ;; *) echo chat_completions ;; esac } # The model check against the catalogue, the page's rule (connect-recipes.ts toolReady + connectDefaultModel): a # model suits a tool when the catalogue lists it for text on the tool's protocol and — once the catalogue publishes # capabilities at all — an enabled text capability on that protocol has streaming AND tools «proven». The pool is the # models that suit — and it stays EMPTY when none does (Sol R5-M1: a switched-off or unproven model is never picked # and never called proven). R6-m1 (round 7): «capabilities» has three states — absent (legacy metadata: the row is # judged by its protocols alone while no row publishes any), valid (an array, judged as above) and malformed (an object, # a string, a number): a malformed row is skipped, never read as absent, so wrong-typed metadata cannot open the pool. The best is the newest Sonnet of the pool by released_on, else the newest, else the # first; a tier pick (fable, opus, sonnet, haiku) is the newest pool model whose id names it. R5-m2: an id holding a # space or a control character is not a model id at all — it is dropped before anything reads it, so no id can # break the line transport below. Prints eight lines: the verdict for KUMO_MODEL (ok, absent, protocol — listed, # but not for this tool's protocol —, disabled, unproven; «ok» for an empty KUMO_MODEL), the best model, the fable, # opus, sonnet and haiku picks (empty when the pool has none), 1 when the catalogue publishes capabilities (else 0), # and up to five pool models, newest first, separated by spaces (U4). KUMO_JS_PICK='var cat = JSON.parse(READ(ENV.KUMO_CATALOG)); var proto = ENV.KUMO_PROTO, want = ENV.KUMO_MODEL || ""; var all = Array.isArray(cat) ? cat : (cat && cat.data) || []; var plain = function (r) { return r && typeof r.id === "string" && /^[A-Za-z0-9._:\/@+-]{1,200}$/.test(r.id); }; var odd = all.filter(function (r) { return r && typeof r.id === "string" && r.id !== "" && !plain(r); }).length; var text = all.filter(function (r) { return plain(r) && Array.isArray(r.modality_codes) && r.modality_codes.indexOf("text_generation") >= 0; }); var rows = text.filter(function (r) { return Array.isArray(r.protocol_codes) && r.protocol_codes.indexOf(proto) >= 0; }); var state = function (r) { return r.capabilities === undefined || r.capabilities === null ? "absent" : Array.isArray(r.capabilities) ? "valid" : "malformed"; }; var caps = function (r) { return state(r) === "valid" ? r.capabilities : null; }; var known = rows.some(function (r) { return state(r) === "valid"; }); var on = function (r, full) { return (caps(r) || []).some(function (c) { return c && c.protocol_code === proto && c.modality_code === "text_generation" && c.status === "enabled" && (!full || (c.streaming_status === "proven" && c.tools_status === "proven")); }); }; var ready = function (r) { return state(r) !== "malformed" && (!known || on(r, true)); }; var pool = rows.filter(ready); var date = function (r) { return typeof r.released_on === "string" ? r.released_on : ""; }; var newest = function (list) { return list.reduce(function (best, r) { if (best === null) return r; if (date(r) === "") return best; if (date(best) === "") return r; return date(r) > date(best) ? r : best; }, null); }; var named = function (word) { var r = newest(pool.filter(function (x) { return x.id.indexOf(word) >= 0; })); return r === null ? "" : r.id; }; var best = named("sonnet") || (newest(pool) || { id: "" }).id; var order = pool.map(function (r, i) { return { r: r, i: i }; }).sort(function (x, y) { var a = date(x.r), b = date(y.r); if (a !== b) return a === "" ? 1 : b === "" ? -1 : a > b ? -1 : 1; return x.i - y.i; }).slice(0, 5).map(function (x) { return x.r.id; }); var verdict = "ok"; if (want !== "") { var row = rows.filter(function (r) { return r.id === want; })[0]; if (row === undefined) verdict = text.some(function (r) { return r.id === want; }) ? "protocol" : "absent"; else if (known && caps(row) !== null && !on(row, false)) verdict = "disabled"; else if (!ready(row)) verdict = "unproven"; } OUT([verdict, best, named("fable"), named("opus"), named("sonnet"), named("haiku"), known ? "1" : "0", order.join(" "), String(odd)].join("\n") + "\n"); ' pick_python() { python3 -c ' import json, os, re, sys e = os.environ with open(e["KUMO_CATALOG"], encoding="utf-8") as h: cat = json.load(h) proto, want = e["KUMO_PROTO"], e.get("KUMO_MODEL", "") rows = cat if isinstance(cat, list) else (cat.get("data") if isinstance(cat, dict) else None) or [] plain = lambda r: isinstance(r, dict) and isinstance(r.get("id"), str) and re.fullmatch(r"[A-Za-z0-9._:/@+-]{1,200}", r["id"]) is not None odd = len([r for r in rows if isinstance(r, dict) and isinstance(r.get("id"), str) and r["id"] and not plain(r)]) # R14-m1 (round 15): list-valued codes, exact membership (the twin of KUMO_JS_PICK): a STRING holding the word is no list. # BUG_FIX_CONTEXT (round 15, R14-m1, sol-edge-r14.md): «proto in value» matched «not_anthropic_messages_suffix …» by # substring, so python3 wrote a model the node engine and PowerShell refuse. has = lambda r, f, v: isinstance(r.get(f), list) and v in r[f] text = [r for r in rows if plain(r) and has(r, "modality_codes", "text_generation")] rows = [r for r in text if has(r, "protocol_codes", proto)] state = lambda r: "absent" if r.get("capabilities") is None else ("valid" if isinstance(r.get("capabilities"), list) else "malformed") caps = lambda r: r.get("capabilities") if state(r) == "valid" else None known = any(state(r) == "valid" for r in rows) def on(r, full): return any(isinstance(c, dict) and c.get("protocol_code") == proto and c.get("modality_code") == "text_generation" and c.get("status") == "enabled" and (not full or (c.get("streaming_status") == "proven" and c.get("tools_status") == "proven")) for c in (caps(r) or [])) ready = lambda r: state(r) != "malformed" and ((not known) or on(r, True)) pool = [r for r in rows if ready(r)] date = lambda r: r["released_on"] if isinstance(r.get("released_on"), str) else "" def newest(lst): best = None for r in lst: if best is None: best = r elif date(r) == "": continue elif date(best) == "" or date(r) > date(best): best = r return best def named(word): r = newest([x for x in pool if word in x["id"]]) return "" if r is None else r["id"] best = named("sonnet") or (newest(pool) or {"id": ""})["id"] dated = sorted([r for r in pool if date(r)], key=date, reverse=True) order = [r["id"] for r in dated + [r for r in pool if not date(r)]][:5] verdict = "ok" if want: row = next((r for r in rows if r["id"] == want), None) if row is None: verdict = "protocol" if any(r["id"] == want for r in text) else "absent" elif known and caps(row) is not None and not on(row, False): verdict = "disabled" elif not ready(row): verdict = "unproven" sys.stdout.write("\n".join([verdict, best, named("fable"), named("opus"), named("sonnet"), named("haiku"), "1" if known else "0", " ".join(order), str(odd)]) + "\n") ' /dev/null } # P1, for one tool before anything is written: is $model (empty: none named) served, switched on and proven on this # tool's protocol by THIS gateway? When not, the gateway's best proven model takes its place — said in one plain line # with the reason (U4: not on this gateway, not on this tool's protocol, switched off, not proven), up to five models # that are proven, and the command that changes it; on a terminal it asks first. With no model named, the best one is # taken. When the list cannot be read, the named model is kept and said to be unchecked. R5-M1: when the gateway has # NO model proven with the tool, nothing is picked: the tool is skipped (KUMO_SKIP=1) — on a terminal a model the # reader named may still be written at their explicit word ([y/N], no by default). Sets model, KUMO_PICK_{FABLE,OPUS, # SONNET,HAIKU} (the tier picks, for Claude Code's slots), KUMO_MODEL_NOTE (why the model was swapped) and KUMO_SKIP. check_model() { KUMO_PICK_FABLE="" KUMO_PICK_OPUS="" KUMO_PICK_SONNET="" KUMO_PICK_HAIKU="" KUMO_MODEL_NOTE="" KUMO_SKIP="" fetch_catalog if [ -z "$KUMO_CAT" ]; then if [ -n "$model" ]; then warn "The model list of $obase could not be read, so $model was kept without a check." \ "Список моделей $obase прочитать не удалось, поэтому модель $model оставлена без проверки." fi return 0 fi proto=$(client_proto "$client") if [ "$KUMO_ENGINE" = python3 ]; then picked=$(KUMO_CATALOG=$KUMO_CAT KUMO_PROTO=$proto KUMO_MODEL=$model pick_python) || picked="" else picked=$(KUMO_CATALOG=$KUMO_CAT KUMO_PROTO=$proto KUMO_MODEL=$model run_js "$KUMO_JS_PICK" 2>/dev/null) || picked="" fi verdict=$(printf '%s\n' "$picked" | sed -n 1p) best=$(printf '%s\n' "$picked" | sed -n 2p) KUMO_PICK_FABLE=$(printf '%s\n' "$picked" | sed -n 3p) KUMO_PICK_OPUS=$(printf '%s\n' "$picked" | sed -n 4p) KUMO_PICK_SONNET=$(printf '%s\n' "$picked" | sed -n 5p) KUMO_PICK_HAIKU=$(printf '%s\n' "$picked" | sed -n 6p) known=$(printf '%s\n' "$picked" | sed -n 7p) choices=$(printf '%s\n' "$picked" | sed -n 8p | sed 's/ / · /g') odd_ids=$(printf '%s\n' "$picked" | sed -n 9p) # R11-M1 (round 12b): rows whose id is not a plain model id were dropped by the engine; said once per run. case $odd_ids in ''|0|*[!0-9]*) ;; *) if [ -z "$KUMO_ODD_SAID" ]; then KUMO_ODD_SAID=1 warn "Models skipped from this gateway's list: $odd_ids — their id is not a plain model id (letters, digits and . _ : / @ + -, up to 200 characters)." \ "Пропущено моделей из списка шлюза: $odd_ids — их id не похож на обычный id модели (буквы, цифры и . _ : / @ + -, до 200 символов)." fi ;; esac if [ -z "$verdict" ]; then if [ -n "$model" ]; then warn "The model list of $obase could not be read, so $model was kept without a check." \ "Список моделей $obase прочитать не удалось, поэтому модель $model оставлена без проверки." fi return 0 fi if [ -z "$best" ]; then # R5-M1: an empty proven pool stays empty — no disabled or unproven model is ever picked or called proven. tty=$(kumo_tty) if [ -n "$model" ]; then warn "This gateway has no model proven with $name yet ($model included)." \ "У этого шлюза пока нет модели, проверенной с $name (в том числе $model)." if ( : <"$tty" ) 2>/dev/null; then ask_yes "Write $model for $name anyway? It may not answer on it. [y/N] " "Всё равно записать $model для $name? Он может на ней не ответить. [д/Н] " no if [ "$?" = 0 ]; then warn "$model was written at your word; $name may not answer on it." "Модель $model записана, как вы сказали; $name может на ней не ответить." return 0 fi fi else warn "This gateway has no model proven with $name yet, so there is none to pick." \ "У этого шлюза пока нет модели, проверенной с $name, поэтому выбрать нечего." fi KUMO_SKIP=1 return 0 fi if [ -z "$model" ]; then model=$best # #533: OpenCode gets no default (the pick only stands in for an unreadable list) and Claude Code only its tier slots # (said in the summary), so neither announces a main model. case $(model_need "$client") in list|slots) return 0 ;; esac if [ "$known" = 1 ]; then say "Model for $name: $model (the newest this gateway has proven with $name)." \ "Модель для $name: $model (самая новая из проверенных этим шлюзом с $name)." else say "Model for $name: $model (the newest this gateway lists for $name; it publishes no test results yet)." \ "Модель для $name: $model (самая новая из моделей этого шлюза для $name; результатов проверки он пока не публикует)." fi return 0 fi if [ "$verdict" = ok ] || [ "$best" = "$model" ]; then return 0; fi case $verdict in unproven) why_en="$model is not proven with $name on this gateway" why_ru="Модель $model не проверена на этом шлюзе с $name" ;; protocol) why_en="$model does not support $name's protocol ($(proto_label "$proto"))" why_ru="Модель $model не поддерживает протокол $name ($(proto_label "$proto"))" ;; disabled) why_en="$model is switched off on this gateway for $name" why_ru="Модель $model отключена на этом шлюзе для $name" ;; *) why_en="$model is not on this gateway" why_ru="Модели $model нет на этом шлюзе" ;; esac tty=$(kumo_tty) if ( : <"$tty" ) 2>/dev/null; then choice_line # With a terminal the reader decides; no clear answer takes the default — the proven model. ask_yes "$why_en — use $best instead? [Y/n] " "$why_ru — взять $best? [Д/н] " if [ "$?" = 1 ]; then warn "$model was kept at your word; $name may not answer on it." "Модель $model оставлена, как вы сказали; $name может на ней не ответить." return 0 fi model=$best KUMO_MODEL_NOTE=$why_en return 0 fi say "$why_en — picked $best. To change it: $(model_cmd)" "$why_ru — выбрана $best. Сменить: $(model_cmd)" choice_line model=$best KUMO_MODEL_NOTE=$why_en } # #533 (owner 2026-11-08, follow-up the same day): what a tool needs of a model when the command names none — the # per-tool table in the header («Models without a choice»): slots (Claude Code: its /model menu shows Fable, Opus, Sonnet, # Haiku, so each tier slot gets the newest model of that family the KEY allows — the list is read with the key — and a # family the key has none of stays unset; no top-level «model»), list (OpenCode: the model list only, no default), auto # (Codex, OpenClaw, Pi: the tool cannot start on Kumo without one, so the key's best proven model for its protocol). model_need() { case $1 in claude-code) echo slots ;; opencode) echo list ;; *) echo auto ;; esac } # #533 final Codex P1 (codex-review of ed622786e..172f2103f): a gateway base the key may be sent to — https:// with a # host, always. BUG_FIX_CONTEXT (#533 Codex r2 #2): plain http to loopback was accepted in normal operation; it is now # accepted ONLY under the test-only switch KUMO_TEST_ALLOW_HTTP=1 (KUMO_HTTP_OK, read and unset at start, never printed # by the page), and even then only to 127.0.0.1, localhost, [::1] and host.docker.internal (the PowerShell-in-Docker # harness's name for its host), with an optional numeric port and a path; nothing else in the authority (no «@», no # query or fragment right after the host). PowerShell's twin: Base-Ok. base_ok() { case $1 in https://[!/?#@]*) return 0 ;; http://*) if [ "$KUMO_HTTP_OK" != 1 ]; then return 1; fi ;; *) return 1 ;; esac bo_auth=$(printf '%s' "$1" | sed 's#^http://##; s#/.*##') case $bo_auth in 127.0.0.1|localhost|'[::1]'|host.docker.internal) return 0 ;; 127.0.0.1:*|localhost:*|'[::1]':*|host.docker.internal:*) bo_port=$(printf '%s' "$bo_auth" | sed 's#.*:##') case $bo_port in ''|*[!0-9]*) return 1 ;; esac return 0 ;; esac return 1 } # #533 (owner follow-up 2026-11-08): Claude Code with no model named — no main model is written (model=""), and its tier # slots take the key's newest model of each family (claude_slots with KUMO_AUTO_SLOTS: the KUMO_PICK_* check_model # read from the key's list). A key with no Claude model proven with Claude Code is not connected (R5-M1's rule: an empty # pool writes nothing) and the run says to allow Claude models in the key. claude_auto() { KUMO_AUTO_SLOTS="" KUMO_UNREAD="" if [ "$client" != claude-code ] || [ -n "$KUMO_NAMED" ]; then return 0; fi # BUG_FIX_CONTEXT (#533 final Codex P2): Claude Code was exempt from the unreadable-list stop, so a failed or malformed # keyed list still wrote the key and the gateway with no slot behind them. check_model leaves model empty with no # KUMO_SKIP only when the key's list could not be read or parsed: KUMO_UNREAD then stops Claude Code like the others. # BUG_FIX_CONTEXT (#533 Codex r2 #3): slots the command named (--opus …, claude-code.opus=) were exempt (the old A13-3 # rule), so an unreadable or malformed key list still wrote the key and the gateway; that exception is gone — with # the key's list unread, Claude Code with no main model is never set up, tiers named or not. if [ -z "$model" ] && [ -z "$KUMO_SKIP" ]; then KUMO_UNREAD=1; fi model="" KUMO_AUTO_SLOTS=1 if [ -n "$KUMO_SKIP" ]; then warn "This key allows no Claude model, so Claude Code's /model menu has nothing behind Opus, Sonnet or Haiku. Allow Claude models in the key's settings (console → Keys), then run the command again." \ "Ключ не даёт моделей Claude, поэтому за Opus, Sonnet и Haiku в меню /model ничего не стоит. Разрешите модели Claude в настройках ключа (консоль → «Ключи») и выполните команду ещё раз." fi } # U4: the models a refused one may be swapped for — up to five, newest first — as one line. choice_line() { if [ -z "$choices" ]; then return 0; fi if [ "$known" = 1 ]; then say " Proven with $name on this gateway: $choices" " Проверены с $name на этом шлюзе: $choices" else say " Listed for $name on this gateway: $choices" " Есть на этом шлюзе для $name: $choices" fi } # The protocol's name as the reader may meet it in Kumo's catalogue. proto_label() { case $1 in anthropic_messages) echo "Anthropic Messages" ;; responses) echo "Responses" ;; *) echo "Chat Completions" ;; esac } # The keyless command that switches $client to another model later (the «model» verb), as the reader pastes it. model_cmd() { extra="" if [ "$abase" != "$KUMO_DEFAULT_ANTHROPIC" ]; then extra=" --base $(sw "$abase")"; fi if [ "$KUMO_RU" = 1 ]; then printf '%s' "curl -fsSL $KUMO_SITE/connect.sh | sh -s -- model $client $extra --ru" else printf '%s' "curl -fsSL $KUMO_SITE/connect.sh | sh -s -- model $client $extra" fi } # The terminal the script talks to beside the piped script: /dev/tty, or KUMO_TTY — the tests' seam (a file to read # from, or a missing path for «no terminal»); a reader never sets it. kumo_tty() { if [ -n "$KUMO_TTY" ]; then printf '%s' "$KUMO_TTY"; else printf '%s' /dev/tty; fi } # The key, read from the terminal with echo off, so it never reaches the shell history (--ask-key). read_key() { tty=$(kumo_tty) # In a subshell: a failed redirection on the special builtin : would end dash itself. if ! ( : <"$tty" ) 2>/dev/null; then die 2 "there is no terminal to ask for the key: pass it as KUMO_KEY instead of --ask-key" \ "нет терминала, чтобы спросить ключ: передайте его через KUMO_KEY вместо --ask-key" fi if [ "$KUMO_RU" = 1 ]; then printf '%s' "Вставьте ключ Kumo: " >&2; else printf '%s' "Paste your Kumo key: " >&2; fi trap 'stty echo <"$tty" 2>/dev/null; kumo_cleanup; exit 130' INT TERM HUP stty -echo <"$tty" 2>/dev/null IFS= read -r key <"$tty" || true stty echo <"$tty" 2>/dev/null trap 'kumo_cleanup; exit 130' INT TERM HUP printf '\n' >&2 key=$(printf '%s' "$key" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') } # After a warning the reader may want to stop before anything is written: ask on the terminal (Enter continues); # with no terminal to ask, the run goes on and the warning stands. ask_continue() { tty=$(kumo_tty) if ! ( : <"$tty" ) 2>/dev/null; then warn "There is no terminal to ask, so the setup goes on." "Спросить негде (нет терминала), поэтому настройка продолжается." return 0 fi # Unclear three times: the safe answer wins — stop with nothing changed. ask_yes "Continue and write the settings? [Y/n] " "Продолжить и записать настройки? [Y/n] (да/нет) " case $? in 0) return 0 ;; 1) die 1 "Stopped: nothing was changed." "Остановлено: ничего не изменено." ;; *) die 1 "No clear answer, so nothing was changed." "Ответ не распознан, поэтому ничего не изменено." ;; esac } # A yes/no question on the terminal (the caller made sure there is one): 0 yes, 1 no, 2 no clear answer. m1: whole # words only. dash (and bash in the C locale) compares bytes, and «д», «Д», «н», «Н» share their lead byte, so a # pattern like [нН]* read «да» as «no». Empty takes the default — yes, or no when $3 is «no» — and anything else is # asked again, at most three times. ask_yes() { tty=$(kumo_tty) tries=0 while [ "$tries" -lt 3 ]; do tries=$((tries + 1)) if [ "$KUMO_RU" = 1 ]; then printf '%s' "$2" >&2; else printf '%s' "$1" >&2; fi answer="" IFS= read -r answer <"$tty" || true answer=$(printf '%s' "$answer" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') if [ -z "$answer" ] && [ "$3" = no ]; then return 1; fi case $answer in ""|y|Y|yes|Yes|д|Д|да|Да) return 0 ;; n|N|no|No|н|Н|нет|Нет) return 1 ;; esac say "Please answer y (yes) or n (no)." "Ответьте «да» или «нет» (y или n)." >&2 done return 2 } # Put a finished temp file in place: nothing when unchanged (but the mode is still made 600); otherwise back up, then # rename. The target is the REAL file (real_path): a config that is a symlink was followed before the temp file was # made beside its target, so the rename is atomic and the link itself is left as it was. Sets KUMO_BACKUP and # KUMO_CHANGED; on a failure (status 1) KUMO_IF_WHY names the cause for install_or_stop (Opus n15-5, round 16): # notfile, copy (the backup copy failed), nolink (no hard links), names (no free backup name), replace (mv failed). install_file() { target=$1 tmp=$2 KUMO_BACKUP="" KUMO_CHANGED=1 KUMO_IF_WHY="" # m2: never rename over (or into) a folder, a FIFO or a device. if [ -e "$target" ] && [ ! -f "$target" ]; then rm -f "$tmp" KUMO_IF_WHY=notfile return 1 fi chmod 600 "$tmp" if [ -f "$target" ] && cmp -s "$tmp" "$target"; then rm -f "$tmp" # BUG_FIX_CONTEXT (round 12b, R11-M3 / A11-1): an unchanged file got only «chmod 600», which leaves an ACL entry # in place — a round-10 settings.json with an inherited «everyone allow read», re-run, stayed readable while the # run said «unchanged». The kept file is now stripped and proven like a new one; when that fails the run stops # and the reader's file is kept exactly as it was (never removed: it is theirs). if ! key_private "$target"; then die 1 "$target holds the key, but could not be made readable by you alone (an access-list entry on it could not be removed), so the run stops here. The file is left as it was: remove the entry (macOS: chmod -N; Linux: setfacl -b), then run the command again." \ "$target содержит ключ, но сделать его доступным только вам не удалось (запись в списке доступа не снимается), поэтому работа остановлена. Файл оставлен как был: снимите запись (macOS: chmod -N; Linux: setfacl -b) и выполните команду ещё раз." fi KUMO_CHANGED=0 return 0 fi if [ -f "$target" ]; then # A10-1: the backup holds the old key; it is proven entry-free while still empty. R11-M2: created, proven and # filled by key_write (one descriptor). A12-1 (round 13): it is born in a private stage (key_temp). A14-3 (round # 15): it is PUBLISHED with no-replace semantics — a hard link (ln -n) of the staged file to the first free name, # which fails when anything holds that name, a link to a folder included — and then verified at the EXACT name: a # regular file, not a link, the staged file itself (same dev:ino; with no stat, the same bytes). A folder that # appeared at the name after the look receives the link INSIDE it (ln's directory rule): that link of ours is taken # out again and the next name is tried; a name taken by anything else is the next name too. Nothing another run or # program put there is ever written over or into, and the undo path names the verified backup only. A file system # with no hard links (ln fails on a free name) stops here with the reader's file untouched. # BUG_FIX_CONTEXT (round 15, A14-3, audit-astra-r14.md): the free-name look did not reserve the name — «mv -f» # then moved the backup INTO a folder created at that name in between (or wrote over a file there), and the # advertised undo command refused («this copy is missing»), leaving the original unrestorable by that command. key_temp "$target" kb_tmp=$KUMO_T kb_rc=0 key_write "$kb_tmp" cat "$target" || kb_rc=$? if [ "$kb_rc" != 0 ]; then key_write_said "$kb_rc" "$kb_tmp"; rm -f "$kb_tmp"; KUMO_IF_WHY=copy; return 1; fi kb_id=$(node_id "$kb_tmp") || kb_id="" kb_leaf=$(printf '%s' "$kb_tmp" | sed 's#.*/##') kb_base="$target.bak-$(stamp)" kb_try=$kb_base kb_n=1 while :; do if [ ! -e "$kb_try" ] && [ ! -L "$kb_try" ]; then if ln -n "$kb_tmp" "$kb_try" 2>/dev/null; then if [ -f "$kb_try" ] && [ ! -L "$kb_try" ]; then if [ -n "$kb_id" ]; then if [ "$(node_id "$kb_try")" = "$kb_id" ]; then break; fi elif cmp -s "$kb_tmp" "$kb_try"; then break fi fi # Not our file at that exact name: our link went inside a folder there. Only OUR link is removed. kb_in="$kb_try/$kb_leaf" if [ -f "$kb_in" ] && [ ! -L "$kb_in" ] && { [ -z "$kb_id" ] || [ "$(node_id "$kb_in")" = "$kb_id" ]; }; then rm -f "$kb_in"; fi elif [ ! -e "$kb_try" ] && [ ! -L "$kb_try" ]; then rm -f "$kb_tmp" KUMO_IF_WHY=nolink return 1 fi fi kb_n=$((kb_n + 1)) if [ "$kb_n" -gt 99 ]; then rm -f "$kb_tmp"; KUMO_IF_WHY=names; return 1; fi kb_try="$kb_base-$kb_n" done rm -f "$kb_tmp" KUMO_BACKUP=$kb_try chmod 600 "$KUMO_BACKUP" fi if ! mv -f "$tmp" "$target"; then rm -f "$tmp" 2>/dev/null KUMO_IF_WHY=replace return 1 fi chmod 600 "$target" # N2 / R6-m4: the undo file puts this file back — its copy, or no file at all when there was none. if [ -n "$KUMO_BACKUP" ]; then undo_add r "$KUMO_BACKUP" "$target"; else undo_add d "$target"; fi } # Opus n15-5 (round 16): install_file, and on a failure stop with WHY and WHAT TO DO — never a bare «cannot write». # The reader's file is never changed by a failed install_file: every cause stops before (or instead of) the rename. # BUG_FIX_CONTEXT (round 16, n15-5, audit-opus-r15.md): every failure ended «kumo: cannot write » — an ln # refused by a file system without hard links, a full disk and a folder that is not writable all read the same, and # none said what to do. install_or_stop() { if install_file "$1" "$2"; then return 0; fi ios_dir=$(dirname "$1") case $KUMO_IF_WHY in notfile) die 1 "$1 is not a regular file (a folder, a pipe or a device is in its place), so Kumo's settings were not written. Nothing was changed. Move it away ($(sq "$1")), then run the command again." \ "$1 — не обычный файл (на его месте папка, канал или устройство), поэтому настройки Kumo не записаны. Ничего не изменено. Уберите его ($(sq "$1")) и выполните команду ещё раз." ;; copy) die 1 "Could not make a backup copy of $1: the disk may be full. Nothing was changed. Free some space (df -h shows how much is left), then run the command again." \ "Не удалось сделать резервную копию $1: возможно, диск заполнен. Ничего не изменено. Освободите место (сколько осталось, покажет df -h) и выполните команду ещё раз." ;; nolink) die 1 "Could not back up $1: the file system of $ios_dir does not support hard links, which the backup needs so that it never writes over another file. Nothing was changed. Copy the file somewhere safe yourself and add Kumo by hand, as the console shows: $KUMO_CONSOLE/#connect" \ "Не удалось сохранить резервную копию $1: файловая система папки $ios_dir не поддерживает жёсткие ссылки, а без них копия может затереть чужой файл. Ничего не изменено. Сохраните копию файла сами и добавьте Kumo вручную, как показано в консоли: $KUMO_CONSOLE/#connect" ;; names) die 1 "Could not back up $1: 99 backups of it already exist. Nothing was changed. Remove the old copies you no longer need (ls $(sq "$1").bak-*), then run the command again." \ "Не удалось сохранить резервную копию $1: таких копий уже 99. Ничего не изменено. Удалите ненужные старые копии (ls $(sq "$1").bak-*) и выполните команду ещё раз." ;; *) if [ ! -w "$ios_dir" ]; then die 1 "Could not replace $1: you cannot write to its folder $ios_dir. Nothing in the file was changed. Give yourself write access (chmod u+w $(sq "$ios_dir"); ls -ld $(sq "$ios_dir") shows who may write), then run the command again." \ "Не удалось заменить $1: у вас нет права записи в его папку $ios_dir. Сам файл не изменён. Дайте себе право записи (chmod u+w $(sq "$ios_dir"); кто может писать, покажет ls -ld $(sq "$ios_dir")) и выполните команду ещё раз." fi die 1 "Could not replace $1: the system refused to put the new file in its place. Nothing in the file was changed. Check that the disk is not full (df -h) and that no other program is holding the file, then run the command again." \ "Не удалось заменить $1: система не дала поставить новый файл на его место. Сам файл не изменён. Проверьте, что диск не заполнен (df -h) и что файл не занят другой программой, и выполните команду ещё раз." ;; esac } # The real file behind a config path: every symlink followed (relative links against the link's folder), at most 40. # Fails — and the caller refuses with a clear message — when a link cannot be read (no readlink, a loop). real_path() { p=$1 n=0 while [ -L "$p" ]; do n=$((n + 1)) if [ "$n" -gt 40 ] || ! has readlink; then return 1; fi l=$(readlink "$p") || return 1 # Sol R7-M1: a link text a record cannot carry is not followed (paths_guard then stops the run). «$( )» drops # trailing line breaks, so the text is read a second time with a mark after it: more than the one line break # readlink ends with means the text itself holds one. lx=$(readlink "$p"; printf x) if odd_path "$l" || [ "$(printf '%s' "$lx" | awk 'END { print NR }')" -gt 2 ]; then return 1; fi case $l in /*) p=$l ;; *) p="$(dirname "$p")/$l" ;; esac # The folder part made plain (no «..»), so the path named on screen is the file itself. d=$(cd "$(dirname "$p")" 2>/dev/null && pwd) || return 1 p="$d/$(printf '%s' "$p" | sed 's#.*/##')" done printf '%s' "$p" } # The config path to write: $1, or — when it is a symlink — its real file, named on screen. Sets KUMO_REAL. resolve_target() { KUMO_REAL=$(real_path "$1") || die 1 "$1 is a symlink this script cannot follow, so nothing was changed. Point it at a real file, or merge by hand." \ "$1 — ссылка, по которой скрипт не может пройти, поэтому ничего не изменено. Направьте её на настоящий файл или перенесите настройки вручную." if [ "$KUMO_REAL" != "$1" ]; then say "$1 is a link to $KUMO_REAL: that file is updated, the link stays." "$1 — ссылка на $KUMO_REAL: обновляется этот файл, ссылка остаётся." fi } # m2: a config path must name a regular file, or nothing yet. A folder, a FIFO (reading it would hang the run), a # socket or a device there is refused before anything reads, backs up or writes it; a link is judged by its target. check_plain() { plain=$(real_path "$1") || plain=$1 if [ -e "$plain" ] && [ ! -f "$plain" ]; then die 1 "$plain is not a regular file (a folder, a FIFO or a device), so it was not touched and the run stops here. Move it away and run the command again." \ "$plain — не обычный файл (папка, FIFO или устройство), поэтому он не тронут и работа остановлена. Уберите его и выполните команду ещё раз." fi } # A temp file next to the target (KUMO_T), created exclusively, removed on any way out. Not called in a command # substitution: the cleanup list must grow in this shell, not in a subshell. For files WITHOUT the key (marker, undo, # start-up file); a file that receives the key is a key_temp, written by key_write (R11-M2, round 12b). temp_for() { KUMO_T=$(mktemp "$1.kumo-tmp.XXXXXX") || die 1 "cannot write next to $1" "не удалось записать рядом с $1" clean_add "$KUMO_T" # A10-1 (round 11): no ACL entry on it before its first byte (an inherited grant would survive chmod 600 and rename). private_or_stop "$KUMO_T" } # R11-M2 (round 12b): the NAME of a key temp for the target (KUMO_T), on the cleanup list; nothing is created yet — # key_write creates it. A12-1 (round 13): the name is inside a fresh private staging folder beside the target # (stage_for, KUMO_STAGE), so the file is never born where a folder's ACL could hand another principal a way in; it is # renamed into place (same folder tree, same file system: an atomic rename) once written. key_temp() { stage_for "$1" kt_leaf=$(printf '%s' "$1" | sed 's#.*/##') KUMO_T=$(mktemp -u "$KUMO_STAGE/$kt_leaf.kumo-tmp.XXXXXX") || die 1 "cannot write next to $1" "не удалось записать рядом с $1" clean_add "$KUMO_T" } # A12-1 (round 13): a NEW staging folder beside $1 — made by mktemp -d (mode 700 from its first instant, exclusive), # proven private (stage_private), its device:inode kept (KUMO_STAGE_ID) — the only place a key file is created. The # previous stage, emptied by its rename, goes first; any stage left is removed by the cleanup. # BUG_FIX_CONTEXT (round 13, A12-1, audit-astra-r12.md): the key temp was created next to the target and made private # AFTER it existed. Under a folder with a default ACL («d:u:1002:r--») the new file is born readable to that uid — # umask 077 does not apply under a default ACL — and uid 1002 opened it in that instant, kept the descriptor and read # the key after the write: stripping the entry (or chmod) does not revoke an open descriptor. A folder made with mode # 700 gets an ACL mask of «---» (no named entry is effective), and once its entries are removed every file born in it # is 600 with none — so nobody but this user can ever open the key file, from its first instant. stage_for() { if [ -n "$KUMO_STAGE" ]; then rmdir "$KUMO_STAGE" 2>/dev/null; fi sf_dir=$(dirname "$1") KUMO_STAGE_ID="" KUMO_STAGE=$(mktemp -d "$sf_dir/.kumo-stage.XXXXXX") || die 1 "cannot write next to $1" "не удалось записать рядом с $1" clean_add "$KUMO_STAGE" KUMO_STAGE_ID=$(node_id "$KUMO_STAGE") || KUMO_STAGE_ID="" if ! stage_private "$KUMO_STAGE"; then rm -rf "$KUMO_STAGE" # The same words as a key file that cannot be made private (private_or_stop): that is what failed. die 1 "$1 could not be made readable by you alone, so the key was not written there. Check the permissions of its folder, then run the command again." \ "Не удалось сделать $1 доступным только вам, поэтому ключ туда не записан. Проверьте права его папки и выполните команду ещё раз." fi } # A12-1: is staging folder $1 this user's, a real folder (not a link), mode exactly 700, with NO access-list entry # (acl_none strips and proves, as for a key file) and EMPTY (nothing slipped in while a folder ACL still applied)? stage_private() { [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] || return 1 chmod 700 "$1" 2>/dev/null || return 1 acl_none "$1" || return 1 case $(mode_of "$1") in drwx------|drwx------@|drwx------.) ;; *) return 1 ;; esac [ -z "$(ls -A "$1" 2>/dev/null)" ] || return 1 stage_same "$1" } # A12-1: is folder $1, right now, the staging folder this run proved (KUMO_STAGE): this user's, not a link, mode 700, # the same device:inode? Nobody else can change the mode or the entries of a folder this user owns. stage_same() { [ -n "$KUMO_STAGE" ] && [ "$1" = "$KUMO_STAGE" ] || return 1 [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] || return 1 if [ -n "$KUMO_STAGE_ID" ]; then [ "$(node_id "$1")" = "$KUMO_STAGE_ID" ] || return 1; fi case $(mode_of "$1") in drwx------|drwx------@|drwx------.) return 0 ;; esac return 1 } # The inode number of a path or of a descriptor's file (/dev/fd/N), following links (stat -L), or nothing. ino_of() { io=$(stat -L -c '%i' "$1" 2>/dev/null) || io=$(stat -L -f '%i' "$1" 2>/dev/null) || return 1 case $io in ''|*[!0-9]*) return 1 ;; esac printf '%s' "$io" } # May anyone but this user replace an entry of folder $1? No when the folder is this user's (or root's) and neither # its group nor others may write — or the sticky bit keeps them to their own entries (/tmp). dir_tight() { dm=$(ls -ld "$1" 2>/dev/null | sed -n '1s/[[:space:]].*//p') case $dm in d????????t|d????????T|d????????t+|d????????T+|d????????t@|d????????T@) ;; d????w*|d???????w*) return 1 ;; d*) ;; *) return 1 ;; esac if [ -O "$1" ]; then return 0; fi do=$(stat -L -c '%u' "$1" 2>/dev/null) || do=$(stat -L -f '%u' "$1" 2>/dev/null) || return 1 [ "$do" = 0 ] } # R11-M2: is pathname $2 the file on descriptor $1 — a regular file, not a link, the same inode, inside the proven # staging folder (A12-1: stage_same)? 0 yes; 122 no (it was replaced); 123 the descriptor cannot be read here (no # /dev/fd for stat) and the folder that holds the stage is one others can change or carries a granting ACL entry. # BUG_FIX_CONTEXT (round 13, R12-M1, sol-edge-r12.md): the no-descriptor fallback trusted the folder's MODE alone # (dir_tight), so a mode-700 folder with «everyone allow …,add_file,delete_child» let another principal move the # freshly created inode away and put a file in its place; the key then went into the original, ACL-readable inode. The # temp now lives in the private stage (nobody else can add, move or open an entry there), and the fallback also needs # the folder holding the stage to be one only this user can change — mode AND access list (acl_free) — so the stage # itself cannot be swapped either. key_same() { if [ ! -f "$2" ] || [ -L "$2" ]; then return 122; fi ks_dir=$(dirname "$2") stage_same "$ks_dir" || return 122 ks_fd=$(ino_of "/dev/fd/$1") || ks_fd="" if [ -z "$ks_fd" ]; then ks_up=$(dirname "$ks_dir") if dir_tight "$ks_up" && acl_free "$ks_up"; then return 0; fi return 123 fi ks_at=$(ino_of "$2") || return 122 if [ "$ks_at" = "$ks_fd" ]; then return 0; fi return 122 } # R11-M2 (round 12b): key_write — create EXCLUSIVELY on descriptor 9 (noclobber «>» is # O_EXCL: a name that exists, a link included, is refused), prove it private (key_private, by its name), check the # name is still the descriptor's file, run the command with its output on the DESCRIPTOR, and check the name again. # All of it in one subshell, so the descriptor never outlives the file it was made for. Its status: the command's own # (0: written), 120 the name exists, 121 not private, 122 replaced, 123 no way to compare in a folder others can change. # BUG_FIX_CONTEXT (round 12b, R11-M2, sol-edge-r11.md): the temp was proven private by its NAME and then written by its # name; a writer of the folder swapped the name for a link to an ACL-readable file between the check and the write, # and the key landed there. Now only the descriptor opened at creation receives the key, and the name is compared with # it after the proof. (A persistent «exec 9>» in the main shell is not used: with an EXIT trap set, macOS bash 3.2 # — /bin/sh — span in its signal trampoline at a later function return, measured 2026-11-06.) key_write() { kw_path=$1 shift if [ -e "$kw_path" ] || [ -L "$kw_path" ]; then return 120; fi ( trap - EXIT INT TERM HUP set -C exec 9>"$kw_path" set +C # A12-1 (round 13): born with no access-list entry, read through the DESCRIPTOR's own file (Linux: getfacl follows # /dev/fd/9 to the inode) — an entry there means another principal may have opened it already, and no strip can # revoke that, so the key never goes in. In the stripped stage a new file has none. if [ "$(uname -s 2>/dev/null)" = Linux ] && has getfacl; then acl_free /dev/fd/9 || exit 121 fi if ! key_private "$kw_path"; then key_same 9 "$kw_path" || exit "$?" exit 121 fi key_same 9 "$kw_path" || exit "$?" "$@" >&9 || exit "$?" key_same 9 "$kw_path" || exit "$?" exit 0 ) } # R11-M2: say a key_write refusal (120–123) and stop; any other status is handed back to the caller. key_write_said() { case $1 in 120|121) rm -f "$2" 2>/dev/null die 1 "$2 could not be made readable by you alone, so the key was not written there. Check the permissions of its folder, then run the command again." \ "Не удалось сделать $2 доступным только вам, поэтому ключ туда не записан. Проверьте права его папки и выполните команду ещё раз." ;; 122) die 1 "$2 was replaced while it was being made private, so the key was not written there. Someone else can change its folder: check its permissions, then run the command again." \ "$2 подменили, пока его делали доступным только вам, поэтому ключ туда не записан. Его папку может менять кто-то ещё: проверьте её права и выполните команду ещё раз." ;; 123) # A12-1 (round 13): $2 is inside the stage; the folder to fix is the one holding the stage. kws_dir=$(dirname "$(dirname "$2")") # n13-5 (round 14): the advice follows the check that failed — the mode (dir_tight): chmod go-w; else the access # list (acl_free): chmod -N on macOS, setfacl -b on Linux. # BUG_FIX_CONTEXT (round 14, n13-5, audit-opus-r13.md): «chmod go-w» was said for a 700 folder whose ACL granted # others; following it changed nothing, and the re-run stopped the same way. kws_fix="chmod go-w $(sq "$kws_dir")" if dir_tight "$kws_dir"; then if [ "$(uname -s 2>/dev/null)" = Darwin ]; then kws_fix="chmod -N $(sq "$kws_dir")"; else kws_fix="setfacl -b $(sq "$kws_dir")"; fi fi die 1 "$kws_dir is a folder others can change, and this system gives no way to check the file written there, so the key was not written. Run: $kws_fix, then run the command again." \ "$kws_dir — папка, которую могут менять другие, а проверить записанный туда файл эта система не даёт, поэтому ключ не записан. Выполните: $kws_fix и запустите команду ещё раз." ;; esac return "$1" } # key_write, and any failure stops the run (a refusal said by key_write_said; a failed command with its folder, the # likely cause — no write access, else a full disk — and what to do: Opus n15-5, round 16). key_write_or_stop() { kwo_path=$1 key_write "$@" kwo=$? if [ "$kwo" = 0 ]; then return 0; fi key_write_said "$kwo" "$kwo_path" rm -f "$kwo_path" # Opus n15-5 (round 16): the folder the reader knows (a stage's parent, not the stage), why, and what to do. kwo_dir=$(dirname "$kwo_path") case $kwo_dir in */.kumo-stage.*) kwo_dir=$(dirname "$kwo_dir") ;; esac if [ ! -w "$kwo_dir" ]; then die 1 "Could not write in $kwo_dir: you cannot write to this folder. Nothing was changed. Give yourself write access (chmod u+w $(sq "$kwo_dir")), then run the command again." \ "Не удалось записать в $kwo_dir: у вас нет права записи в эту папку. Ничего не изменено. Дайте себе право записи (chmod u+w $(sq "$kwo_dir")) и выполните команду ещё раз." fi die 1 "Could not write in $kwo_dir: the disk may be full. Nothing was changed. Free some space (df -h shows how much is left), then run the command again." \ "Не удалось записать в $kwo_dir: возможно, диск заполнен. Ничего не изменено. Освободите место (сколько осталось, покажет df -h) и выполните команду ещё раз." } # R11-M5 (round 12b): the key bytes, as JSON / TOML serialise them, are in file $1 — or there is no key in this run. # #533 r1: the serialised key reaches awk as the first line of its stdin («-», then the file); it was grep's argument # (-e), visible in the process list. A fixed-string search, as grep -F was. key_in() { [ -z "$key" ] && return 0 printf '%s\n' "$(jsraw "$key")" | awk 'NR == FNR { s = $0; next } index($0, s) > 0 { found = 1; exit } END { exit found ? 0 : 1 }' - "$1" 2>/dev/null } # R11-M5: a result that lacks the key is never published — the temp goes, the run stops with one plain line. key_in_or_stop() { if key_in "$1"; then return 0; fi rm -f "$1" die 1 "The settings for $2 could not be put together (the key is missing from the result), so nothing was written there. Run the command again; if it repeats, set it up by hand (console → Connect → «Manual»)." \ "Настройки для $2 не удалось собрать (в результате нет ключа), поэтому туда ничего не записано. Выполните команду ещё раз; если повторится — настройте вручную (консоль → «Подключение» → «Вручную»)." } # A10-1 (round 11): may this freshly created, still EMPTY file receive the key? Mode 600 and NO access-list entry at # all — stricter than acl_free, which admits deny entries on the undo folder: an entry on a key file is removed, then # its absence is proven, or no. # macOS chmod -N strips every entry (an inherited grant from the folder included — the mode does not show it, and # «@» hides the «+»); /bin/ls -le must then print exactly ONE line for the file; # Linux getfacl, when it is here: an extended entry is removed with setfacl -b (when it is here) and getfacl must # then print the base entries only — an entry getfacl sees that nothing here can remove stops the run; # setfacl without getfacl: setfacl -b must succeed (it removes every extended entry by definition), and GNU # coreutils ls, when it is the ls here, must then show no «+»; # neither tool: GNU coreutils ls must show no «+» (a «+» is an entry seen and not removable); any other ls # (busybox, uutils — which never print «+») gives no reading, and the mode 600 below decides (round 12, M-1); # else the mode alone (no ACL tool this script can read — the platforms the undo rule already refuses). # Any tool that fails is a no. # RATIONALE (round 12, M-1) — why a Linux key file with no ACL tool at all is accepted on its mode: # Q: Is mode 600 enough when neither getfacl nor setfacl is here and ls cannot show ACLs? # A: Yes, for the file this run has just created. (1) It is born by mktemp / set -C under umask 077 inside a folder # this run resolved, so the only way an entry can reach it is a DEFAULT ACL inherited from that folder — and a # default ACL is made with setfacl, which is not on this machine. (2) Even then a POSIX ACL cannot widen access # past the mode: chmod 600 sets the ACL mask to «---», and the mask caps every named user and group entry # (measured 2026-11-05, Debian 12 + acl, a «d:u:1002:rw» folder: the new file lists «user:1002:rw- # #effective:---», and uid 1002 gets «Permission denied»). So the strip-and-prove step is kept wherever a tool # can do it, and a machine without one loses nothing by trusting the mode. # BUG_FIX_CONTEXT (round 11, A10-1, audit-astra-r10.md): «born 600 via mktemp» proved the MODE only. A folder with # «everyone allow read,file_inherit» (chmod +a) hands every new file an inherited allow-read entry that neither # chmod 600 nor the rename removes, so settings.json held the key readable to another principal while the run said # «saved with permissions 600». Every key file is now stripped and proven entry-free before its first byte. key_private() { chmod 600 "$1" 2>/dev/null || return 1 acl_none "$1" || return 1 case $(mode_of "$1") in -rw-------|-rw-------@|-rw-------.) return 0 ;; esac return 1 } # A10-1 / round 12 M-1 (the rule above), shared by key files and, round 13 (A12-1), the staging folder: remove every # access-list entry of $1 with the tool that is here and prove none is left. Any tool that fails is a no. acl_none() { case $(uname -s 2>/dev/null) in Darwin) [ -x /bin/ls ] || return 1 chmod -N "$1" 2>/dev/null # -d: the entry itself, a folder (the stage) included — not what it holds. kp=$(/bin/ls -lde "$1" 2>/dev/null) || return 1 [ "$(printf '%s\n' "$kp" | sed -n '$=')" = 1 ] || return 1 ;; Linux) # BUG_FIX_CONTEXT (round 12, M-1, audit-opus-r11.md): round 11 called acl_free here on every Linux, and acl_free # (the UNDO folder's proof) refuses any ls that is not GNU coreutils when getfacl is absent — so every run on # Alpine/busybox (and uutils ls) stopped before its first config, where round 10 worked. The key-file rule now # strips and proves with whatever tool is here, stops only on an entry SEEN and not removable, and with no tool # at all lets the mode decide (RATIONALE above). acl_free itself is unchanged: the undo rule still refuses there. if has getfacl; then if ! acl_free "$1" && has setfacl; then setfacl -b "$1" 2>/dev/null; fi acl_free "$1" || return 1 else if has setfacl; then setfacl -b "$1" 2>/dev/null || return 1; fi if ls --version 2>/dev/null | sed -n 1p | grep -q '(GNU coreutils)'; then case $(mode_of "$1") in ''|*+*) return 1 ;; esac fi fi ;; esac return 0 } # A10-1: a key file that cannot be proven private is removed and the run stops with one plain line (the PowerShell # twin's words, R8-M3): nothing is written into it, and the cleanup list removes every other temp on the way out. private_or_stop() { if key_private "$1"; then return 0; fi rm -f "$1" die 1 "$1 could not be made readable by you alone, so the key was not written there. Check the permissions of its folder, then run the command again." \ "Не удалось сделать $1 доступным только вам, поэтому ключ туда не записан. Проверьте права его папки и выполните команду ещё раз." } # The JSON merge of KUMO_PATCH into $target, to stdout (key_write's command: it runs in its subshell). # #533 r1: the engine (node, python3, osascript) merges the patch still built around the slot word (KUMO_SLOTTED) and # the key is filled into its output afterwards (fill_key: stdin, never an environment), so no engine's environment # holds the key. The merge's own status (3 not JSON, 5 lossy numbers) is handed back before anything is filled; a # fill that fails leaves a result without the key, which key_in_or_stop refuses to publish. The slot word is one no # key or path holds (KUMO_KS). # #533 r2 (codex-533-script-r2.log #2): the fill touches the patch's own key field and nothing else. The slot word is # this run's (kumo_main, unpredictable); a target that already holds it is not merged (status 6: the side file), and # the merged text must hold it exactly as often as the patch did (once) or nothing is filled (status 6). With no key # (the «model» verb) nothing is filled at all: the engine's output is written as it came. # BUG_FIX_CONTEXT (#533 r1): KUMO_PATCH=$KUMO_PATCH (sealed) put the serialised key into the engine's environment. # BUG_FIX_CONTEXT (#533 r2): the fill replaced EVERY __KUMO_KEY_SLOT__ in the merged file — a reader's value or # property name holding that text received the key, and the «model» verb (empty key) deleted the text. merge_into() { if [ -z "$key" ]; then KUMO_FILE=$target KUMO_PATCH=$KUMO_SLOTTED KUMO_DROP=$KUMO_DROP merge_json return "$?" fi if [ -f "$target" ] && grep -F -q -e "$KUMO_KS" "$target" 2>/dev/null; then return 6; fi mi_out=$(KUMO_FILE=$target KUMO_PATCH=$KUMO_SLOTTED KUMO_DROP=$KUMO_DROP merge_json) || return "$?" mi_want=$(printf '%s\n' "$KUMO_SLOTTED" | slot_count) mi_have=$(printf '%s\n' "$mi_out" | slot_count) if [ "$mi_want" != 1 ] || [ "$mi_have" != 1 ]; then return 6; fi printf '%s\n' "$mi_out" | fill_key "$(jsraw "$key")" } # #533 r2: how many times this run's slot word stands in stdin (the word is public: it may ride in awk's environment). slot_count() { KUMO_S=$KUMO_KS awk '{ s = ENVIRON["KUMO_S"]; line = $0; while ((i = index(line, s)) > 0) { n++; line = substr(line, i + length(s)) } } END { print n + 0 }' } # Merge KUMO_PATCH into a JSON config. Sets KUMO_WROTE (the file written) or KUMO_SIDE (the side file left instead). write_json() { resolve_target "$1" target=$KUMO_REAL KUMO_SIDE="" KUMO_WROTE="" mkdir -p "$(dirname "$target")" || die 1 "cannot create $(dirname "$target")" "не удалось создать $(dirname "$target")" if [ "$KUMO_ENGINE" = none ]; then if [ -s "$target" ]; then side_file "$target" engine return 0 fi key_temp "$target" tmp=$KUMO_T key_write_or_stop "$tmp" printf '%s\n' "$KUMO_PATCH" else key_temp "$target" tmp=$KUMO_T rc=0 key_write "$tmp" merge_into || rc=$? key_write_said "$rc" "$tmp" if [ "$rc" != 0 ]; then rm -f "$tmp" if [ "$rc" = 5 ]; then side_file "$target" numbers; else side_file "$target" parse; fi return 0 fi fi key_in_or_stop "$tmp" "$target" install_or_stop "$target" "$tmp" KUMO_WROTE=$target } # Leave the Kumo part beside a file this script will not touch, and show exactly what to paste where — with the key # masked on screen; the side file holds it in full. Nothing is backed up here: the original is not touched at all. side_file() { KUMO_SIDE="$1.kumo.json" key_temp "$KUMO_SIDE" side_tmp=$KUMO_T key_write_or_stop "$side_tmp" printf '%s\n' "$KUMO_PATCH" key_in_or_stop "$side_tmp" "$KUMO_SIDE" chmod 600 "$side_tmp" && mv -f "$side_tmp" "$KUMO_SIDE" if [ "$2" = engine ]; then warn "$1 already exists and there is no node or python3 to merge it safely, so it was not touched. Install python3 (or Node.js) and run the command again: then the script merges it itself." \ "$1 уже есть, а node или python3 для безопасного слияния не нашлось, поэтому файл не тронут. Установите python3 (или Node.js) и выполните команду ещё раз — тогда скрипт сам дополнит файл." elif [ "$2" = numbers ]; then warn "$1 holds a number too long to write back exactly, so it was not touched." \ "В $1 есть число, которое не получится записать обратно без искажения, поэтому файл не тронут." else warn "$1 is not plain JSON (comments?), so it was not touched." \ "$1 не обычный JSON (возможно, с комментариями), поэтому файл не тронут." fi say "Open $1 and merge these keys into it: keep your own keys, and where a section such as \"env\" already exists, put Kumo's lines inside it. The full text, key included, is in $KUMO_SIDE." \ "Откройте $1 и перенесите в него эти ключи: свои ключи оставьте, а где раздел (например, \"env\") уже есть, впишите строки Kumo внутрь него. Полный текст вместе с ключом лежит в $KUMO_SIDE." printf '%s\n' "$KUMO_SHOWN" # F17: a hand merge of Claude Code's settings keeps whatever the file held, and an old ANTHROPIC_API_KEY there # wins over the ANTHROPIC_AUTH_TOKEN the merge adds (the merged write drops it; a hand merge must too). if [ "$client" = claude-code ] && grep -q '"ANTHROPIC_API_KEY"[[:space:]]*:' "$1" /dev/null; then warn "$1 holds env.ANTHROPIC_API_KEY: delete it while merging, or it overrides Kumo's ANTHROPIC_AUTH_TOKEN." \ "В $1 есть env.ANTHROPIC_API_KEY: при переносе удалите его, иначе он перебьёт ключ Kumo в ANTHROPIC_AUTH_TOKEN." fi } # ── The clients ─────────────────────────────────────────────────────────────── patch_claude() { # #533 final Codex P2: plus the Kumo-owned slots the key has no model for any more (claude_slots: KUMO_SLOT_DROP). KUMO_DROP="env.ANTHROPIC_API_KEY$KUMO_SLOT_DROP" if [ -n "$model" ]; then KUMO_PATCH=$(printf '{\n "env": {\n "ANTHROPIC_BASE_URL": %s,\n "ANTHROPIC_AUTH_TOKEN": %s,\n "ANTHROPIC_DEFAULT_FABLE_MODEL": %s,\n "ANTHROPIC_DEFAULT_OPUS_MODEL": %s,\n "ANTHROPIC_DEFAULT_SONNET_MODEL": %s,\n "ANTHROPIC_DEFAULT_HAIKU_MODEL": %s,\n "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",\n "CLAUDE_CODE_AUTO_MODE_SERVER": "0"\n },\n "model": %s\n}' \ "$(js "$abase")" "$(js "$KUMO_KS")" "$(js "$fable")" "$(js "$opus")" "$(js "$sonnet")" "$(js "$haiku")" "$(js "$model")") else # A13-3 (round 14): no main model — the reader's own model fields stay — but the tiers the command named are # written exactly as given, and only those (claude_given). pc_tiers="" for pc_t in FABLE OPUS SONNET HAIKU; do case $pc_t in FABLE) pc_v=$fable ;; OPUS) pc_v=$opus ;; SONNET) pc_v=$sonnet ;; HAIKU) pc_v=$haiku ;; esac if [ -n "$pc_v" ]; then pc_tiers="$pc_tiers \"ANTHROPIC_DEFAULT_$pc_t""_MODEL\": $(js "$pc_v"),"; fi done KUMO_PATCH=$(printf '{\n "env": {\n "ANTHROPIC_BASE_URL": %s,\n "ANTHROPIC_AUTH_TOKEN": %s,%s\n "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",\n "CLAUDE_CODE_AUTO_MODE_SERVER": "0"\n }\n}' \ "$(js "$abase")" "$(js "$KUMO_KS")" "$pc_tiers") fi } # #533 (owner 2026-11-08): OpenCode's top-level «model» is written only for a model the command NAMED (KUMO_NAMED); # otherwise the provider and its model list alone — OpenCode picks a model itself, and /models switches (model_need). patch_opencode() { KUMO_DROP="" po_default="" if [ -n "$KUMO_NAMED" ]; then po_default=$(printf ',\n "model": %s' "$(js "kumo/$model")"); fi KUMO_PATCH=$(printf '{\n "$schema": "https://opencode.ai/config.json",\n "provider": {\n "kumo": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Kumo",\n "options": {\n "baseURL": %s,\n "apiKey": %s\n },\n "models": {\n %s: {\n "name": %s\n }\n }\n }\n }%s\n}' \ "$(js "$obase")" "$(js "$KUMO_KS")" "$(js "$model")" "$(js "$model")" "$po_default") } patch_openclaw() { KUMO_DROP="" KUMO_PATCH=$(printf '{\n "agents": {\n "defaults": {\n "model": {\n "primary": %s\n }\n }\n },\n "models": {\n "mode": "merge",\n "providers": {\n "kumo": {\n "baseUrl": %s,\n "apiKey": %s,\n "api": "openai-completions",\n "models": [\n {\n "id": %s,\n "name": %s,\n "input": [\n "text"\n ]\n }\n ]\n }\n }\n }\n}' \ "$(js "kumo/$model")" "$(js "$obase")" "$(js "$KUMO_KS")" "$(js "$model")" "$(js "$model")") } patch_pi() { KUMO_DROP="" KUMO_PATCH=$(printf '{\n "providers": {\n "kumo": {\n "baseUrl": %s,\n "api": "openai-completions",\n "apiKey": %s,\n "models": [\n {\n "id": %s,\n "input": [\n "text"\n ]\n }\n ]\n }\n }\n}' \ "$(js "$obase")" "$(js "$KUMO_KS")" "$(js "$model")") } # Codex's config.toml, read and rewritten by one awk program that knows TOML's statement structure: a line inside a # multi-line string ("""…""" or '''…''') or inside a multi-line array or inline table is never taken for a table header # or a key; quoted keys and quoted table names ("model" = …, ["model_providers".'kumo']) are compared by their DECODED # value (\", \\, and \uXXXX / \UXXXXXXXX for printable ASCII; any other escape in a key is something the script # does not claim to understand — R1). # mode=scan prints eleven lines: the old top-level model, 1 when a kumo provider exists, 1 when [profiles.*] # tables exist, the top-level model_provider, and — empty when the file is understood — the reason it # is not (then the file is NOT edited: R1's conservative rule); then (U6, round 6) the active profile # (top-level «profile»), its model_provider, 1 when that is a plain line of its own [profiles.] # table (the one form this script edits), the other profiles that name a non-Kumo model_provider, and # the active profile's own model; and (R6-m2, round 7) the raw value of a top-level «profile» this # scanner cannot decode as a TOML string (empty when it decoded it). # mode=write prints the file without the top-level model / model_provider statements (whole, continuation lines # included), without any kumo provider (its table and subtables, a dotted model_providers.kumo.* key, # a kumo key inside [model_providers]); comment lines between a dropped kumo table and the next table # are kept. With only=model it drops the top-level model alone (the «model» verb). Either way it drops # the multi_agent = false that an earlier Kumo run wrote — only the line under Kumo's own marker # comment, and the [features] header Kumo added when nothing else is left in it; a multi_agent the # reader set is never touched. With KUMO_SETPROF= (U6) the model_provider line of # [profiles.] is written as model_provider = "kumo" and nothing else of the profile changes. # mode=check the structural self-check of a finished file, used when no TOML library is at hand: prints «ok», or # the first problem — a table or key defined twice (after decoding), a header that reopens a dotted # or inline table, a kumo table not exactly once, an unterminated string, unbalanced brackets. KUMO_TOML_AWK=' function trimws(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } function hexv(h, i, c, v) { v = 0; h = tolower(h) if (h == "") return -1 for (i = 1; i <= length(h); i++) { c = index("0123456789abcdef", substr(h, i, 1)); if (c == 0) return -1; v = v * 16 + c - 1 } return v } function keypath(s, i, out, c, seg, n, j, L, e, k, cp, quoted) { out = ""; n = 0; L = length(s) while (1) { while (i <= L && (substr(s, i, 1) == " " || substr(s, i, 1) == "\t")) i++ c = substr(s, i, 1) seg = ""; quoted = 0 if (c == DQ) { quoted = 1; i++ while (i <= L) { c = substr(s, i, 1) if (c == "\\") { e = substr(s, i + 1, 1) if (e == DQ || e == "\\") { seg = seg e; i += 2; continue } if (e == "u" || e == "U") { k = (e == "u") ? 4 : 8 cp = (length(substr(s, i + 2, k)) == k) ? hexv(substr(s, i + 2, k)) : -1 if (cp < 32 || cp > 126) { if (UNSURE == "") UNSURE = "a quoted key holds an escape this script does not decode"; cp = 63 } seg = seg sprintf("%c", cp); i += 2 + k; continue } if (UNSURE == "") UNSURE = "a quoted key holds an escape this script does not decode" i += 2; continue } i++ if (c == DQ) break seg = seg c } } else if (c == SQ) { quoted = 1 j = index(substr(s, i + 1), SQ) if (j == 0) { seg = substr(s, i + 1); i = L + 1 } else { seg = substr(s, i + 1, j - 1); i = i + j + 1 } } else { while (i <= L) { c = substr(s, i, 1); if (c ~ /[A-Za-z0-9_-]/) { seg = seg c; i++ } else break } } if (!quoted && seg == "" && UNSURE == "") UNSURE = "a line this script cannot read as a TOML key" out = (n++ ? out SEP : "") seg while (i <= L && (substr(s, i, 1) == " " || substr(s, i, 1) == "\t")) i++ if (substr(s, i, 1) == ".") { i++; continue } break } KEYEND = i return out } function scan(s) { scanline(s); if (bd > 0) { BADSTR = 1; bd = 0 } } # TOML 1.0: an inline table ends on its own line, so a brace still open at the end of a line is not TOML. function scanline(s, i, c, L, j, k) { L = length(s); i = 1 while (i <= L) { if (ml == DQ3) { c = substr(s, i, 1) if (c == "\\") { i += 2; continue } if (substr(s, i, 3) == DQ3) { i += 3; k = 0; while (k < 2 && substr(s, i, 1) == DQ) { i++; k++ } ml = ""; continue } i++; continue } if (ml == SQ3) { j = index(substr(s, i), SQ3) if (j == 0) return i = i + j + 2; k = 0; while (k < 2 && substr(s, i, 1) == SQ) { i++; k++ } ml = ""; continue } c = substr(s, i, 1) if (c == "#") return if (substr(s, i, 3) == DQ3) { ml = DQ3; i += 3; continue } if (substr(s, i, 3) == SQ3) { ml = SQ3; i += 3; continue } if (c == DQ) { i++ while (i <= L) { c = substr(s, i, 1); if (c == "\\") { i += 2; c = ""; continue } i++; if (c == DQ) break } if (c != DQ) BADSTR = 1 continue } if (c == SQ) { j = index(substr(s, i + 1), SQ); if (j == 0) { BADSTR = 1; return } i = i + j + 1; continue } if (c == "[" || c == "{") { depth++; if (c == "{") bd++ } else if (c == "]" || c == "}") { if (depth > 0) depth--; else if (kind != "header") BADSTR = 1; if (c == "}" && bd > 0) bd-- } i++ } } function valueof(s, v) { v = substr(s, KEYEND + 1); sub(/^[ \t]*/, "", v); return v } # R6-m2 (round 7): a TOML value read as TOML reads it — a "basic" string with \" \\ and \uXXXX / \UXXXXXXXX (printable # ASCII) decoded and a # inside its quotes kept, a 'literal' string as written, a bare value up to its comment. TSU is 1 # when the value cannot be decided this way (another escape, a non-ASCII escape, an unclosed or a multi-line string). function tomlval(v, c, i, L, out, e, k, cp) { TSU = 0 sub(/^[ \t]+/, "", v); L = length(v); c = substr(v, 1, 1) if (substr(v, 1, 3) == DQ3 || substr(v, 1, 3) == SQ3) { TSU = 1; return "" } if (c == SQ) { i = index(substr(v, 2), SQ); if (i == 0) { TSU = 1; return "" } return substr(v, 2, i - 1) } if (c == DQ) { out = ""; i = 2 while (i <= L) { c = substr(v, i, 1) if (c == DQ) return out if (c == "\\") { e = substr(v, i + 1, 1) if (e == DQ || e == "\\") { out = out e; i += 2; continue } if (e == "u" || e == "U") { k = (e == "u") ? 4 : 8 cp = (length(substr(v, i + 2, k)) == k) ? hexv(substr(v, i + 2, k)) : -1 if (cp < 32 || cp > 126) { TSU = 1; return "" } out = out sprintf("%c", cp); i += 2 + k; continue } TSU = 1; return "" } out = out c; i++ } TSU = 1; return "" } sub(/[ \t]*#.*$/, "", v); sub(/[ \t]+$/, "", v); return v } function kumo_name(n) { return n == "model_providers" SEP "kumo" || index(n, "model_providers" SEP "kumo" SEP) == 1 } function emit(s) { out[++n] = s } function flush( b) { for (b = 1; b <= held; b++) { if (hold[b] ~ /^[ \t\r]*$/ && (n == 0 || out[n] ~ /^[ \t\r]*$/)) continue; emit(hold[b]) } } # The check: a path with every array-of-tables prefix replaced by its current element, so two [[x]] elements never # read as one table defined twice. function canonp(path, parts, m, i, o) { m = split(path, parts, SEP); o = "" for (i = 1; i <= m; i++) { o = (i == 1) ? parts[1] : o SEP parts[i] if (i < m && (o in arrn)) o = o "#" arrn[o] } return o } function bad(why) { if (BAD == "") BAD = why } function check_header(line, name, isarr, cp, parts, m, i, o, rest) { isarr = (line ~ /^[ \t]*\[\[/) rest = substr(h, KEYEND) if (isarr ? rest !~ /^\]\][ \t]*(#.*)?$/ : rest !~ /^\][ \t]*(#.*)?$/) bad("a table header that does not close") cp = canonp(name) m = split(cp, parts, SEP); o = "" for (i = 1; i < m; i++) { o = (i == 1) ? parts[1] : o SEP parts[i]; if (o in val) bad("a table inside a value") } if (isarr) { if ((cp in hdr) || (cp in val) || (cp in dot)) bad("a table defined twice") arrn[cp]++ cur = cp "#" arrn[cp] return } if ((cp in hdr) || (cp in val) || (cp in dot) || (cp in arrn)) bad("a table defined twice") hdr[cp] = 1 if (cp == "model_providers" SEP "kumo") kumos++ cur = cp } function check_key(key, full, parts, m, i, o) { m = split(key, parts, SEP); o = cur for (i = 1; i < m; i++) { o = (o == "") ? parts[i] : o SEP parts[i] if ((o in val) || (o in hdr)) bad("a key that reopens a table") dot[o] = 1 } full = (cur == "") ? key : cur SEP key if ((full in val) || (full in hdr) || (full in dot) || (full in arrn)) bad("a key defined twice") val[full] = 1 } BEGIN { DQ = "\""; SQ = sprintf("%c", 39); DQ3 = DQ DQ DQ; SQ3 = SQ SQ SQ; SEP = sprintf("%c", 28); QC = "[" DQ SQ "]"; DEL = sprintf("%c", 127) depth = 0; ml = ""; table = ""; skip = 0; drop = 0; n = 0; held = 0 oldmodel = ""; hadkumo = 0; profiles = 0; provider = ""; fidx = 0; fkv = 0; fours = 0; dropma = 0 UNSURE = ""; BAD = ""; BADSTR = 0; kumos = 0; cur = ""; bd = 0 activeprof = ""; activeodd = ""; np = 0; setprof = ENVIRON["KUMO_SETPROF"] # The marker of the Codex multi_agent workaround earlier Kumo runs wrote (round 2 to round 4, removed 2026-11-05). note = "# Kumo: off until the gateway accepts the multi-agent tools of Codex." } # The [features] header an earlier Kumo run added, dropped when its only content was Kumo own workaround line. function close_features() { if (fidx > 0 && fours && fkv == 0) out[fidx] = DEL; fidx = 0; fkv = 0; fours = 0 } { line = $0 sub(/\r$/, "", line) if (ml != "" || depth > 0) { kind = "cont" } else if (line ~ /^[ \t]*\[/) { kind = "header" h = line; sub(/^[ \t]*\[\[?/, "", h) name = keypath(h, 1) } else if (line ~ /^[ \t]*(#.*)?$/) { kind = "blank" } else { kind = "kv" key = keypath(line, 1) if (substr(line, KEYEND, 1) != "=" && UNSURE == "") UNSURE = "a line this script cannot read as a TOML key" } scan(line) if (mode == "check") { if (kind == "header") check_header(line, name) else if (kind == "kv") check_key(key) next } # The workaround line under Kumo marker, written by an earlier run: dropped with its marker. if (kind == "blank" && trimws(line) == note) { dropma = 1; fours = fours || (fidx > 0); next } if (dropma && kind == "kv") { dropma = 0 if ((key == "multi_agent" && table == "features") || (key == "features" SEP "multi_agent" && table == "")) { v = valueof(line); sub(/[ \t]*#.*$/, "", v); sub(/[ \t]+$/, "", v) if (v == "false") next } } dropma = 0 if (kind == "header") { close_features() table = name drop = 0 if (kumo_name(name)) hadkumo = 1 if (kumo_name(name) && only != "model") { skip = 1; held = 0; next } if (skip) { flush(); held = 0 } skip = 0 if (index(name, "profiles" SEP) == 1) profiles = 1 emit($0) if (name == "features" && line !~ /^[ \t]*\[\[/) fidx = n next } if (skip) { if (kind == "blank") hold[++held] = $0; else held = 0 next } if (kind == "cont") { if (drop) next emit($0) next } if (kind == "blank") { drop = 0; emit($0); next } # A key statement. drop = 0 # U6: the active profile (a top-level «profile = …») and every profile that names its own model_provider — said # before the write; with KUMO_SETPROF the active profile own «model_provider = …» line becomes Kumo (nothing else). # BUG_FIX_CONTEXT (Sol R6-m2): the value was cut at the first # and stripped of its outer quotes as raw text, so # profile = "work#1" read as «"work» and "w\u006frk" stayed escaped — the active profile was missed and called an # inactive one. It is decoded as TOML now (tomlval); one that cannot be decided is said BEFORE the write (line 11). pv = tomlval(valueof(line)) if (TSU) { pv = valueof(line); sub(/[ \t]*#.*$/, "", pv); sub(/[ \t]+$/, "", pv); gsub("^" QC "|" QC "$", "", pv) } if (table == "" && key == "profile") { if (TSU) { activeodd = pv; activeprof = "" } else { activeodd = ""; activeprof = pv } } if (index(table, "profiles" SEP) == 1 && split(table, tp, SEP) == 2) { if (key == "model") pmodel[tp[2]] = pv if (key == "model_provider") { if (!(tp[2] in pprov)) porder[++np] = tp[2] pprov[tp[2]] = pv; pedit[tp[2]] = 1 if (setprof != "" && tp[2] == setprof && only != "model") { match(line, /^[ \t]*/); emit(substr(line, 1, RLENGTH) "model_provider = " DQ "kumo" DQ); next } } } else if ((table == "" && index(key, "profiles" SEP) == 1 && split(key, tp, SEP) == 3 && tp[3] == "model_provider") || (table == "profiles" && split(key, tp, SEP) == 2 && tp[2] == "model_provider")) { pn = (table == "") ? tp[2] : tp[1] if (!(pn in pprov)) porder[++np] = pn pprov[pn] = pv; pedit[pn] = 0 } if (table == "") { if (key == "model" || key == "model_provider") { v = valueof(line); sub(/[ \t]*#.*$/, "", v); sub(/[ \t]+$/, "", v); gsub("^" QC "|" QC "$", "", v) if (key == "model") { oldmodel = v; drop = 1 } else { provider = v; if (only != "model") drop = 1 } } else if (key == "model_providers") { # R1: the providers written as one inline table — a [model_providers.kumo] table cannot be added beside it. if (UNSURE == "") UNSURE = "model_providers is written as an inline table" } else if (kumo_name(key)) { hadkumo = 1; if (only != "model") drop = 1 } } else if (table == "model_providers" && (key == "kumo" || index(key, "kumo" SEP) == 1)) { hadkumo = 1; if (only != "model") drop = 1 } if (drop) next if (fidx > 0) fkv++ emit($0) } END { if (mode == "check") { if (BADSTR) bad("an unterminated string or a stray bracket") if (ml != "" || depth > 0) bad("an unclosed string, array or inline table") if (UNSURE != "") bad(UNSURE) if (kumos != 1) bad("the kumo provider table is not there exactly once") if (BAD == "") print "ok"; else print BAD exit } if (skip) flush() close_features() if (mode == "scan") { if (UNSURE == "" && (BADSTR || ml != "" || depth > 0)) UNSURE = "it is not valid TOML (an unclosed string, array or table)" print oldmodel; print hadkumo; print profiles; print provider; print UNSURE others = "" for (i = 1; i <= np; i++) if (porder[i] != activeprof && pprov[porder[i]] != "kumo") others = (others == "" ? "" : others ", ") porder[i] print activeprof print (activeprof != "" && (activeprof in pprov)) ? pprov[activeprof] : "" print (activeprof != "" && (activeprof in pedit)) ? pedit[activeprof] : 0 print others print (activeprof != "" && (activeprof in pmodel)) ? pmodel[activeprof] : "" print activeodd exit } while (n > 0 && out[n] ~ /^[ \t\r]*$/) n-- for (i = 1; i <= n; i++) { if (out[i] == DEL) { # The blank line that separated a dropped [features] table goes with it, so no double gap is left. if (i > 1 && out[i - 1] ~ /^[ \t\r]*$/ && i < n && out[i + 1] ~ /^[ \t\r]*$/) i++ continue } print out[i] } } ' # R1 / m3: is this config.toml valid TOML? The first validator at hand decides: python3 3.11+ tomllib, then python3 # with tomli, then the awk structural self-check (mode=check), which needs nothing but POSIX awk. A node TOML check # is not attempted: no dependency-free parser small enough to embed exists. 0 valid; 1 not (the reason on stdout). toml_check() { if py_ok; then for lib in tomllib tomli; do if python3 -c "import $lib" /dev/null 2>&1; then if python3 -c "import sys, $lib with open(sys.argv[1], 'rb') as h: $lib.load(h)" "$1" /dev/null 2>&1; then return 0 fi printf '%s' "$lib rejects it" return 1 fi done fi verdict=$(awk -v mode=check "$KUMO_TOML_AWK" "$1" /dev/null) || scan_rc=$? old_model=$(printf '%s\n' "$facts" | sed -n 1p) had_kumo=$(printf '%s\n' "$facts" | sed -n 2p) if [ "$had_kumo" = 0 ]; then had_kumo=""; fi KUMO_PROFILES=$(printf '%s\n' "$facts" | sed -n 3p) unsure=$(printf '%s\n' "$facts" | sed -n 5p) KUMO_PROF_SET="" KUMO_PROF_WAS="" table_with() { printf '[model_providers.kumo]\nname = "Kumo"\nbase_url = %s\nwire_api = "responses"\nexperimental_bearer_token = "%s"' "$(js "$obase")" "$1" } kumo_table=$(table_with "$(jsraw "$key")") kumo_shown=$(table_with "$(jsraw "$(mask "$key")")") if [ "$scan_rc" != 0 ]; then # R14-M1 (round 15): the scanner itself failed (macOS awk exits 2 on a byte its locale cannot decode — a BOM, an # invalid UTF-8 byte), so the file is not edited at all. codex_side "$target" "this system's awk could not read it to the end" return 0 fi if [ -n "$unsure" ]; then # R1: the scanner cannot prove it understands this file, so it is not edited at all. codex_side "$target" "$unsure" return 0 fi codex_profiles "$facts" key_temp "$target" tmp=$KUMO_T # R14-M1 (round 15): a rewrite that failed (codex_body hands awk's status back) leaves the reader's file as it was and # Kumo's part beside it — the same side file as an unsure scan; a key-file refusal (120–123) still stops the run. # BUG_FIX_CONTEXT (round 15, R14-M1, sol-edge-r14.md): the scan's failure left empty facts, read as «nothing to keep», # and the rewrite's failure was masked by codex_body's last printf — so Kumo's lines alone, which parse, replaced a # config.toml with a BOM: «[features] web_search = true» and every other setting of the reader were gone. rc=0 key_write "$tmp" codex_body || rc=$? key_write_said "$rc" "$tmp" if [ "$rc" != 0 ]; then rm -f "$tmp" codex_side "$target" "this system's awk could not rewrite it" return 0 fi key_in_or_stop "$tmp" "$target" rc=0 why=$(toml_check "$tmp") || rc=$? if [ "$rc" != 0 ]; then # The result does not validate: the reader's file stays as it was. rm -f "$tmp" codex_side "$target" "$why" return 0 fi install_or_stop "$target" "$tmp" KUMO_WROTE=$target } # U6 (round 6): Codex profiles, said BEFORE anything is written. An active profile (top-level «profile = …») whose # own [profiles.] table names another model_provider would send Codex past Kumo: when that is a plain line of # its table, the script offers to set it to "kumo" too — asked on a terminal ([Y/n]), said in one line and done # without one — with the same backup and validation as the rest of the file; any other form is named with the line to # change by hand. Other profiles with their own provider, and profile files beside config.toml, are named, not edited. # Sets KUMO_PROF_SET (the profile whose provider line becomes Kumo) and KUMO_PROF_WAS (its old provider). codex_profiles() { active=$(printf '%s\n' "$1" | sed -n 6p) aprov=$(printf '%s\n' "$1" | sed -n 7p) aedit=$(printf '%s\n' "$1" | sed -n 8p) others=$(printf '%s\n' "$1" | sed -n 9p) amodel=$(printf '%s\n' "$1" | sed -n 10p) aodd=$(printf '%s\n' "$1" | sed -n 11p) said_profiles=0 if [ -n "$aodd" ]; then # R6-m2: the active profile's name could not be decoded, so it cannot be matched to its table — said before the # write, with the one line to check, rather than calling every profile an inactive one. said_profiles=1 warn "config.toml selects the Codex profile $aodd, written in a form this script cannot read exactly. If that profile sets its own model_provider, Codex does not use Kumo while it is active: set model_provider = \"kumo\" in that profile by hand." \ "config.toml выбирает профиль Codex $aodd, записанный в виде, который скрипт не может прочитать точно. Если в этом профиле задан свой model_provider, пока он активен, Codex работает мимо Kumo: поставьте в нём model_provider = \"kumo\" вручную." fi if [ -n "$active" ] && [ -n "$aprov" ] && [ "$aprov" != kumo ]; then said_profiles=1 if [ "$aedit" = 1 ]; then tty=$(kumo_tty) if ( : <"$tty" ) 2>/dev/null; then ask_yes "Your active Codex profile «$active""» sends Codex to $aprov, past Kumo. Set model_provider = \"kumo\" in [profiles.$active] too? [Y/n] " \ "Активный профиль Codex «$active""» уводит Codex на $aprov, мимо Kumo. Поставить и в [profiles.$active] model_provider = \"kumo\"? [Д/н] " if [ "$?" = 0 ]; then KUMO_PROF_SET=$active; fi else say "Your active Codex profile «$active""» sends Codex to $aprov, past Kumo, so model_provider = \"kumo\" is set in [profiles.$active] too (the backup keeps the old line)." \ "Активный профиль Codex «$active""» уводит Codex на $aprov, мимо Kumo, поэтому и в [profiles.$active] ставится model_provider = \"kumo\" (прежняя строка останется в копии)." KUMO_PROF_SET=$active fi if [ -n "$KUMO_PROF_SET" ]; then KUMO_PROF_WAS=$aprov if [ -n "$amodel" ] && [ "$amodel" != "$model" ]; then say " The profile's own model $amodel stays: if Kumo does not serve it, set it to $model there." \ " Своя модель профиля $amodel остаётся: если в Kumo её нет, поставьте там $model." fi else warn "Profile «$active""» keeps model_provider = $aprov: while it is active, Codex does not use Kumo. To use Kumo, set model_provider = \"kumo\" in [profiles.$active]." \ "Профиль «$active""» оставлен с model_provider = $aprov: пока он активен, Codex работает мимо Kumo. Чтобы работать через Kumo, поставьте model_provider = \"kumo\" в [profiles.$active]." fi else warn "Your active Codex profile «$active""» sets model_provider = $aprov in a form this script does not edit: set model_provider = \"kumo\" in [profiles.$active] by hand, or Codex will not use Kumo." \ "Активный профиль Codex «$active""» задаёт model_provider = $aprov в виде, который скрипт не правит: поставьте model_provider = \"kumo\" в [profiles.$active] вручную, иначе Codex не пойдёт через Kumo." fi fi if [ -n "$others" ] && [ -z "$aodd" ]; then said_profiles=1 say "Codex profiles $others name their own model_provider: started with --profile, they do not use Kumo. To use Kumo in one, set model_provider = \"kumo\" in it." \ "Профили Codex $others задают свой model_provider: запущенные с --profile, они работают мимо Kumo. Чтобы профиль работал через Kumo, поставьте в нём model_provider = \"kumo\"." fi for pf in "$dir"/*.config.toml; do if [ -f "$pf" ]; then KUMO_PROFILES=1; fi done if [ "$said_profiles" = 0 ] && [ "$KUMO_PROFILES" = 1 ]; then say "Codex profiles keep their own model settings: to use Kumo in a profile, add model_provider = \"kumo\" to it." \ "У профилей Codex свои настройки модели: чтобы профиль работал через Kumo, добавьте в него model_provider = \"kumo\"." fi } # The new config.toml, to stdout (key_write's command): Kumo's model lines, the reader's file rewritten by the awk # program, Kumo's table. codex_body() { printf 'model = %s\nmodel_provider = "kumo"\n' "$(js "$model")" # R14-M1 (round 15): awk's failure is this command's failure (key_write hands it to write_codex). KUMO_SETPROF=$KUMO_PROF_SET awk -v mode=write "$KUMO_TOML_AWK" "$source" /dev/null || return "$?" printf '\n%s\n' "$kumo_table" } # Leave Kumo's part of config.toml beside a file this script will not edit, and say exactly what goes where: the key # masked on screen (the mask serialised, R7), in full only in the side file. codex_side() { KUMO_SIDE="$1.kumo.toml" key_temp "$KUMO_SIDE" side_tmp=$KUMO_T side_body() { printf 'model = %s\nmodel_provider = "kumo"\n\n%s\n' "$(js "$model")" "$1" } key_write_or_stop "$side_tmp" side_body "$kumo_table" key_in_or_stop "$side_tmp" "$KUMO_SIDE" chmod 600 "$side_tmp" && mv -f "$side_tmp" "$KUMO_SIDE" warn "$1 could not be edited safely ($2), so it was not touched." \ "$1 не удалось безопасно изменить ($2), поэтому файл не тронут." say "Put the first two lines at the very top of $1 (replacing its own model and model_provider), and the table at its end. The full text, key included, is in $KUMO_SIDE:" \ "Первые две строки поставьте в самое начало $1 (вместо его model и model_provider), а таблицу — в конец. Полный текст вместе с ключом лежит в $KUMO_SIDE:" side_body "$kumo_shown" } # The installer of each client: its binary and the official one-line install. client_bin() { case $1 in claude-code) echo claude ;; codex) echo codex ;; opencode) echo opencode ;; openclaw) echo openclaw ;; pi) echo pi ;; esac } client_how() { case $1 in claude-code) echo 'curl -fsSL https://claude.ai/install.sh | bash' ;; codex) echo 'curl -fsSL https://chatgpt.com/codex/install.sh | sh' ;; opencode) echo 'curl -fsSL https://opencode.ai/install | bash' ;; openclaw) echo 'curl -fsSL https://openclaw.ai/install.sh | bash' ;; pi) echo 'curl -fsSL https://pi.dev/install.sh | sh' ;; esac } client_name() { case $1 in claude-code) echo "Claude Code" ;; codex) echo "Codex" ;; opencode) echo "OpenCode" ;; openclaw) echo "OpenClaw" ;; pi) echo "Pi" ;; esac } # Is the client installed? Its binary on PATH — and for Pi, the binary must be the Pi coding agent: Debian's «pi» # (digits of π) shares the name, so «pi --version» must answer a bare version number, as the agent does (m10). # A12-4 (round 13): not on PATH but executable in a folder the official installers use (known_dir) is installed too: # KUMO_BIN_DIR then names that folder, so nothing is installed over it and the probe, path_hint and run_line use it. # BUG_FIX_CONTEXT (round 13, A12-4 / R12-m2, audit-astra-r12.md, sol-edge-r12.md): only PATH was asked, and the vendor # folders were searched only AFTER another installer run — so a re-run from a shell whose PATH lacks ~/.local/bin # downloaded and ran the installer again (the counter went 1 → 2), «all» and --no-install skipped the tool, and with the # installer unreachable the run ended «Start it with: claude», a command that shell cannot find. client_installed() { b=$(client_bin "$1") KUMO_PI_HUNG="" ci_exe=$b if ! has "$b"; then known_dir "$1" || return 1 ci_exe="$KUMO_BIN_DIR/$b" fi if [ "$1" = pi ]; then # R11-M6 (round 12b): bounded — a «pi» that never answers is said as such (KUMO_PI_HUNG), not «not installed». pi_version "$ci_exe" v=$KUMO_PI_V if [ -n "$KUMO_PI_HUNG" ]; then KUMO_BIN_DIR=""; return 1; fi case $v in [0-9]*.[0-9]*.[0-9]*) case $v in *[!0-9A-Za-z.+-]*) KUMO_BIN_DIR=""; return 1 ;; esac ;; *) KUMO_BIN_DIR=""; return 1 ;; esac fi return 0 } # F16 / A12-4: the first folder an official installer uses that holds client $1's executable (a regular file) — sets # KUMO_BIN_DIR — or a failure. known_dir() { kd_bin=$(client_bin "$1") for kd in "$HOME/.local/bin" "$HOME/.opencode/bin" "$HOME/.openclaw/bin" "$HOME/.pi/bin" "$HOME/bin"; do if [ -x "$kd/$kd_bin" ] && [ -f "$kd/$kd_bin" ]; then KUMO_BIN_DIR=$kd return 0 fi done return 1 } # R11-M6 (round 12b): «pi --version» with a deadline — 5 seconds, no input, no terminal output, in a process group of # its own (in_group, as the probe), the whole group stopped after it answers or when the time is up. Sets KUMO_PI_V # (its first line of output) and KUMO_PI_HUNG (1: it did not answer in time). # BUG_FIX_CONTEXT (round 12b, R11-M6, sol-edge-r11.md): «pi --version» ran with no deadline, so a hung or foreign «pi» # first on PATH blocked the run (and every list naming Pi) for as long as it pleased, before any probe deadline. # R12-M3 (round 13): $1 — the «pi» to ask (default: PATH's; A12-4 passes one found in a vendor folder). The group is # the run's to stop from the moment it exists: its leader's pid file is named in KUMO_PI_PIDF before anything starts, # so an interruption (kumo_cleanup, on INT / TERM / HUP / EXIT) stops the version group — or, where no group could # be made, the tree under its leader — as it stops the probe's. # BUG_FIX_CONTEXT (round 13, R12-M3, sol-edge-r12.md): kumo_cleanup knew only KUMO_PGROUP, which pi_version never set, # so a SIGINT to the run while «pi --version» waited exited 130 and left the version shell and its «sleep 90» running # in their own group, its supervisor writing into a folder the cleanup had removed. # N16-1 (round 17): the same bounded ask serves «claude --help» (probe_tool): $2 — the word to ask (default # --version), $3 — the bytes of its output read (default 4096), $4 — a fixed text; KUMO_PI_HAS becomes 1 when the # bounded copy holds it. The tool is asked from its own temp folder, never the reader's. # N17-1 (round 18): $5 — a second fixed text from the same ONE ask; KUMO_PI_HAS2 becomes 1 when the copy holds it. pi_version() { KUMO_PI_V="" KUMO_PI_HUNG="" KUMO_PI_HAS=0 KUMO_PI_HAS2=0 pv_exe=$1 if [ -z "$pv_exe" ]; then pv_exe=pi; fi pv_word=$2 if [ -z "$pv_word" ]; then pv_word=--version; fi pv_cap=$3 if [ -z "$pv_cap" ]; then pv_cap=4096; fi pw=$(mktemp -d "$KUMO_TMP/kumo-pi.XXXXXX") || return 0 clean_add "$pw" # R13-M1 (round 14): the bounded copy of what it printed goes to a second private folder (probe_read). prd=$(mktemp -d "$KUMO_TMP/kumo-ctl.XXXXXX") || { rm -rf "$pw"; return 0; } clean_add "$prd" KUMO_PI_PIDF="$pw/pid" own_new ( if ! has perl && ! has setsid; then set -m 2>/dev/null; fi # R13-M2 / A13-1 (round 14): the ownership mark rides on descriptor 7 and in KUMO_OWNED_BY (own_holders). KUMO_OWNED_BY=$KUMO_OWN_MARK export KUMO_OWNED_BY cd "$pw" || exit 0 in_group "$pv_exe" "$pv_word" "$pw/out" 2>/dev/null 7<"$KUMO_OWN_FD" & printf '%s\n' "$!" > "$pw/pid" wait "$!" : > "$pw/done" ) /dev/null 2>&1 & pr=$! pn=0 pp="" while [ ! -f "$pw/done" ] && [ "$pn" -lt 50 ]; do if [ -z "$pp" ]; then pp=$(cat "$pw/pid" 2>/dev/null); fi own_track "$pp" sleep 0.1 2>/dev/null || { sleep 1; pn=$((pn + 9)); } pn=$((pn + 1)) done pp=$(cat "$pw/pid" 2>/dev/null) if [ ! -f "$pw/done" ]; then KUMO_PI_HUNG=1; fi if [ -n "$pp" ]; then own_track "$pp" if kill -s 0 -- -"$pp" 2>/dev/null; then stop_group "$pp"; elif [ -n "$KUMO_PI_HUNG" ]; then kill_tree "$pp"; fi fi # A13-1 (round 14): with or without a group, everything owned is stopped — on an answer exactly as on a timeout. # BUG_FIX_CONTEXT (round 14, A13-1, audit-astra-r13.md): under dash with neither perl nor setsid no group exists, an # answer left KUMO_PI_HUNG empty, and nothing was stopped: «sleep 90 &» lived on at PPID 1 after the run returned. own_stop wait "$pr" 2>/dev/null KUMO_PI_PIDF="" KUMO_READ_LEFT=20 probe_read "$pw/out" "$prd/out" "$pv_cap" "" KUMO_PI_V=$(sed -n 1p "$prd/out" 2>/dev/null) if [ -n "$4" ] && grep -q -F -e "$4" "$prd/out" 2>/dev/null; then KUMO_PI_HAS=1; fi if [ -n "$5" ] && grep -q -F -e "$5" "$prd/out" 2>/dev/null; then KUMO_PI_HAS2=1; fi rm -rf "$pw" "$prd" } # R11-M6: the plain line for a «pi» that did not answer in time. pi_hung_words() { pi_en="pi did not answer «pi --version» within 5 seconds, so it is not clear this is the Pi coding agent: nothing was installed over it and nothing was written for Pi. Check it (pi --version), then run the command again." pi_ru="pi не ответил на «pi --version» за 5 секунд, поэтому непонятно, что это агент Pi: поверх него ничего не установлено и для Pi ничего не записано. Проверьте его (pi --version) и выполните команду ещё раз." } install_tool() { bin=$(client_bin "$client") how=$(client_how "$client") if client_installed "$client"; then return 0 fi if [ -n "$KUMO_PI_HUNG" ]; then pi_hung_words die 1 "$pi_en" "$pi_ru" fi if [ "$install" = 0 ]; then warn "$bin is not installed; install it with: $how" "$bin не установлен; установите его командой: $how" return 0 fi say "Installing $name with its official installer: $how" \ "Устанавливаю $name официальным установщиком: $how" if has bash; then shell=bash; else shell=sh; fi case $how in *'| bash') runner=$shell ;; *) runner=sh ;; esac url=$(printf '%s' "$how" | sed 's/^curl -fsSL \([^ ]*\) .*/\1/') # U16: the vendor installer's own words (English, and its own PATH notes) go to a log; the reader sees one line of # ours. The installer's exit status decides; the log is kept only when it failed. log=$(mktemp "$KUMO_TMP/kumo-install-$client.XXXXXX") || log=/dev/null rc=0 # BUG_FIX_CONTEXT (round 12, M-2, audit-opus-r11.md): «curl … | runner» gave the RUNNER's status only (POSIX sh has # no pipefail), and bash or sh reading an empty stdin exits 0 — so an offline machine, a proxy, a DNS failure or a # 403/404 under -f said «✓ installed», and a list configured a tool that does not exist. The installer is now # downloaded to a file first, with its own status; an empty body or a web page («<» first) is a failed download # too; only then is it run, and either failure is the same install failure (nothing written in a list). inst=$(mktemp "$KUMO_TMP/kumo-installer-$client.XXXXXX") || inst="" if [ -z "$inst" ]; then rc=1 else clean_add "$inst" curl -fsSL "$url" -o "$inst" "$log" 2>&1 || rc=$? if [ "$rc" = 0 ]; then lead=$(awk 'NF { sub(/^[[:space:]]+/, ""); print substr($0, 1, 1); exit }' "$inst" >"$log" 2>/dev/null ;; esac fi if [ "$rc" = 0 ]; then "$runner" "$inst" >"$log" 2>&1 || rc=$?; fi rm -f "$inst" fi # BUG_FIX_CONTEXT (round 12b, R11-M4 / A11-2): an installer that exited 0 and put nothing anywhere counted as # «installed», and a list configured a tool that is not there. Exit 0 now also needs the executable: on PATH, or in # a folder the official installers use (KUMO_BIN_DIR then names it); otherwise it is the same install failure. # client_installed looks in those folders itself (A12-4, known_dir) and sets KUMO_BIN_DIR when the tool is there. if [ "$rc" = 0 ] && ! client_installed "$client"; then rc=1 printf '%s\n' "kumo: the installer finished, but $bin is neither on PATH nor in a folder the official installers use" >>"$log" 2>/dev/null fi if [ "$rc" != 0 ]; then KUMO_INSTALL_FAILED=1 if [ -n "$KUMO_LISTING" ]; then # m-F (round 11): in a list a tool whose installer failed is not set up at all — the summary names it for a hand # install, with the vendor command. warn "The installer for $name failed, so $name was not set up. Install it by hand: $how, then run this command again. Install details: $log" \ "Установщик $name завершился с ошибкой, поэтому $name не настроен. Установите его вручную: $how, затем выполните эту команду ещё раз. Подробности установки: $log" else warn "The installer for $name failed; Kumo's settings are written anyway. Install it by hand: $how. Install details: $log" \ "Установщик $name завершился с ошибкой; настройки Kumo всё равно записаны. Установите вручную: $how. Подробности установки: $log" fi return 0 fi if [ "$log" != /dev/null ]; then rm -f "$log"; fi KUMO_INSTALLED_NOW=1 # F16: a binary in a folder this shell's PATH lacks (Claude Code → ~/.local/bin) was found above (KUMO_BIN_DIR): # path_hint names the line that fixes PATH, and run_line the full path that works without it. say "✓ $name is installed." "✓ $name установлен." } # U16 (round 6): a tool installed into a folder the login shell's PATH lacks (Claude Code → ~/.local/bin) would make # «Start it with: claude» a dead end in every new terminal. The folder goes into the start-up file of the reader's # shell ($SHELL): zsh → ~/.zshrc (created when absent), bash → ~/.bash_profile on macOS, ~/.bashrc elsewhere, # fish → ~/.config/fish/conf.d/kumo-path.fish, anything else → ~/.profile — ONE line marked «# added by Kumo # connect», once (a re-run finds it and adds nothing), after a backup of the file. Asked on a terminal (Enter: yes), # said and done without one. m4: the folder under HOME is written as $HOME/…, anything but plain characters # single-quoted, so the line runs as printed whatever HOME holds. N3 (round 7): said as what it does for the reader — # the command will work in new windows — not in the words PATH or rc. # Sol R7-M3 (round 8): the line goes at the TOP of the file (after a «#!» line, if there is one), so nothing later in # it — an early «return» or «exit», a «sleep», an «exec» — can keep it from running, and it is checked by its TEXT # alone (rc_first: Kumo's exact line is the first one); the reader's start-up file is never executed. A new window is # promised only when that check holds AND the reader's shell is on this machine. A start-up file that is a link (a # dotfiles manager's), not a regular file, or not writable is left as it is: the line to add is printed, and the run # names the full path. Sets KUMO_PATH_ADDED (1: a new window of this shell finds the tool). # BUG_FIX_CONTEXT (Sol R7-M3): round 7 PROVED the line by running the start-up file in the reader's shell; an rc that # sleeps, execs or waits on the network hung the run after the config was written, with no deadline. Executing the # reader's code is not needed once the line stands first: its text is the evidence. path_hint() { KUMO_PATH_ADDED=0 # N13-1 (round 14): the command name is this client's own — install_tool set it on a fresh install, but a tool a list # found off PATH never passes there, so «bin» was empty (or the previous tool's) and the full path was the folder. # BUG_FIX_CONTEXT (round 14, N13-1, audit-opus-r13.md): «the command should work … start it like this: '…/.local/bin/'». bin=$(client_bin "$client") shell_name=$(printf '%s' "$SHELL" | sed 's#.*/##') rel=$(printf '%s\n' "$KUMO_BIN_DIR" | KUMO_H=$HOME awk '{ h = ENVIRON["KUMO_H"]; if (h != "" && index($0, h "/") == 1) print substr($0, length(h) + 1) }') case $rel in ""|*[!A-Za-z0-9._/-]*) folder=$(sq "$KUMO_BIN_DIR") ;; *) folder="\"\$HOME$rel\"" ;; esac case $shell_name in zsh) rc="$HOME/.zshrc" ;; bash) if [ "$(uname -s 2>/dev/null)" = Darwin ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi ;; fish) rc="$HOME/.config/fish/conf.d/kumo-path.fish" ;; *) rc="$HOME/.profile" ;; esac rc_shown=$(tilde "$rc") mark="# added by Kumo connect" if [ "$shell_name" = fish ]; then line="set -gx PATH $folder \$PATH $mark" else case $folder in \"*) line="export PATH=$(printf '%s' "$folder" | sed 's/"$//'):\$PATH\" $mark" ;; *) line="export PATH=$folder\":\$PATH\" $mark" ;; esac fi # The reader's shell on this machine: $SHELL, else the shell of that name on PATH. Looked up, never run. shell_here=0 prog=$SHELL if [ -z "$prog" ] || [ ! -x "$prog" ]; then prog=$(command -v "$shell_name" 2>/dev/null) || prog=""; fi if [ -n "$prog" ] && [ -x "$prog" ]; then shell_here=1; fi if [ -L "$rc" ] || { [ -e "$rc" ] && [ ! -f "$rc" ]; } || { [ -f "$rc" ] && [ ! -w "$rc" ]; }; then warn "$rc_shown is a link, not a regular file, or not writable, so it was not changed. For new windows to find $bin, put this line at the very top of the file it stands for: $line" \ "$rc_shown — ссылка, не обычный файл или закрыт для записи, поэтому он не изменён. Чтобы новые окна находили $bin, впишите в самое начало файла, который за ним стоит, строку: $line" return 0 fi # Sol R8-m1 (round 10): the full path, quoted, for the line that says what to do if a new window does not find it. full_bin=$(sq "$KUMO_BIN_DIR/$bin") if rc_first; then if [ "$shell_here" = 1 ]; then KUMO_PATH_ADDED=1 say "Terminal's settings already have Kumo's line — in a new window the command $bin should work. If it does not — start it like this: $full_bin" \ "Строка Kumo уже есть в настройках Терминала — в новом окне должна работать команда $bin. Если не сработает — запустите так: $full_bin" else warn "$rc_shown has Kumo's line, but a new window still does not find $bin: the shell $shell_name is not on this machine. Start $bin by its full path (below)." \ "В $rc_shown уже есть строка Kumo, но новое окно всё равно не находит $bin: оболочки $shell_name на этом компьютере нет. Запускайте $bin по полному пути (ниже)." fi return 0 fi tty=$(kumo_tty) if ( : <"$tty" ) 2>/dev/null; then ask_yes "Set up Terminal so the command $bin works in new windows? (one line is added at the top of $rc_shown; a copy of the file is kept) [Y/n] " \ "Настроить Терминал, чтобы команда $bin работала в новых окнах? (в начало $rc_shown добавится одна строка, копия файла сохранится) [Д/н] " if [ "$?" != 0 ]; then say " Not added. To add it yourself, put this line at the top of $rc_shown:" " Не добавлено. Чтобы добавить самим, впишите в начало $rc_shown строку:" say " $line" " $line" return 0 fi fi mkdir -p "$(dirname "$rc")" 2>/dev/null rc_backup="" rc_new=1 rc_moved=0 if [ -f "$rc" ]; then rc_new=0 if grep -F -x -q -e "$line" "$rc" 2>/dev/null; then rc_moved=1; fi rc_backup=$(backup_of "$rc") && cat "$rc" > "$rc_backup" || rc_backup="" if [ -z "$rc_backup" ]; then warn "Could not keep a copy of $rc_shown, so it was not changed. Put this line at its very top yourself: $line" \ "Не удалось сохранить копию $rc_shown, поэтому он не изменён. Впишите в самое его начало строку: $line" return 0 fi fi # The new text: a «#!» line stays first, then Kumo's line, then the reader's file without any earlier copy of # Kumo's exact line (an earlier run appended it at the end). Written over the file in place (cat >), so its # mode, owner and hard links stay as they were; the copy above is the way back. temp_for "$rc" rt=$KUMO_T if [ -f "$rc" ]; then KUMO_L=$line awk 'NR == 1 && /^#!/ { print; print ENVIRON["KUMO_L"]; put = 1; next } NR == 1 { print ENVIRON["KUMO_L"]; put = 1 } $0 == ENVIRON["KUMO_L"] { next } { print } END { if (!put) print ENVIRON["KUMO_L"] }' "$rc" > "$rt" "$rt" fi if [ "$?" = 0 ] && cat "$rt" > "$rc" 2>/dev/null; then rm -f "$rt" # N2 / R6-m4: the undo file brings the start-up file back too. if [ -n "$rc_backup" ]; then undo_add r "$rc_backup" "$rc"; elif [ "$rc_new" = 1 ]; then undo_add d "$rc"; fi if [ "$rc_moved" = 1 ]; then how_en="Kumo's line moved to the top of $rc_shown, a copy of the file kept"; how_ru="строка Kumo перенесена в начало $rc_shown, копия файла сохранена" elif [ -n "$rc_backup" ]; then how_en="one line added at the top of $rc_shown, a copy of the file kept"; how_ru="добавлена одна строка в начало $rc_shown, копия файла сохранена" else how_en="$rc_shown created with one line"; how_ru="создан файл $rc_shown с одной строкой" fi # BUG_FIX_CONTEXT (Sol R8-m1): «the command will work in new windows» was promised from the first line's TEXT, but a # later line of the same file (export PATH=/usr/bin:/bin) still takes the folder away, and the rc is never run to # know. The line now says what was done and what SHOULD happen, with the full path as the plain fallback. if rc_first && [ "$shell_here" = 1 ]; then KUMO_PATH_ADDED=1 say "Added a line to Terminal's settings — in a new window the command $bin should work. If it does not — start it like this: $full_bin" \ "Добавил строку в настройки Терминала — в новом окне должна работать команда $bin. Если не сработает — запустите так: $full_bin" say " ($how_en)" " ($how_ru)" else warn "Added a line to $rc_shown, but a new window would still not find $bin: the shell $shell_name is not on this machine. Start $bin by its full path (below)." \ "Строка добавлена в $rc_shown, но новое окно всё равно не найдёт $bin: оболочки $shell_name на этом компьютере нет. Запускайте $bin по полному пути (ниже)." fi else rm -f "$rt" warn "Could not write $rc_shown. Put this line at its very top yourself: $line" "Не удалось записать $rc_shown. Впишите в самое его начало строку: $line" fi } # A path under HOME as the reader writes it: ~/… (the rest as it is). tilde() { printf '%s\n' "$1" | KUMO_H=$HOME awk '{ h = ENVIRON["KUMO_H"]; if (h != "" && index($0, h "/") == 1) print "~" substr($0, length(h) + 1); else print }' } # Sol R7-M3 (round 8): is Kumo's exact line the FIRST line of the start-up file (the second, after a «#!» line)? Read as # text only — the file is never run. rc_first() { [ -f "$rc" ] || return 1 rc_top=$(sed -n '1p' "$rc" 2>/dev/null) case $rc_top in '#!'*) rc_top=$(sed -n '2p' "$rc" 2>/dev/null) ;; esac [ "$rc_top" = "$line" ] } # The key check, before anything is installed or written. It POSTs a chat completion for a model that does not # exist; the gateway authenticates the key before it resolves the model (measured on the stand, 2026-11-05): # a valid key → 404 application/json {"error":{…,"type":"not_found_error","code":"model_not_found"}} ok # a refused key → 401 application/json {"error":{…,"type":"authentication_error","code":"unauthenticated"}} # R2: the answer is PARSED (node, python3 or JavaScript for Automation), never matched as a substring, so property # order does not matter and an HTML page that happens to hold the marker proves nothing: # ok 404, a JSON content type, a JSON object whose error.code is "model_not_found"; # refused any 401; a 403 whose JSON error type or code names authentication or permission — the run stops; # unreachable no answer or a 5xx; # inconclusive another status with a JSON error object (429, another 403…); # foreign everything else (HTML, a redirect, another server's 404, malformed JSON). # Without an engine the check is textual and conservative: ok needs the JSON content type, the exact # "code":"model_not_found" pair and no «<» anywhere in the body. The key travels in a header file, never on a # command line. KUMO_JS_VERIFY='var code = ENV.KUMO_CODE || "", json = /^application\/([a-z0-9.+-]*\+)?json(;|$)/i.test(ENV.KUMO_CTYPE || ""); var o = null; try { o = JSON.parse(READ(ENV.KUMO_FILE)); } catch (e) { o = null; } var err = (o !== null && typeof o === "object" && !Array.isArray(o) && o.error !== null && typeof o.error === "object") ? o.error : null; var str = function (v) { return typeof v === "string" ? v : ""; }; var authish = err !== null && /authentication|permission/.test(str(err.type) + " " + str(err.code)); var out = "foreign"; if (code === "" || code === "000" || /^5/.test(code)) out = "unreachable"; else if (code === "401") out = "refused"; else if (code === "403" && json && authish) out = "refused"; else if (code === "404" && json && err !== null && err.code === "model_not_found") out = "ok"; else if (json && err !== null) out = "inconclusive"; OUT(out); ' verify_classify() { case $KUMO_ENGINE in node|jxa) KUMO_FILE=$1 KUMO_CODE=$2 KUMO_CTYPE=$3 run_js "$KUMO_JS_VERIFY" 2>/dev/null return 0 ;; python3) KUMO_FILE=$1 KUMO_CODE=$2 KUMO_CTYPE=$3 python3 -c ' import json, os, re, sys e = os.environ code = e["KUMO_CODE"] ctype = re.match(r"^application/([a-z0-9.+-]*\+)?json(;|$)", e.get("KUMO_CTYPE", ""), re.I) is not None try: with open(e["KUMO_FILE"], encoding="utf-8") as h: o = json.load(h) except Exception: o = None err = o.get("error") if isinstance(o, dict) and isinstance(o.get("error"), dict) else None s = lambda v: v if isinstance(v, str) else "" authish = err is not None and re.search("authentication|permission", s(err.get("type")) + " " + s(err.get("code"))) is not None out = "foreign" if code in ("", "000") or code.startswith("5"): out = "unreachable" elif code == "401": out = "refused" elif code == "403" and ctype and authish: out = "refused" elif code == "404" and ctype and err is not None and err.get("code") == "model_not_found": out = "ok" elif ctype and err is not None: out = "inconclusive" sys.stdout.write(out) ' /dev/null return 0 ;; esac # No engine: textual and conservative. answer=$(tr -d ' \t\r\n' < "$1" 2>/dev/null) json=0 case $3 in application/json*|application/*+json*|Application/[Jj][Ss][Oo][Nn]*) json=1 ;; esac errobj=0 case $answer in '{'*'"error":{'*) errobj=1 ;; esac case $answer in *'<'*) errobj=0 ;; esac case $2 in ""|000|5??) printf unreachable ;; 401) printf refused ;; 403) if [ "$json" = 1 ] && [ "$errobj" = 1 ]; then case $answer in *authentication*|*permission*) printf refused ;; *) printf inconclusive ;; esac else printf foreign fi ;; 404) if [ "$json" = 1 ] && [ "$errobj" = 1 ]; then case $answer in *'"code":"model_not_found"'*) printf ok ;; *) printf inconclusive ;; esac else printf foreign fi ;; *) if [ "$json" = 1 ] && [ "$errobj" = 1 ]; then printf inconclusive; else printf foreign; fi ;; esac } verify_key() { KUMO_VERIFY=unchecked KUMO_VERIFY_CODE="" if ! has curl; then KUMO_VERIFY=nocurl return 0 fi # A10-1: the header file holds the key: no ACL entry from TMPDIR may reach it. R11-M2: key_write. A12-1 (round 13): # born in a private stage inside TMPDIR (key_temp), as every key file. key_temp "$KUMO_TMP/kumo-hdr" hdr=$KUMO_T key_write_or_stop "$hdr" printf 'Authorization: Bearer %s\n' "$key" body=$(mktemp "$KUMO_TMP/kumo-body.XXXXXX") || return 0 clean_add "$body" meta=$(curl -sS -o "$body" -w '%{http_code}\n%{content_type}' --max-time 20 -X POST -H @"$hdr" -H 'content-type: application/json' \ --data '{"model":"kumo-key-check","messages":[{"role":"user","content":"x"}],"max_tokens":1}' \ "$obase/chat/completions" /dev/null) || true rm -f "$hdr" code=$(printf '%s\n' "$meta" | sed -n 1p) ctype=$(printf '%s\n' "$meta" | sed -n 2p) KUMO_VERIFY_CODE=$code KUMO_VERIFY=$(verify_classify "$body" "$code" "$ctype") if [ -z "$KUMO_VERIFY" ]; then KUMO_VERIFY=foreign; fi rm -f "$body" } # What the key check means for this run: a refused key stops it with nothing changed; a doubtful answer asks. check_key() { if [ "$verify" = 0 ]; then return 0; fi verify_key case $KUMO_VERIFY in ok) # U3 (round 6): the 402 line is not said here — only where a 402 was seen, or in the «Almost done» block. say "✓ The key is valid: Kumo's gateway accepted it." "✓ Ключ действителен: шлюз Kumo его принял." ;; refused) die 1 "✗ Kumo's gateway refused this key, so nothing was installed or changed. Check the key in the console (Keys) and run the command again." \ "✗ Шлюз Kumo не принял этот ключ, поэтому ничего не установлено и не изменено. Проверьте ключ в консоли (раздел «Ключи») и выполните команду ещё раз." ;; nocurl) warn "curl is missing, so the key was not checked." "curl не найден, ключ не проверен." ;; unreachable) warn "Could not reach the gateway at $obase to check the key (HTTP $KUMO_VERIFY_CODE). Check your network." \ "Не удалось достучаться до шлюза $obase, чтобы проверить ключ (HTTP $KUMO_VERIFY_CODE). Проверьте сеть." ask_continue ;; foreign) warn "$obase does not look like a Kumo gateway (HTTP $KUMO_VERIFY_CODE, not Kumo's answer), so the key was not checked. Check the address (--base)." \ "Адрес $obase не похож на шлюз Kumo (HTTP $KUMO_VERIFY_CODE, ответ не от Kumo), поэтому ключ не проверен. Проверьте адрес (--base)." ask_continue ;; inconclusive) warn "Kumo's gateway answered HTTP $KUMO_VERIFY_CODE, so it is not clear whether the key works." \ "Шлюз Kumo ответил HTTP $KUMO_VERIFY_CODE, поэтому непонятно, работает ли ключ." ask_continue ;; esac } # N2 / R6-m4 (round 7): every file this run changed — the config, Kumo's marker, the shell's start-up file — is noted # (r : put the copy back; d : it did not exist, remove it), one entry per line, fields split by the # unit separator, so a path with a space survives. undo_add() { us=$(printf '\037') if [ "$1" = r ]; then entry="r$us$2$us$3"; else entry="d$us$2"; fi KUMO_UNDO="$KUMO_UNDO $entry" } # N2: the noted changes become ONE short command — a small script at ~/.kumo/undo-.sh (folder 700, file 600) # that refuses to touch anything when a copy it needs is gone, puts every copy back (cp, so a second run gives the same # files: idempotent) and removes what did not exist before. The line printed is «sh ~/.kumo/undo-.sh»; the long # form lives in the file itself. Sol R8-M1 / R8-M2 (round 10): the short form is printed ONLY when everything about the # place it is stored in is PROVEN (undo_dir_ok before the write, undo_file_ok after the rename); otherwise one plain # reason line, then the long form (m4: every path single-quoted) — data only, nothing to execute. Nothing noted (an # unchanged re-run): nothing written, nothing said. Clears the notes. # BUG_FIX_CONTEXT (Sol R8-M1, R8-M2; the third cycle on this surface): rounds 7 and 8 trusted ~/.kumo on a partial check # (its mode string's first column, then «@» read as «no ACL»), so an ACL hidden behind an xattr, and a mode-700 folder # inside a group-writable HOME (renamed away and replaced by another user), both held an undo script the reader was told # to run. The rule is now «prove everything or go data-only»: no heuristic decides trust, any check that cannot run is a no. undo_finish() { if [ -z "$KUMO_UNDO" ]; then return 0; fi us=$(printf '\037') ufile="$HOME/.kumo/undo-$client.sh" if undo_dir_ok && [ ! -L "$ufile" ] && { [ ! -e "$ufile" ] || [ -f "$ufile" ]; }; then kd_first=$KUMO_KD_ID # An exclusive create under a random name inside ~/.kumo (mktemp: mode 600 from the start), then a rename. temp_for "$ufile" ut=$KUMO_T if undo_body > "$ut" && chmod 600 "$ut" && mv -f "$ut" "$ufile" && undo_file_ok "$ufile" "$kd_first"; then say "To undo: sh ~/.kumo/undo-$client.sh" "Вернуть как было: sh ~/.kumo/undo-$client.sh" KUMO_UNDO="" return 0 fi rm -f "$ut" fi say "The undo command was not saved: could not confirm the folder is yours alone — here are the steps by hand" \ "Команду отката не сохранил: не удалось убедиться, что папка доступна только вам — вот шаги вручную" # m-D (round 11): an undo file an EARLIER run stored here would now undo that run, not this one, from a folder that is # no longer proven: a plain file of this user's (not a link) is removed, and the reader is told to use the steps below. # Round 12 (m-1, audit-opus-r11.md): a LINK there is still not followed or removed, but it is said — the reader was # told earlier to run that path. if [ -L "$ufile" ]; then say "There is a link where the undo command was — left as it is; use the steps below" "На месте команды отката лежит ссылка — не трогаю; пользуйтесь шагами ниже" elif [ -f "$ufile" ] && [ -O "$ufile" ] && rm -f "$ufile" 2>/dev/null; then say "The earlier undo command was removed — use the steps below" "Прежняя команда отката удалена — пользуйтесь шагами ниже" fi printf '%s\n' "$KUMO_UNDO" | while IFS="$us" read -r kind one two; do case $kind in r) say "To undo: cp $(sq "$one") $(sq "$two")" "Вернуть как было: cp $(sq "$one") $(sq "$two")" ;; d) say "To undo: rm $(sq "$one")" "Вернуть как было: rm $(sq "$one")" ;; esac done KUMO_UNDO="" } # The first column of «ls -ld» for a path (its type, mode and the «+» / «@» / «.» mark), or nothing. mode_of() { ls -ld "$1" 2>/dev/null | sed -n '1s/[[:space:]].*//p' } # Sol R8-M1 (round 10): does this folder or file carry NO access-list entry that GRANTS anything? Proven, or no: # macOS /bin/ls -lde prints each ACL entry on a line of its own (« 0: …»): exactly one line, and no «+», is a yes — # whatever «@» (extended attributes) says; more lines are a yes only when EVERY one is a numbered «deny» # entry and none says «allow» (a deny entry only takes rights away: every stock macOS HOME carries # « 0: group:everyone deny delete»); # Linux getfacl, when it is here, must print the base entries only (user::, group::, other::); without it, GNU # coreutils ls (which marks any ACL with «+») must show no «+»; any other ls (busybox, uutils) is a no; # else no (no check this script can trust). # Any tool that fails or is missing is a no. acl_free() { case $(uname -s 2>/dev/null) in Darwin) [ -x /bin/ls ] && has grep || return 1 af=$(/bin/ls -lde "$1" 2>/dev/null) || return 1 [ -n "$af" ] || return 1 if [ "$(printf '%s\n' "$af" | sed -n '$=')" = 1 ]; then case $(printf '%s\n' "$af" | sed -n '1s/[[:space:]].*//p') in *+*) return 1 ;; esac return 0 fi # Entry lines only (the first line is the path's own): any line that is not a numbered deny entry, or any # «allow» at all, is a no. # BUG_FIX_CONTEXT (round 10 follow-up): «any ACL line is unproven» refused the short undo on EVERY stock macOS # account (HOME carries «group:everyone deny delete»); a deny entry can only restrict, so only allow is unproven. printf '%s\n' "$af" | sed '1d' | grep -v -E '^ *[0-9]+: .* deny [a-z_,]+$' | grep -q . && return 1 printf '%s\n' "$af" | sed '1d' | grep -q -E ' allow( |$)' && return 1 return 0 ;; Linux) if has getfacl; then af=$(getfacl -c "$1" 2>/dev/null) || return 1 printf '%s\n' "$af" | grep -q '^user::' || return 1 extra=$(printf '%s\n' "$af" | sed '/^$/d; /^#/d; /^user::/d; /^group::/d; /^other::/d') [ -z "$extra" ] || return 1 return 0 fi ls --version 2>/dev/null | sed -n 1p | grep -q '(GNU coreutils)' || return 1 case $(mode_of "$1") in ''|*+*) return 1 ;; esac return 0 ;; esac return 1 } # The device and inode of a path, «dev:ino» (GNU, busybox or BSD stat), or a failure. node_id() { ni=$(stat -c '%d:%i' "$1" 2>/dev/null) || ni=$(stat -f '%d:%i' "$1" 2>/dev/null) || return 1 case $ni in ''|*[!0-9:]*|:*|*:) return 1 ;; esac printf '%s' "$ni" } # Sol R7-M2 (round 8), R8-M1 / R8-M2 (round 10): may ~/.kumo hold a file the reader will later RUN? ALL of these must # be proven, else no: # 1. HOME and ~/.kumo are real folders — not links (judged BEFORE anything changes a mode) — owned by this user; # 2. neither is writable by its group or by others: HOME's mode read as it is (HOME is never chmod-ed), ~/.kumo's # read back exactly 700 after chmod (a missing one is made, umask 077); # 3. neither carries an ACL entry that grants anything — none, or deny entries only (acl_free); # 4. ~/.kumo's device and inode are known (KUMO_KD_ID), so undo_file_ok can tell it was not swapped. # A group-writable HOME lets another user rename ~/.kumo away and put their own in its place (R8-M2), whatever the mode # of ~/.kumo itself; an ACL grants what the mode does not show (R8-M1). undo_dir_ok() { KUMO_KD_ID="" has ls && has sed && has stat || return 1 [ -n "$HOME" ] || return 1 h=$(printf '%s' "$HOME" | sed 's#//*$##') if [ -z "$h" ]; then h=/; kd=/.kumo; else kd="$h/.kumo"; fi if [ -L "$h" ] || [ ! -d "$h" ] || [ ! -O "$h" ]; then return 1; fi case $(mode_of "$h") in d????-??-?*) ;; *) return 1 ;; esac acl_free "$h" || return 1 if [ -L "$kd" ]; then return 1; fi if [ ! -e "$kd" ]; then mkdir "$kd" 2>/dev/null || return 1; fi if [ -L "$kd" ] || [ ! -d "$kd" ] || [ ! -O "$kd" ]; then return 1; fi chmod 700 "$kd" 2>/dev/null || return 1 # «+» marks an ACL: acl_free below decides whether it grants anything (round 10 follow-up: deny-only passes). case $(mode_of "$kd") in drwx------|drwx------@|drwx------.|drwx------+) ;; *) return 1 ;; esac acl_free "$kd" || return 1 KUMO_KD_ID=$(node_id "$kd") || { KUMO_KD_ID=""; return 1; } return 0 } # Sol R8-M2 (round 10): after the rename, the undo file is re-verified — still a regular file (not a link), owned by # this user, mode 600 with no ACL entry — and ~/.kumo re-proven (undo_dir_ok) and still the SAME folder ($2, its # device and inode before the write). undo_file_ok() { [ -f "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] || return 1 case $(mode_of "$1") in -rw-------|-rw-------@|-rw-------.|-rw-------+) ;; *) return 1 ;; esac acl_free "$1" || return 1 undo_dir_ok || return 1 [ -n "$2" ] && [ "$KUMO_KD_ID" = "$2" ] } # The undo script itself (stdout): the checks first, then the restores, then one line of what was done. undo_body() { us=$(printf '\037') if [ "$KUMO_RU" = 1 ]; then gone="Ничего не изменено: нет копии" done_line="Готово: файлы $name возвращены к тому, что было до последнего запуска Kumo." else gone="Nothing was changed: this copy is missing:" done_line="Done: the files of $name are back as they were before Kumo's last run." fi printf '#!/bin/sh\n' printf '# Kumo connect: puts back what its last run for %s changed (written %s).\n' "$name" "$(stamp)" printf '# sh ~/.kumo/undo-%s.sh\n' "$client" printf '# Safe to run again: each file gets the same copy back. It changes nothing when a copy is missing.\n' printf '%s\n' "$KUMO_UNDO" | while IFS="$us" read -r kind one two; do if [ "$kind" = r ]; then printf 'if [ ! -f %s ]; then echo %s >&2; exit 1; fi\n' "$(sq "$one")" "$(sq "$gone $one")" fi done printf '%s\n' "$KUMO_UNDO" | while IFS="$us" read -r kind one two; do case $kind in r) printf 'cp %s %s || exit 1\n' "$(sq "$one")" "$(sq "$two")" ;; d) printf 'rm -f %s || exit 1\n' "$(sq "$one")" ;; esac done printf 'echo %s\n' "$(sq "$done_line")" } # P3: Claude Code's slots as one line, and each slot the reader set themselves (left as it was) on a line of its own. slots_said() { slots="" for one in "fable $fable" "opus $opus" "sonnet $sonnet" "haiku $haiku"; do case $one in *" ") ;; *) if [ -z "$slots" ]; then slots=$one; else slots="$slots · $one"; fi ;; esac done if [ -n "$slots" ]; then say " model slots: $slots" " слоты моделей: $slots"; fi if [ -n "$KUMO_KEPT" ]; then printf '%s\n' "$KUMO_KEPT" | while IFS= read -r kept; do if [ -n "$kept" ]; then say " kept as you set it: $kept" " оставлен как вы задали: $kept"; fi done fi } # What this run changed, in a few lines: the model, the provider, the key's file, how to undo, how to revoke. summary() { wrote_shown=$(tilde "$KUMO_WROTE") say "Changed $wrote_shown:" "Изменён $wrote_shown:" if [ -n "$new_model" ]; then if [ -n "$old_model" ] && [ "$old_model" != "$new_model" ] && [ "$old_model" != "{}" ]; then say " model: was $old_model → now $new_model" " model: было $old_model → стало $new_model" else say " model: $new_model" " model: $new_model" fi fi if [ "$client" = claude-code ]; then slots_said say " Kumo's address: env.ANTHROPIC_BASE_URL = $abase" " адрес Kumo: env.ANTHROPIC_BASE_URL = $abase" # m8: the reader's old key leaves the file — said, not done silently. if [ -n "$had_apikey" ]; then say " env.ANTHROPIC_API_KEY removed (kept in the backup)" " env.ANTHROPIC_API_KEY удалён (сохранён в копии)" fi elif [ -n "$had_kumo" ]; then say " provider kumo updated" " обновлён провайдер kumo" else say " provider kumo added" " добавлен провайдер kumo" fi if [ "$client" = codex ] && [ -n "$KUMO_PROF_SET" ]; then say " profile $KUMO_PROF_SET: model_provider = \"kumo\" (was $KUMO_PROF_WAS)" " профиль $KUMO_PROF_SET: model_provider = \"kumo\" (было $KUMO_PROF_WAS)" fi if [ -n "$KUMO_MODELS_N" ] && [ "$KUMO_MODELS_N" != 1 ]; then say " Kumo models registered: $KUMO_MODELS_N" " моделей Kumo в списке: $KUMO_MODELS_N" fi say " key saved in $wrote_shown (mode 600)" " ключ сохранён в $wrote_shown (права 600)" say "To revoke the key: Kumo console → Keys" "Отозвать ключ: консоль Kumo → Ключи" } # The config file of $client, honouring CLAUDE_CONFIG_DIR, CODEX_HOME and XDG_CONFIG_HOME. Sets dir and file. config_file() { case $client in claude-code) dir=$CLAUDE_CONFIG_DIR if [ -z "$dir" ]; then dir="$HOME/.claude"; fi file="$dir/settings.json" ;; codex) dir=$CODEX_HOME if [ -z "$dir" ]; then dir="$HOME/.codex"; fi file="$dir/config.toml" ;; opencode) dir=$XDG_CONFIG_HOME if [ -z "$dir" ]; then dir="$HOME/.config"; fi file="$dir/opencode/opencode.json" ;; openclaw) dir="$HOME/.openclaw" file="$dir/openclaw.json" ;; pi) dir="$HOME/.pi/agent" file="$dir/models.json" ;; esac } # P3: Claude Code's four model slots, ANTHROPIC_DEFAULT_{FABLE,OPUS,SONNET,HAIKU}_MODEL. A slot the command names # (--fable, --opus, --sonnet, --haiku) takes that model; the Sonnet slot is the model itself; every other slot takes # the gateway's newest proven model of its tier (check_model), else the model. Sol R5-M4 (round 6): ownership is PER # SLOT and does not hang on the gateway: a slot that already holds a value is Kumo's only when that value is one Kumo # wrote — recorded in Kumo's marker (claude_marker) as that slot's value or as the model — and then it follows this # run; any other value is the reader's own: it stays, and the summary names it, on a first setup, a re-run and a # gateway change alike. A file from before the marker (round 5) falls back to the old rule, for this gateway only: the # old model or Kumo's tier pick. Sets fable, opus, sonnet, haiku, KUMO_KEPT (one «NAME = value» line per kept slot) # and KUMO_MK_{FABLE,OPUS,SONNET,HAIKU} (what Kumo writes, empty for a kept slot — the marker's next content). claude_slots() { KUMO_KEPT="" KUMO_SLOT_DROP="" marker=$(claude_marker) mk_model=$(json_get "$marker" model) mk_slots=$(json_get "$marker" slots) compat=0 if [ -z "$mk_model" ] && [ -n "$had_kumo" ] && [ "$(printf '%s' "$had_kumo" | sed 's#/*$##')" = "$abase" ]; then compat=1; fi for tier in FABLE OPUS SONNET HAIKU; do case $tier in FABLE) given=$xfable; pick=$KUMO_PICK_FABLE ;; OPUS) given=$xopus; pick=$KUMO_PICK_OPUS ;; SONNET) given=$xsonnet; pick=""; if [ -n "$KUMO_AUTO_SLOTS" ]; then pick=$KUMO_PICK_SONNET; fi ;; HAIKU) given=$xhaiku; pick=$KUMO_PICK_HAIKU ;; esac want=$given mine=1 if [ -z "$want" ]; then want=$pick # #533 (owner follow-up 2026-11-08): with no model named (KUMO_AUTO_SLOTS) a tier the key has no model for stays unset. if [ -z "$want" ] && [ -z "$KUMO_AUTO_SLOTS" ]; then want=$model; fi cur=$(json_get "$file" "env.ANTHROPIC_DEFAULT_$tier"_MODEL) # BUG_FIX_CONTEXT (Sol R6-M2): ownership was judged only when the value had to change («cur != want»), so a # reader's slot that happened to equal today's model was claimed silently — recorded as Kumo's in the marker — # and overwritten by the next run. Ownership is now decided for EVERY slot that holds a value, whether or not # it changes: with a marker, a slot is Kumo's only when the marker names that very value for that slot (the # marker's model counts only for a marker from before slots were recorded); an unmarked value is the reader's. if [ -n "$cur" ] && [ "$cur" != "{}" ]; then ours=0 if [ -n "$mk_model" ]; then mk_slot=$(json_get "$marker" "slots.$tier") if [ -n "$mk_slot" ] && [ "$cur" = "$mk_slot" ]; then ours=1 elif [ "$mk_slots" != "{}" ] && [ "$cur" = "$mk_model" ]; then ours=1 fi elif [ "$compat" = 1 ]; then if [ "$cur" = "$old_model" ] || [ "$cur" = "$KUMO_PICK_SONNET" ] || { [ -n "$pick" ] && [ "$cur" = "$pick" ]; }; then ours=1; fi fi if [ "$ours" = 0 ]; then want=$cur mine=0 KUMO_KEPT="$KUMO_KEPT ANTHROPIC_DEFAULT_$tier""_MODEL = $cur" elif [ -z "$want" ]; then # BUG_FIX_CONTEXT (#533 final Codex P2): a family the key no longer allows left its slot empty, and the patch # omits empty slots, so the merge KEPT Kumo's old value — a model the narrower key cannot use. A slot Kumo owns # (the marker names that value) with nothing to put in it is now deleted (KUMO_SLOT_DROP -> KUMO_DROP); a slot # the reader set is never ours here, and the backup taken by write_json lets the undo bring it back. KUMO_SLOT_DROP="$KUMO_SLOT_DROP env.ANTHROPIC_DEFAULT_$tier""_MODEL" fi fi fi wrote=$want if [ "$mine" = 0 ]; then wrote=""; fi case $tier in FABLE) fable=$want; KUMO_MK_FABLE=$wrote ;; OPUS) opus=$want; KUMO_MK_OPUS=$wrote ;; SONNET) sonnet=$want; KUMO_MK_SONNET=$wrote ;; HAIKU) haiku=$want; KUMO_MK_HAIKU=$wrote ;; esac done } # A13-3 (round 14): with no main model (none named, and the gateway's list could not give one), the slots the command # names — --fable / --opus / --sonnet / --haiku, or claude-code.= in --models — are kept exactly as given; the # others are left alone. No marker is written (there is no Kumo model to record), so a later run reads these slots as # the reader's own and keeps them. slots_said names them in the summary. # BUG_FIX_CONTEXT (round 14, A13-3, audit-astra-r13.md): «if [ -n "$model" ]; then claude_slots; fi» skipped every # slot when no model was known, so «claude-code.opus=tier» was silently discarded and the run said nothing about it. claude_given() { fable=$xfable opus=$xopus sonnet=$xsonnet haiku=$xhaiku KUMO_KEPT="" KUMO_SLOT_DROP="" } # Kumo's marker beside Claude Code's settings.json: the model and the slot values Kumo itself wrote (no key), so a # later run can tell Kumo's slots from the reader's whatever gateway the file points at (Sol R5-M4). claude_marker() { printf '%s' "$dir/kumo-connect.json" } # Write the marker (owner-only, atomic); a path that is not a regular file is left alone — the marker is a hint only. # N2 / R6-m4: an unchanged marker is not rewritten; a changed one is backed up first and noted for the undo file. write_marker() { mkf=$(claude_marker) if [ -e "$mkf" ] && [ ! -f "$mkf" ]; then return 0; fi if [ "$KUMO_ENGINE" = none ]; then return 0; fi temp_for "$mkf" mkt=$KUMO_T printf '{\n "note": "Written by Kumo connect: the models it set in settings.json, so a later run keeps the ones you set.",\n "base": %s,\n "model": %s,\n "slots": {\n "FABLE": %s,\n "OPUS": %s,\n "SONNET": %s,\n "HAIKU": %s\n }\n}\n' \ "$(js "$abase")" "$(js "$1")" "$(js "$KUMO_MK_FABLE")" "$(js "$KUMO_MK_OPUS")" "$(js "$KUMO_MK_SONNET")" "$(js "$KUMO_MK_HAIKU")" > "$mkt" || { rm -f "$mkt"; return 0; } chmod 600 "$mkt" if [ -f "$mkf" ] && cmp -s "$mkt" "$mkf"; then rm -f "$mkt"; return 0; fi mkb="" if [ -f "$mkf" ]; then mkb=$(backup_of "$mkf") && cat "$mkf" > "$mkb" && chmod 600 "$mkb" || { rm -f "$mkt"; return 0; } fi if mv -f "$mkt" "$mkf"; then if [ -n "$mkb" ]; then undo_add r "$mkb" "$mkf"; else undo_add d "$mkf"; fi fi } # Sol R5-m5: env.ANTHROPIC_MODEL beats «model» in Claude Code. Kumo never writes it, so it is never Kumo's: it is left # as it is, and when it differs from the model this run set, the reader is told that it wins and how to drop it. main_override() { am=$(json_get "$file" env.ANTHROPIC_MODEL) if [ -n "$am" ] && [ "$am" != "{}" ] && [ "$am" != "$1" ]; then warn "kept as you set it: env.ANTHROPIC_MODEL = $am — it wins over «model», so Claude Code starts on $am, not on $1. Remove it from env in $file to use $1." \ "оставлен как вы задали: env.ANTHROPIC_MODEL = $am — он важнее «model», поэтому Claude Code запустится на $am, а не на $1. Чтобы работать на $1, удалите его из env в $file." fi } # One client, end to end once the key is known: its file, what it held (for the summary), the patch, the write, the # summary, the warnings about what still bypasses Kumo. Sets next (the command that starts it). configure() { KUMO_WROTE="" KUMO_SIDE="" KUMO_BACKUP="" KUMO_CHANGED=0 KUMO_MODELS_N="" KUMO_PROFILES="" KUMO_KEPT="" old_model="" had_kumo="" had_apikey="" config_file check_plain "$file" case $client in claude-code) old_model=$(json_get "$file" model) had_kumo=$(json_get "$file" env.ANTHROPIC_BASE_URL) had_apikey=$(json_get "$file" env.ANTHROPIC_API_KEY) new_model=$model sonnet="" if [ -n "$model" ] || [ -n "$KUMO_AUTO_SLOTS" ]; then claude_slots; else claude_given; fi patch_claude seal_patch write_json "$file" if [ -n "$KUMO_WROTE" ] && [ -n "$model" ]; then write_marker "$model" elif [ -n "$KUMO_WROTE" ] && [ -n "$KUMO_AUTO_SLOTS" ]; then # #533: the marker records the slots Kumo picked (its «model» names the first of them, so a later run reads it). am_ref=$KUMO_MK_SONNET for am_one in "$KUMO_MK_OPUS" "$KUMO_MK_HAIKU" "$KUMO_MK_FABLE"; do if [ -z "$am_ref" ]; then am_ref=$am_one; fi; done if [ -n "$am_ref" ]; then write_marker "$am_ref"; fi fi next=claude ;; codex) new_model=$model write_codex "$file" next=codex ;; opencode) old_model=$(json_get "$file" model) had_kumo=$(json_get "$file" provider.kumo) new_model="" if [ -n "$KUMO_NAMED" ]; then new_model="kumo/$model"; fi patch_opencode expand_models seal_patch write_json "$file" if [ -f "$dir/opencode/opencode.jsonc" ]; then warn "$dir/opencode/opencode.jsonc exists too: OpenCode reads both; remove any other provider default there if it wins." \ "Рядом есть $dir/opencode/opencode.jsonc: OpenCode читает оба файла; если там задана другая модель по умолчанию, уберите её." fi next=opencode ;; openclaw) old_model=$(json_get "$file" agents.defaults.model.primary) had_kumo=$(json_get "$file" models.providers.kumo) new_model="kumo/$model" patch_openclaw expand_models seal_patch write_json "$file" next="openclaw tui" ;; pi) had_kumo=$(json_get "$file" providers.kumo) new_model="" patch_pi expand_models seal_patch write_json "$file" # BUG_FIX_CONTEXT (round 12b, R11-M1, sol-edge-r11.md): the model went into this printed command bare, so a # catalogue id «edge-;touch$IFS-X;#» ran a second command when the reader pasted «Start it with: …». The id is # one shell word now (sw: bare when plainly safe, else single-quoted), and the catalogue engines drop any id # outside [A-Za-z0-9._:/@+-]{1,200} before it is read at all. next="pi --provider kumo --model $(sw "$model")" ;; esac if [ -n "$KUMO_WROTE" ]; then if [ "$KUMO_CHANGED" = 0 ]; then say "Already set up: $(tilde "$KUMO_WROTE") is unchanged." "Уже настроено: $(tilde "$KUMO_WROTE") не изменился." else summary fi if [ "$client" = claude-code ] && [ -n "$model" ]; then main_override "$model"; fi # S8 / m2: a key elsewhere in the file that sends the tool past Kumo is named, not silently left. overs=$(json_overrides "$KUMO_WROTE") if [ -n "$overs" ]; then while IFS= read -r over; do if [ "$client" = opencode ]; then warn "$over in $KUMO_WROTE overrides Kumo's model: that agent still goes elsewhere. Remove the line, or set it to kumo/$model." \ "$over в $KUMO_WROTE перекрывает модель Kumo: этот агент по-прежнему пойдёт мимо Kumo. Удалите строку или поставьте kumo/$model." else warn "$over in $KUMO_WROTE switches Claude Code to another cloud, past Kumo. Remove it to use Kumo." \ "$over в $KUMO_WROTE переключает Claude Code на другое облако, мимо Kumo. Удалите эту строку, чтобы работать через Kumo." fi done < 0) { out = out substr(line, 1, i - 1) v; line = substr(line, i + length(s)) } least = 4 if (match(s, /^[A-Za-z0-9]+_[A-Za-z0-9]+_/)) least = RLENGTH + 1 for (n = length(s) - 1; n >= least; n--) { if (length(line) >= n && substr(line, length(line) - n + 1) == substr(s, 1, n)) { line = substr(line, 1, length(line) - n) v; break } } } print out line }' } # P2: the free end-to-end proof. The configured tool runs once, non-interactively — no terminal (/dev/null) || exe="" if [ -z "$exe" ] && [ -n "$KUMO_BIN_DIR" ] && [ -x "$KUMO_BIN_DIR/$bin" ]; then exe="$KUMO_BIN_DIR/$bin"; fi if [ -z "$exe" ]; then KUMO_PROBE=absent; return 0; fi KUMO_PROBE_EXE=$exe vendor_place "$exe" # Sol R7-m1 (round 8): the probe runs only when its process group can be owned — perl, setsid, or job control that # really gives a group here. Without one, whatever the tool leaves running could not be found after it exits, so the # check is skipped and says why (KUMO_PROBE=nogroup) instead of promising a clean stop it cannot make. # BUG_FIX_CONTEXT (Sol R7-m1): dash ignores «set -m» outside an interactive shell, so with neither perl nor setsid # the tool ran in this script's own group; a child it left behind outlived the run, re-parented to init, and the # tree walk (timeout only) could not find it any more. if ! has perl && ! has setsid && ! group_works; then KUMO_PROBE=nogroup; return 0; fi # N16-1 (round 17): the probe needs no tools, so none of the reader's MCP servers is started for it — Claude Code # runs with --strict-mcp-config and an empty MCP config when its own «--help» lists the flag (asked within 5 seconds, # in its own group, as «pi --version» is: pi_version); a Claude Code without it runs with the flags it always had. # BUG_FIX_CONTEXT (round 17, N16-1, audit-opus-r16.md): the probe started the reader's stdio MCP servers, and one with # an expired token wrote «Error: 401 Unauthorized» into --debug-file — a working connection was called a sign-in error. # The ask is part of the probe's ONE 20-second deadline (Sol R5-M3): the whole seconds it took are counted as waited. # n18-4 (round 19, probe-verdict-design.md §3): the same ONE ask also looks for --settings; when it is listed the # probe runs with «--settings '{"disableAllHooks":true}'», so no hook of the reader's runs under the probe — without # touching auth. --bare is never used, even when listed: its own help binds auth to ANTHROPIC_API_KEY / apiKeyHelper # only, and the installer writes ANTHROPIC_AUTH_TOKEN, the auth path the reader's real sessions take. # BUG_FIX_CONTEXT (round 19, n18-4, audit-opus-r18.md): round 18 probed with --bare; a Claude Code that enforces its # documented --bare auth rule would send no Kumo credential, and every probe would be a TRUE Kumo 401 for a key that # works in the reader's own sessions. Hooks no longer decide the verdict anyway (the allow-list below); turning them # off only saves the probe their time and side effects. KUMO_MCP_STRICT=0 KUMO_CC_SETTINGS=0 ask_took=0 if [ "$client" = claude-code ]; then ask_from=$(date +%s 2>/dev/null) pi_version "$exe" --help 262144 --strict-mcp-config --settings KUMO_MCP_STRICT=$KUMO_PI_HAS KUMO_CC_SETTINGS=$KUMO_PI_HAS2 KUMO_PI_HUNG="" ask_to=$(date +%s 2>/dev/null) case "$ask_from:$ask_to" in *[!0-9:]*|:*|*:) ask_took=0 ;; *) ask_took=$((ask_to - ask_from)) ;; esac if [ "$ask_took" -lt 0 ]; then ask_took=0; fi if [ "$ask_took" -gt 20 ]; then ask_took=20; fi fi wd=$(mktemp -d "$KUMO_TMP/kumo-probe.XXXXXX") || { KUMO_PROBE=skipped; return 0; } clean_add "$wd" # R13-M1 (round 14): the run's own files — the tool's pid, its «done» mark, the bounded copies of what it printed — # live in a private folder OUTSIDE the tool's working folder, so the tool never knows their names. ctl=$(mktemp -d "$KUMO_TMP/kumo-ctl.XXXXXX") || { rm -rf "$wd"; KUMO_PROBE=skipped; return 0; } clean_add "$ctl" said="$wd/said" own_new if [ "$KUMO_RU" = 1 ]; then printf '%s' "Проверяю, что $name отвечает через Kumo (до 20 секунд)" >&2; else printf '%s' "Checking that $name answers through Kumo (up to 20 seconds)" >&2; fi # The tool runs in the background of a subshell that records its pid and, when it ends, a «done» mark: the wait # below never mistakes a finished tool for a running one. R6-M3 (round 7): the tool starts as the leader of its OWN # process group (in_group), so whatever it starts — even a child its TERM handler spawns while it is being stopped, # or one it leaves behind when it exits — is in that group, and the whole group is stopped on a timeout, on an # interruption (kumo_cleanup) AND after a normal end, before the working folder is removed. # BUG_FIX_CONTEXT (Sol R6-M3): kill_tree took ONE snapshot of the tree, TERMed it and KILLed that snapshot a second # later — a child the tool's TERM handler spawned after the snapshot (re-parented to init once its parent exited) # escaped, and a tool that exited normally was never stopped at all, so whatever it left in the background lived on. # A process group catches both: membership does not depend on a snapshot or on a living parent. # Round 19 (probe-verdict-design.md §2.1): the probe model carries this run's nonce — 16 random bytes as 32 lowercase # hex — and the gateway on this branch binds its proof to it (connectprobe.go: error.probe = # "kumo-connect-check-:<16 hex>"). No readable /dev/urandom: no nonce and the plain model (no weaker source is # mixed in), so Claude Code can at most be «unconfirmed» — the degraded path of §2.3. KUMO_NONCE=$(od -An -N16 -tx1 /dev/urandom 2>/dev/null /dev/null | awk 'NR == 1 && length($0) == 32 && $0 !~ /[^0-9a-f]/ { print }' 2>/dev/null) pm=kumo-connect-check if [ -n "$KUMO_NONCE" ]; then pm="kumo-connect-check-$KUMO_NONCE"; fi # Round 19 (design §2.4): Claude Code's debug file is looked at on every tick for the FIRST Kumo line — Kumo's 401, # a refusal, or the 404 carrying this run's proof — and the wait ends there (probe_peek). # n19-1 (round 20): so does the first API line with a 401 / 403 and NO Kumo code (early_foe; round 21: KUMO_M_KUMO_CODE) — another server's # refusal, which Claude Code keeps retrying exactly as it retries Kumo's. early="" early_foe="" if [ "$client" = claude-code ]; then early_foe=$KUMO_M_API_DENIED early="$KUMO_M_API.*($KUMO_M_SIGNIN_CODE|$KUMO_M_REFUSED" if [ -n "$KUMO_NONCE" ]; then early="$early|$KUMO_M_OK_CODE.*$KUMO_M_PROBE_HEAD$KUMO_NONCE$KUMO_M_PROBE_TAIL|$KUMO_M_PROBE_HEAD$KUMO_NONCE$KUMO_M_PROBE_TAIL.*$KUMO_M_OK_CODE" fi early="$early)" fi if [ "$client" = opencode ]; then OPENCODE_CONFIG_CONTENT="{\"provider\":{\"kumo\":{\"models\":{\"$pm\":{\"name\":\"$pm\"}}}}}"; fi ( cd "$wd" || exit 0 if [ "$client" = opencode ]; then export OPENCODE_CONFIG_CONTENT; fi # Without perl or setsid, job control makes the background job below a group leader (bash; dash ignores it). if ! has perl && ! has setsid; then set -m 2>/dev/null; fi # R13-M2 (round 14): the tool carries the run's ownership mark — descriptor 7 open on it, its path in # KUMO_OWNED_BY — and so does everything it starts that does not close it (own_holders). KUMO_OWNED_BY=$KUMO_OWN_MARK export KUMO_OWNED_BY case $client in claude-code) # Round 19 (design §3): non-essential traffic, telemetry and the OTel exporters off — for this child only (a # subshell), never written into the reader's settings. Defence in depth for the probe's time and network: a # reader's settings.json «env» still outranks them, and the verdict does not rely on them. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1; DISABLE_TELEMETRY=1; DISABLE_ERROR_REPORTING=1; DISABLE_AUTOUPDATER=1 DISABLE_BUG_COMMAND=1; CLAUDE_CODE_ENABLE_TELEMETRY=0; OTEL_METRICS_EXPORTER=none; OTEL_LOGS_EXPORTER=none OTEL_TRACES_EXPORTER=none export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC DISABLE_TELEMETRY DISABLE_ERROR_REPORTING DISABLE_AUTOUPDATER \ DISABLE_BUG_COMMAND CLAUDE_CODE_ENABLE_TELEMETRY OTEL_METRICS_EXPORTER OTEL_LOGS_EXPORTER OTEL_TRACES_EXPORTER # N17-1 (round 18): the argument list is built once (this subshell's own positional parameters), each # capability flag only when «--help» listed it — the twin of connect.ps1's $words. Round 19 (n18-4): never # --bare; --settings '{"disableAllHooks":true}' when listed. set -- -p --no-session-persistence --debug-file "$wd/debug.log" if [ "$KUMO_CC_SETTINGS" = 1 ]; then set -- "$@" --settings '{"disableAllHooks":true}'; fi if [ "$KUMO_MCP_STRICT" = 1 ]; then set -- "$@" --strict-mcp-config --mcp-config '{"mcpServers":{}}'; fi in_group "$exe" "$@" --model "$pm" x "$said" 2>&1 7<"$KUMO_OWN_FD" & ;; codex) in_group "$exe" exec --skip-git-repo-check --ephemeral -m "$pm" x "$said" 2>&1 7<"$KUMO_OWN_FD" & ;; opencode) in_group "$exe" run -m "kumo/$pm" x "$said" 2>&1 7<"$KUMO_OWN_FD" & ;; esac printf '%s\n' "$!" > "$ctl/pid" wait "$!" : > "$ctl/done" ) /dev/null 2>&1 & runner=$! if [ "$client" = opencode ]; then unset OPENCODE_CONFIG_CONTENT; fi waited=$ask_took timed=0 tool="" ticks=0 while [ ! -f "$ctl/done" ]; do if [ -z "$tool" ]; then tool=$(cat "$ctl/pid" 2>/dev/null); KUMO_PGROUP=$tool; fi # R13-M2 (round 14): every tick adds what now runs under the tool (or under anything already owned) to the run's # owned set — so a child that leaves the group while its parent lives is still stopped at the end. own_track "$tool" if [ "$waited" -ge 20 ]; then timed=1 if [ -z "$tool" ]; then tool=$(cat "$ctl/pid" 2>/dev/null); fi # The group exists exactly when the tool got one (its id is the tool's pid, never this script's group); without # one, the tree walk stands in. if [ -n "$tool" ] && kill -s 0 -- -"$tool" 2>/dev/null; then stop_group "$tool"; elif [ -n "$tool" ]; then kill_tree "$tool"; fi break fi # Round 19 (design §2.4): Kumo's first answer in the debug file ends the wait — a real Kumo 401 no longer costs the # full 20 seconds while Claude Code retries it; the tool is stopped below as after a normal end. if [ -n "$early" ] && probe_peek "$wd/debug.log" "$ctl" "$early" "$early_foe"; then break; fi # Half-second ticks (the owned set is walked on each); «waited» still counts whole seconds. if sleep 0.5 2>/dev/null; then ticks=$((ticks + 1)); else sleep 1; ticks=$((ticks + 2)); fi if [ $((ticks % 2)) = 0 ]; then waited=$((waited + 1)) # R9-5 (round 9, r8-sonnet-p11): each progress mark is spaced, so «(до 20 секунд)» is never followed by «..». # BUG_FIX_CONTEXT: a bare '.' read as a doubled full stop after the closing parenthesis («секунд)..»). if [ $((waited % 2)) = 0 ]; then printf ' .' >&2; fi fi done # A normal end too: whatever the tool left running in its group is stopped (R6-M3, the detach case) — and, R13-M2 # (round 14), everything the run owns outside that group (own_stop). if [ -z "$tool" ]; then tool=$(cat "$ctl/pid" 2>/dev/null); fi if [ -n "$tool" ]; then own_track "$tool"; stop_group "$tool"; fi own_stop probe_peek_stop "$ctl" KUMO_PGROUP="" wait "$runner" 2>/dev/null printf '\n' >&2 # What the tool printed comes first, then Claude Code's debug file (whole since round 16, less the MCP and hook lines # since rounds 17 / 18 — see below). R13-M1 (round 14): each read is bounded — a regular file only, at most a cap of bytes, and one shared 3-second # budget (probe_read) — and its copy lands in the private control folder, which the tool cannot name. # BUG_FIX_CONTEXT (round 14, R13-M1, sol-edge-r13.md): «cat "$said"» ran after the deadline; a tool that replaced # its output file with a FIFO (or left a file that never ends) held the run forever, past every probe limit. KUMO_READ_LEFT=30 KUMO_READ_BIG=0 KUMO_READ_FAIL=0 probe_read "$said" "$ctl/said" 65536 probe_read "$wd/debug.log" "$ctl/debug" 8388608 # The whole of both bounded copies must be readable (a failing read is «unread», R15-M1). cat "$ctl/said" "$ctl/debug" > "$ctl/all" 2>/dev/null {», the attribution anchor — a hook, an MCP server or an exporter never STARTS a # line with it), plus Kumo's sentences in what it printed. Codex / OpenCode: what they printed (they print Kumo's # message). In that text only Kumo's envelope counts (the shape anchor): its code «unauthenticated» or sentence → auth; # one of its 403 / 402 codes → refused; «model_not_found» WITH this run's probe proof → ok (Codex / OpenCode: Kumo's # 404 sentence too, they print no body); Kumo's 404 without this run's proof → unconfirmed; nothing of Kumo's → # foreign. Numerals 401 / 403, «Unauthorized», authentication_error, permission_denied, «Please sign in» decide # nothing: they are every HTTP client's words, not Kumo's. Precedence (§2.4): auth → refused → timeout → big → unread # → ok → unconfirmed → foreign; round 20 (n19-1): API lines none of which carries a Kumo code → foreign, right after # refused. # BUG_FIX_CONTEXT (round 19, N18-1 / n18-4, audit-opus-r18.md; the class N15-M2, N16-1, N17-1, N18-1): a deny-list of # third-party writers (MCP servers r16, hooks r17) grew by one door per round — r18's was an OpenTelemetry exporter's # «"code":"401"» and Claude Code's own «1P event logging … status=403» — and each turned a working connection into # «sign-in error … check that the key is not revoked». The open set of writers cannot be listed; the one writer that # matters can be named. A missed or changed format now ends «foreign» (Almost done), never a false Done or sign-in. api="$ctl/api" : > "$api" ev="$ctl/said" if [ "$client" = claude-code ]; then grep -E -e "$KUMO_M_API" "$ctl/debug" > "$api" 2>/dev/null /dev/null /dev/null; then k_ok=1; fi if [ "$client" != claude-code ] && grep -q -F -e "$KUMO_M_OK_TEXT" "$ctl/said" 2>/dev/null; then k_ok=1; fi if grep -q -E -e "$KUMO_M_OK_CODE" "$ev" 2>/dev/null || grep -q -F -e "$KUMO_M_OK_TEXT" "$ev" "$ctl/said" 2>/dev/null; then k_like=1; fi # n17-2 (round 18) stands: Kumo's own refusal captured before the deadline outranks the timeout — it is specific, and # the timeout is its consequence (Claude Code retries a 401 for about 3 minutes). if grep -q -E -e "$KUMO_M_SIGNIN_CODE" "$ev" 2>/dev/null || grep -q -F -e "$KUMO_M_SIGNIN_TEXT" "$ev" "$ctl/said" 2>/dev/null; then KUMO_PROBE=auth elif grep -q -E -e "$KUMO_M_REFUSED" "$ev" 2>/dev/null; then KUMO_PROBE=refused # n19-1 (round 20): API lines exist and none carries a Kumo code — the answer came from another server before the # deadline, and any timeout after it is only Claude Code's retry loop: «foreign» (Details picks «elsewhere» below). # BUG_FIX_CONTEXT (round 20, n19-1, audit-opus-r19.md): the timeout came first, so on the real client an Anthropic # 401 at 0.2 s, retried for 20 s, read «did not answer — check your internet or proxy» beside a Details line quoting # that very answer. elif [ -s "$api" ] && ! grep -q -E -e "$KUMO_M_KUMO_CODE" "$api" 2>/dev/null; then KUMO_PROBE=foreign elif [ "$timed" = 1 ]; then KUMO_PROBE=timeout elif [ "$KUMO_READ_BIG" = 1 ]; then KUMO_PROBE=big elif [ "$KUMO_READ_FAIL" = 1 ]; then KUMO_PROBE=unread elif [ "$k_ok" = 1 ]; then KUMO_PROBE=ok elif [ "$k_like" = 1 ]; then KUMO_PROBE=unconfirmed else KUMO_PROBE=foreign fi # «Details»: the line that decided — Kumo's refusal for auth / refused, Kumo's unproven 404 for unconfirmed; any # other verdict quotes the first API line (Claude Code), else the tool's own sign-in sentence, else the first # error-like line of what it printed and of the debug file's [ERROR] / Kumo lines, less the MCP and hook lines (kept # for a Claude Code without --settings, whose hooks still run — they choose the quoted line only, never the verdict). # For «foreign» the chosen line also chooses the wording (KUMO_PROBE_NOTE): an API line without a Kumo code — the # request went to another server; the tool's own sign-in sentence — it asked to sign in instead of using the key. # A18-1 (round 19): the key is masked in the WHOLE line first (mask_in), and only then is it cut to 200 characters. # Every pick reads ONE stream (cat … | grep), so no file name ever prefixes the quoted line. # BUG_FIX_CONTEXT (round 19): «grep -F x a b» prefixed «:» to the line, which named a private temp path in # Details and pushed the key past the 200-character cut. shown="$ctl/shown" { cat "$ctl/said"; awk '!/^([^ ]* )?\[[A-Za-z]+\] ("?MCP server |\[MCP\]|"?Hooks?[ :])/ && (index($0, "[ERROR]") || index($0, "No live model answers") || index($0, "model_not_found"))' "$ctl/debug"; } > "$shown" 2>/dev/null "$pick" KUMO_PROBE_NOTE="" case $KUMO_PROBE in ok) ;; auth) { grep -E -e "$KUMO_M_SIGNIN_CODE" "$ev"; cat "$ev" "$ctl/said" | grep -F -e "$KUMO_M_SIGNIN_TEXT"; } > "$pick" 2>/dev/null "$pick" 2>/dev/null "$pick" 2>/dev/null "$pick" 2>/dev/null /dev/null; then KUMO_PROBE_NOTE=elsewhere; fi elif grep -i -E -e "$KUMO_PROBE_SAID_RE" "$ctl/said" > "$pick" 2>/dev/null "$pick" 2>/dev/null /dev/null /dev/null) # Claude Code keeps an (empty) project folder per working folder, even with --no-session-persistence (measured # 2.1.318): the one the probe's temp folder made is removed — only while empty, so nothing of the reader's goes. if [ "$client" = claude-code ]; then suffix=$(printf '%s' "$wd" | sed 's#.*/kumo-probe\.##') cdir=$CLAUDE_CONFIG_DIR if [ -z "$cdir" ]; then cdir="$HOME/.claude"; fi for pd in "$cdir/projects/"*"-kumo-probe-$suffix"; do if [ -d "$pd" ]; then rmdir "$pd/memory" 2>/dev/null; rmdir "$pd" 2>/dev/null; fi done fi # R6-M3: the working folder goes once its group is stopped and its words are read. rm -rf "$wd" "$ctl" } # R6-M3 (round 7): run a program as the leader of a process group of its own, in place (exec — its pid stays the one # the caller recorded). Measured 2026-11-05 on macOS /bin/sh (bash 3.2), /bin/dash, /bin/bash and zsh: perl's setpgrp # gives the group in all four; util-linux setsid (Linux, not macOS) gives a session, which is a group too; «set -m» # gives one under bash but NOT under dash — so: perl, else setsid, else «set -m» in the caller's subshell, else the # program as it is (no group exists then, and the old tree walk stands in). in_group() { if has perl; then exec perl -e 'setpgrp(0, 0); exec { $ARGV[0] } @ARGV or exit 127' "$@"; fi if has setsid; then exec setsid "$@"; fi exec "$@" } # Sol R7-m1 (round 8): does «set -m» give a background job a process group of its own in THIS shell (bash, zsh: yes; # dash: no)? A two-second sleep is started that way and looked up as a group; it is stopped either way. group_works() { ( set -m 2>/dev/null sleep 2 /dev/null 2>&1 & gp=$! gr=1 if kill -s 0 -- -"$gp" 2>/dev/null; then gr=0; fi kill "$gp" 2>/dev/null exit "$gr" ) /dev/null 2>&1 } # R6-M3: stop a whole process group — TERM, up to two seconds for it to go, then KILL of whatever is left in it # (a child spawned by a TERM handler is in the same group and is caught by the KILL). «kill -s SIG -- -» is the # one form all four shells above accept (measured). stop_group() { kill -s TERM -- -"$1" 2>/dev/null || return 0 n=0 while [ "$n" -lt 20 ] && kill -s 0 -- -"$1" 2>/dev/null; do sleep 0.1 2>/dev/null || { sleep 1; n=$((n + 9)); }; n=$((n + 1)); done kill -s KILL -- -"$1" 2>/dev/null return 0 } # Sol R5-M3, the fallback when the tool could not get a group of its own: stop a process and every process under it. # The tree is read from ps BEFORE anything is killed (a killed parent's children are adopted by init and could no # longer be found), stopped with TERM, and what is left after a second with KILL. Without ps only the process itself. kill_tree() { tree=$1 if has ps; then tree=$(ps -A -o pid= -o ppid= 2>/dev/null | awk -v root="$1" ' { kid[NR] = $1; dad[NR] = $2 } END { want[root] = 1; out = root; grew = 1 while (grew) { grew = 0; for (i = 1; i <= NR; i++) if ((dad[i] in want) && !(kid[i] in want)) { want[kid[i]] = 1; out = out " " kid[i]; grew = 1 } } print out }') if [ -z "$tree" ]; then tree=$1; fi fi for one in $tree; do kill "$one" 2>/dev/null; done sleep 1 for one in $tree; do kill -9 "$one" 2>/dev/null; done } # R13-M2 (round 14): what a probed tool or «pi --version» starts is the run's to stop, even when it leaves the process # group (setsid, a double fork) — so ownership does not rest on the group alone. own_new makes a fresh, empty mark # file; the tool is started with descriptor 7 open on it and its path in KUMO_OWNED_BY, which every descendant # inherits unless it closes the descriptor AND clears its environment. own_track walks «pid ppid» (ps, else /proc) on # every tick of the wait and adds whatever runs under the tool, or under anything already owned. own_stop TERMs the # owned set plus every process that holds the mark (Linux: /proc//environ and /proc//fd; macOS: lsof), gives # them two seconds, then KILLs that set again with the holders found by then. On every way out: an answer, a timeout, # an interruption (kumo_cleanup). # BUG_FIX_CONTEXT (round 14, R13-M2, sol-edge-r13.md): a group is not a tree — a child that called setsid() left the # only group the cleanup knew, and once its parent exited the tree walk could not find it: it survived both the probe # and «pi --version». Residual (said in test_guide.md): a descendant that closes descriptor 7, clears its environment # (or, on macOS, where no environment of another process is readable, only closes the descriptor) AND leaves the group # before a tick sees it under its parent is not found. own_new() { KUMO_OWNED="" KUMO_OWN_MARK="" # The file descriptor 7 is opened on: the mark, or /dev/null when no mark could be made (only the walk then). KUMO_OWN_FD=/dev/null om=$(mktemp "$KUMO_TMP/kumo-own.XXXXXX") || return 0 clean_add "$om" omd=$(cd "$(dirname "$om")" 2>/dev/null && pwd -P) || omd=$(dirname "$om") KUMO_OWN_MARK="$omd/$(printf '%s' "$om" | sed 's#.*/##')" KUMO_OWN_FD=$KUMO_OWN_MARK } # «pid ppid» for every process: ps (on PATH, else /bin/ps), else Linux's /proc//stat (the PowerShell and slim # Debian images have no ps). Nothing when neither exists. own_ptable() { if has ps; then ps -A -o pid= -o ppid= 2>/dev/null; return 0; fi if [ -x /bin/ps ]; then /bin/ps -A -o pid= -o ppid= 2>/dev/null; return 0; fi if [ -r /proc/self/stat ]; then cat /proc/[0-9]*/stat 2>/dev/null | awk '{ p = $1; s = $0; sub(/^.*\) /, "", s); split(s, f, " "); print p, f[2] }' fi } # Add to KUMO_OWNED every process under $1 (the tool, the leader of its group) or under one already owned — $1 itself too. own_track() { if [ -z "$1" ]; then return 0; fi case $1 in *[!0-9]*) return 0 ;; esac KUMO_OWNED=$(own_ptable | awk -v root="$1" -v have="$KUMO_OWNED" ' { kid[NR] = $1; dad[NR] = $2 } END { n = split(have, h, " "); for (i = 1; i <= n; i++) want[h[i]] = 1 want[root] = 1; grew = 1 while (grew) { grew = 0; for (i = 1; i <= NR; i++) if ((dad[i] in want) && !(kid[i] in want)) { want[kid[i]] = 1; grew = 1 } } out = ""; for (p in want) if (p ~ /^[0-9]+$/ && p + 0 > 1) out = out " " p print out }') } # The processes holding the run's ownership mark: its path in their environment or a descriptor open on it. own_holders() { if [ -z "$KUMO_OWN_MARK" ]; then return 0; fi if [ -r /proc/self/environ ]; then grep -l -s -F -e "KUMO_OWNED_BY=$KUMO_OWN_MARK" /proc/[0-9]*/environ 2>/dev/null | sed -n 's#^/proc/\([0-9]*\)/environ$#\1#p' ls -l /proc/[0-9]*/fd 2>/dev/null | KUMO_M=$KUMO_OWN_MARK awk ' /^\/proc\/[0-9]+\/fd:$/ { p = $0; sub(/^\/proc\//, "", p); sub(/\/fd:$/, "", p); next } { i = index($0, " -> "); if (i > 0 && p != "" && substr($0, i + 4) == ENVIRON["KUMO_M"]) print p }' return 0 fi oh_lsof="" if has lsof; then oh_lsof=lsof; elif [ -x /usr/sbin/lsof ]; then oh_lsof=/usr/sbin/lsof; fi if [ -n "$oh_lsof" ]; then "$oh_lsof" -t -- "$KUMO_OWN_MARK" 2>/dev/null; fi return 0 } # The owned set and the mark's holders, once each, as one line: pids above 1, never this shell. own_pids() { { printf '%s\n' $KUMO_OWNED; own_holders; } 2>/dev/null | awk -v me="$$" '$1 ~ /^[0-9]+$/ && $1 + 0 > 1 && $1 != me && !seen[$1]++ { printf "%s ", $1 }' } # Stop everything the run owns: TERM, up to two seconds, then KILL (the holders looked up again, so a child a TERM # handler started is caught). Clears the mark: one probe or one «pi --version» at a time. own_stop() { os_p=$(own_pids) if [ -n "$os_p" ]; then for os_q in $os_p; do kill -s TERM "$os_q" 2>/dev/null; done os_n=0 while [ "$os_n" -lt 20 ]; do os_up=0 for os_q in $os_p; do if kill -s 0 "$os_q" 2>/dev/null; then os_up=1; break; fi; done if [ "$os_up" = 0 ]; then break; fi sleep 0.1 2>/dev/null || { sleep 1; os_n=$((os_n + 9)); } os_n=$((os_n + 1)) done os_p="$os_p $(own_pids)" for os_q in $os_p; do kill -s KILL "$os_q" 2>/dev/null; done fi if [ -n "$KUMO_OWN_MARK" ]; then rm -f "$KUMO_OWN_MARK"; fi KUMO_OWNED="" KUMO_OWN_MARK="" } # Round 19 (probe-verdict-design.md §2.4): one look at Claude Code's debug file $1 for the ERE $3 (the FIRST Kumo line: # its 401, a refusal, or the 404 carrying this run's proof), called on every tick of the probe's wait. It never blocks # the wait: the look is a grep in the background (its pid in $2/peek.pid, its end marked by $2/peek.done); a look still # running is left alone until the next tick, a finished one is read — 0 (stop waiting) when it found a line. Only a # regular file, not a link, of at most the debug cap (8 MB) is looked at; a name swapped for a FIFO after that check # blocks only the grep, which probe_peek_stop (or, on an interruption, kumo_cleanup through KUMO_READ_PID / # KUMO_READ_PIDF) kills. The verdict itself is still drawn afterwards from the bounded copy (probe_read). # n19-1 (round 20): $4, when set, is a second ERE (KUMO_M_API_DENIED) — a line of it WITHOUT a Kumo code # (KUMO_M_KUMO_CODE, round 21: another server's 401 / 403, LiteLLM's "code":"401" included) stops the wait too. One grep collects the lines of either ERE into # peek.hit; which of them decides is read from that private copy, never from the tool's file again. probe_peek() { if [ -n "$KUMO_PEEK_PID" ]; then if [ ! -f "$2/peek.done" ]; then return 1; fi wait "$KUMO_PEEK_PID" 2>/dev/null KUMO_PEEK_PID="" KUMO_READ_PID="" KUMO_READ_PIDF="" if [ -s "$2/peek.hit" ]; then if grep -q -E -e "$3" "$2/peek.hit" 2>/dev/null /dev/null /dev/null; then return 0; fi fi fi if [ ! -f "$1" ] || [ -L "$1" ]; then return 1; fi pk_size=$(ls -ln -- "$1" 2>/dev/null | awk 'NR == 1 && $5 ~ /^[0-9]+$/ { print $5 }') case $pk_size in ''|*[!0-9]*) return 1 ;; esac if [ "$pk_size" -gt 8388608 ]; then return 1; fi rm -f "$2/peek.hit" "$2/peek.done" "$2/peek.pid" pk_foe=$4 if [ -z "$pk_foe" ]; then pk_foe=$3; fi ( grep -E -e "$3" -e "$pk_foe" -- "$1" > "$2/peek.hit" 2>/dev/null "$2/peek.pid" wait "$!" : > "$2/peek.done" ) /dev/null 2>&1 & KUMO_PEEK_PID=$! KUMO_READ_PID=$KUMO_PEEK_PID KUMO_READ_PIDF="$2/peek.pid" return 1 } # Round 19: a look of probe_peek still running when the wait ends is stopped — the grep by the pid it wrote, then its # subshell — before the bounded reads begin. probe_peek_stop() { if [ -z "$KUMO_PEEK_PID" ]; then return 0; fi pk_d=$(cat "$1/peek.pid" 2>/dev/null) case $pk_d in ''|*[!0-9]*) ;; *) kill -s KILL "$pk_d" 2>/dev/null ;; esac kill -s KILL "$KUMO_PEEK_PID" 2>/dev/null wait "$KUMO_PEEK_PID" 2>/dev/null KUMO_PEEK_PID="" KUMO_READ_PID="" KUMO_READ_PIDF="" } # R13-M1 (round 14) / A14-1, R14-M2, N14-5 (round 15): copy at most $3 bytes of file $1 into $2 (with $4 = debug, only # Claude Code's [ERROR] / Kumo lines of those bytes) — only when $1 is a regular file and not a link, and within what is # left of KUMO_READ_LEFT (tenths of a second, shared by the reads of one probe). The BYTES are capped before any line is # parsed: dd, started in the background, opens $1 itself and reads at most $3 / 4096 + 1 blocks of 4096 bytes ($3 is a # multiple of 4096), so a file with no newline, or one line of any length, never costs more than that; a name swapped # for a FIFO after the check blocks only that dd, which the deadline kills (its pid is in $2.pid, and kumo_cleanup # kills it on an interruption). KUMO_READ_BIG becomes 1 when the read did not END within its bytes and its time — more # was there than the cap, or the deadline cut it — so the caller knows the copy is not the whole of what was printed. # $2 is in a private folder the tool cannot name. Its «.done» mark tells a finished read from a running one. # BUG_FIX_CONTEXT (round 15, A14-1 / R14-M2, audit-astra-r14.md, sol-edge-r14.md): awk read a whole RECORD before its # byte count was tested, and printed it first — one 4 MB (or 16 MB) line was copied whole past a 64 KB cap, and Kumo's # words at its far end were read as the probe's answer. Moving the test before «print» would still buffer the record. probe_read() { # R15-M2 (round 16): the copy always exists (empty when there is nothing to read), so the caller joins it without a # failing cat; $4 is no longer a filter — the verdict reads the whole bounded copy, and only the display line is # drawn from a filtered one (probe_tool). : > "$2" if [ ! -f "$1" ] || [ -L "$1" ]; then return 0; fi pr_n=$(($3 / 4096)) # R15-M1 (round 16): the bytes the file holds BEFORE the read — what a complete read of at most the cap must copy — # from its directory entry (ls -ln, field 5): nothing is read, so a file that never ends costs nothing here. Inline, # not a helper: the function is cut out and run alone by its own test. pr_want=$(ls -ln -- "$1" 2>/dev/null | awk 'NR == 1 && $5 ~ /^[0-9]+$/ { print $5 }') rm -f "$2.raw" "$2.pid" "$2.done" "$2.st" ( dd if="$1" of="$2.raw" bs=4096 count=$((pr_n + 1)) /dev/null 2>&1 & printf '%s\n' "$!" > "$2.pid" wait "$!" printf '%s\n' "$?" > "$2.st" : > "$2.done" ) /dev/null 2>&1 & pr_a=$! KUMO_READ_PID=$pr_a KUMO_READ_PIDF="$2.pid" while [ ! -f "$2.done" ] && [ "$KUMO_READ_LEFT" -gt 0 ]; do sleep 0.1 2>/dev/null || { sleep 1; KUMO_READ_LEFT=$((KUMO_READ_LEFT - 9)); } KUMO_READ_LEFT=$((KUMO_READ_LEFT - 1)) done if [ ! -f "$2.done" ]; then KUMO_READ_BIG=1 pr_d=$(cat "$2.pid" 2>/dev/null) case $pr_d in ''|*[!0-9]*) ;; *) kill -s KILL "$pr_d" 2>/dev/null ;; esac kill -s KILL "$pr_a" 2>/dev/null # R15-M1 (round 16): a finished read is not a successful one — dd's own status decides. # BUG_FIX_CONTEXT (round 16, R15-M1, sol-edge-r15.md): «wait; : > .done» published completion whatever dd returned: # a dd that copied 40 bytes and failed (exit 42) left Kumo's words without the 401 after them, and the run said Done. elif [ "$(cat "$2.st" 2>/dev/null)" != 0 ]; then KUMO_READ_FAIL=1 fi wait "$pr_a" 2>/dev/null KUMO_READ_PID="" KUMO_READ_PIDF="" # The bounded raw copy is this run's own regular file. R15-M1 (round 16): it must hold every byte the file held, up # to one block past the cap — fewer is a short read, even with status 0 — and «more than the cap» is read from its # size; the cut at the cap is checked too (the split readers fail the same way). if [ -f "$2.raw" ]; then pr_got=$(ls -ln -- "$2.raw" 2>/dev/null | awk 'NR == 1 && $5 ~ /^[0-9]+$/ { print $5 }') pr_most=$(((pr_n + 1) * 4096)) case "$pr_want:$pr_got" in *[!0-9:]*|:*|*:) KUMO_READ_FAIL=1 ;; *) if [ "$pr_want" -gt "$pr_most" ]; then pr_want=$pr_most; fi if [ "$pr_got" -lt "$pr_want" ]; then KUMO_READ_FAIL=1; fi if [ "$pr_got" -gt "$3" ]; then KUMO_READ_BIG=1; fi ;; esac if dd if="$2.raw" of="$2.cut" bs=4096 count="$pr_n" /dev/null 2>&1 && [ -f "$2.cut" ]; then # n16-2 (round 17): the cut must hold every byte of the raw copy up to the cap (pr_n blocks) — fewer is a short # read, even with status 0. # BUG_FIX_CONTEXT (round 17, n16-2, audit-opus-r16.md): only the cut's status was checked, so a dd that copied 40 # bytes into the cut and exited 0 left Kumo's words without the 401 after them, and the run said Done. pr_cut=$(ls -ln -- "$2.cut" 2>/dev/null | awk 'NR == 1 && $5 ~ /^[0-9]+$/ { print $5 }') pr_need=$((pr_n * 4096)) case "$pr_got:$pr_cut" in *[!0-9:]*|:*|*:) KUMO_READ_FAIL=1 ;; *) if [ "$pr_got" -lt "$pr_need" ]; then pr_need=$pr_got; fi if [ "$pr_cut" -lt "$pr_need" ]; then KUMO_READ_FAIL=1; fi ;; esac cat "$2.cut" > "$2" 2>/dev/null || KUMO_READ_FAIL=1 else KUMO_READ_FAIL=1 fi elif [ "$KUMO_READ_BIG" != 1 ]; then KUMO_READ_FAIL=1 fi rm -f "$2.raw" "$2.cut" "$2.pid" "$2.done" "$2.st" return 0 } # R5-M2: is the probed executable where the vendor's own installer (or npm, or Homebrew) puts the tool? Its real # file is compared, so a PATH link to the vendor's file counts. N4 (round 7): HOME is compared as itself AND as its # real folder (macOS: /var → /private/var — the vendor installer links ~/.local/bin/claude into the real path), and a # tool this very run installed with the vendor's installer is the vendor's by definition. Sets KUMO_PROBE_KNOWN. vendor_place() { KUMO_PROBE_KNOWN=0 if [ "$KUMO_INSTALLED_NOW" = 1 ]; then KUMO_PROBE_KNOWN=1; return 0; fi place=$(real_path "$1") || place=$1 pd=$(cd "$(dirname "$place")" 2>/dev/null && pwd -P) && place="$pd/$(printf '%s' "$place" | sed 's#.*/##')" rh=$(cd "$HOME" 2>/dev/null && pwd -P) || rh=$HOME for h in "$HOME" "$rh"; do case $client in claude-code) case $place in "$h"/.local/share/claude/*|"$h"/.claude/local/*|*/node_modules/@anthropic-ai/claude-code/*|*/Caskroom/claude-code/*) KUMO_PROBE_KNOWN=1 ;; esac ;; codex) case $place in "$h"/.codex/packages/*|*/node_modules/@openai/codex/*|*/node_modules/@openai/codex-*|*/Caskroom/codex/*|*/Cellar/codex/*) KUMO_PROBE_KNOWN=1 ;; esac ;; opencode) case $place in "$h"/.opencode/bin/*|*/node_modules/opencode-ai/*|*/node_modules/opencode-*|*/Cellar/opencode/*) KUMO_PROBE_KNOWN=1 ;; esac ;; esac done } # The «Checked» lines (R5-M2, N4): the tick says only what was proved, and a neutral second line names the program # that ran. At the vendor's place: «✓ Checked: answers through Kumo.»; elsewhere: «✓ Checked: Kumo's answer # came back.» — no warning beside a green tick, no overclaim either. probe_said_ok() { if [ "$KUMO_PROBE_KNOWN" = 1 ]; then say "✓ Checked: $name answers through Kumo." "✓ Проверено: $name отвечает через Kumo." say " Checked via: $(sq "$(tilde "$KUMO_PROBE_EXE")")" " Проверено через: $(sq "$(tilde "$KUMO_PROBE_EXE")")" else # R11-m1 (round 12b, sol-edge-r11.md): outside the vendor's places the answer is a heuristic — any program can # print Kumo's words — so the line says where it came from and that this is not where the tool is installed. say "✓ Checked: Kumo's answer came back." "✓ Проверено: ответ Kumo пришёл." say " ⚠ Checked via $(sq "$(tilde "$KUMO_PROBE_EXE")") — this is not a standard install location" " ⚠ Проверено через $(sq "$(tilde "$KUMO_PROBE_EXE")") — это не стандартное место установки" fi } # U3: the empty-wallet hint, said only in an «Almost done» block (the probe itself is never billed, so it cannot see one). funds_line() { say " If the account has no money on it, the first request answers 402: top up in the console (Wallet or Packages): $KUMO_CONSOLE/#billing" \ " Если на счету нет денег, первый запрос получит ответ 402: пополните баланс в консоли (Кошелёк или Пакеты): $KUMO_CONSOLE/#billing" } # U16 / N1 / F2 (round 7): how to start the tool — ONE instruction, chosen by what is true. The tool is on this # window's PATH: «Start it with: claude». The script just set up the shell's start-up file AND proved a new window # finds the tool (KUMO_PATH_ADDED, R6-m3): «Open a new Terminal window (Cmd+N) and type: claude» — a new window opens # in the home folder, the plain place for a first start — with the full path for this window on a quieter second # line. Otherwise only the full path. Never a bare command this very window cannot find. Sets run_line_en, # run_line_ru and run_also_en, run_also_ru (the second line, or empty). run_line() { run_also_en="" run_also_ru="" if [ -n "$KUMO_BIN_DIR" ]; then first=$(printf '%s' "$next" | sed 's/ .*//') rest=$(printf '%s' "$next" | sed -n 's/^[^ ]*\( .*\)$/\1/p') full="$(sq "$KUMO_BIN_DIR/$first")$rest" if [ "$KUMO_PATH_ADDED" = 1 ]; then if [ "$(uname -s 2>/dev/null)" = Darwin ]; then run_line_en="Open a new Terminal window (Cmd+N) and type: $next" run_line_ru="Откройте новое окно Терминала (Cmd+N) и наберите: $next" else run_line_en="Open a new terminal window and type: $next" run_line_ru="Откройте новое окно терминала и наберите: $next" fi # Sol R8-m1 (round 10): the full path is a plain instruction of its own, not a quieter aside — a new window # finding the tool is not proven by any check that does not run the reader's start-up file. run_also_en="If it does not work — start it like this: $full" run_also_ru="Если не сработает — запустите так: $full" else run_line_en="Start it by its full path: $full" run_line_ru="Запустите по полному пути: $full" fi else run_line_en="Start it with: $next" run_line_ru="Запустите: $next" fi } # The reason and the one action of an «Almost done» for this client's probe (P2). Sets why_en, why_ru, act_en, act_ru. probe_words() { how=$(client_how "$client") # R7-m2 (round 8): the launch itself is the run's last line, chosen by state (run_line) — this action points at it. run_en="start $name as said below, and ask it anything" run_ru="запустите $name, как написано ниже, и задайте любой вопрос" case $KUMO_PROBE in skipped) why_en="the check was skipped (--no-probe)"; why_ru="проверка пропущена (--no-probe)" act_en=$run_en; act_ru=$run_ru ;; nogroup) why_en="the check was skipped: this computer has neither perl nor setsid, which the check needs to stop $name cleanly afterwards" why_ru="проверка пропущена: на этом компьютере нет ни perl, ни setsid, без которых проверку не завершить чисто" act_en=$run_en; act_ru=$run_ru ;; absent) why_en="the check was skipped: $name is not installed"; why_ru="проверка пропущена: $name не установлен" act_en="install it ($how), then start $next"; act_ru="установите его ($how), затем запустите $next" ;; none) why_en="there is no automatic check for $name yet"; why_ru="автоматической проверки для $name пока нет" act_en=$run_en; act_ru=$run_ru ;; own-content) why_en="the check was skipped: OPENCODE_CONFIG_CONTENT is set in this terminal"; why_ru="проверка пропущена: в этом терминале задана OPENCODE_CONFIG_CONTENT" act_en=$run_en; act_ru=$run_ru ;; timeout) # U14: a silence has two plain causes — the network, or a setting that sends the tool to another address. why_en="$name did not answer within 20 seconds — check your internet connection or proxy, or settings of the project folder that send it to another address" why_ru="$name не ответил за 20 секунд — проверьте интернет/прокси или настройки проекта, которые уводят на другой адрес" act_en="$run_en; if it is silent again, try from a folder without its own .claude, .codex or opencode settings" act_ru="$run_ru; если он снова молчит, попробуйте из папки без своих настроек .claude, .codex или opencode" ;; auth) # Round 19 (probe-verdict-design.md §2): «auth» is Kumo's OWN 401 now — the request reached Kumo with a key Kumo # refused — so the advice about the key is right; a stranger's 401 is no longer this verdict. why_en="Kumo did not accept the key $name sent (a sign-in error)" why_ru="Kumo не принял ключ, который отправил $name (ошибка входа)" act_en="check that the key is not revoked (Kumo console → Keys) and run this command again" act_ru="проверьте, что ключ не отозван (консоль Kumo → Ключи), и выполните эту команду ещё раз" ;; refused) # Round 19 (design §2.2, defensive): Kumo authenticated the key and refused the request — a paused key, a scope, # a frozen or closed account; «Details» quotes Kumo's own sentence. why_en="Kumo refused $name's request: the key, its project or the account is paused, limited or frozen" why_ru="Kumo отказал в запросе $name: ключ, его проект или аккаунт приостановлен, ограничен или заморожен" act_en="open the Kumo console (Keys, Projects, Wallet), lift what Details names, and run this command again" act_ru="откройте консоль Kumo (Ключи, Проекты, Кошелёк), снимите то, что названо в «Подробностях», и выполните эту команду ещё раз" ;; unconfirmed) # Round 19 (design §2.3): Kumo's 404 without THIS run's probe proof — an older gateway mid-deploy, or a program # that only repeats Kumo's words; it is no proof that this tool reached Kumo with this key. why_en="$name's answer only looked like Kumo's: the gateway did not confirm this run's check" why_ru="ответ $name лишь похож на ответ Kumo: шлюз не подтвердил эту проверку" act_en=$run_en; act_ru=$run_ru ;; big) # R14-M5 (round 15): the check read only part of what the tool printed, so its words prove nothing either way. why_en="$name printed more than the check reads, so its answer proves nothing" why_ru="$name вывел больше, чем читает проверка, поэтому его ответ ничего не доказывает" act_en=$run_en; act_ru=$run_ru ;; unread) # R15-M1 (round 16): a read of what the tool printed failed or came back short — a prefix proves nothing. why_en="$name's output could not be read in full, so its answer proves nothing" why_ru="вывод $name не удалось прочитать целиком, поэтому его ответ ничего не доказывает" act_en=$run_en; act_ru=$run_ru ;; *) # Round 19 (design §2.4): one verdict, two wordings chosen by the line «Details» quotes (KUMO_PROBE_NOTE) — both # a setting, never the key. case $KUMO_PROBE_NOTE in elsewhere) why_en="$name's request went to another server, not Kumo: another setting still wins"; why_ru="запрос $name ушёл на другой сервер, а не в Kumo: другая настройка всё ещё важнее" ;; signin) why_en="$name asked you to sign in instead of using the key: another setting still wins"; why_ru="$name попросил войти в аккаунт вместо ключа: другая настройка всё ещё важнее" ;; *) why_en="$name answered from somewhere other than Kumo: another setting still wins"; why_ru="$name ответил не через Kumo: другая настройка всё ещё важнее" ;; esac case $client in claude-code) act_en="start claude in a new window and type /status: the address must be Kumo's; if it is not, settings of the project folder or ANTHROPIC_* variables of your terminal override it" act_ru="запустите claude в новом окне и введите /status: адрес должен быть адресом Kumo; если нет, его перекрывают настройки папки проекта или переменные ANTHROPIC_* вашего терминала" ;; codex) act_en="check for a profile line (profile = …) or a .codex/config.toml in your project folder: they override ~/.codex/config.toml" act_ru="проверьте, нет ли строки profile = … или файла .codex/config.toml в папке проекта: они перекрывают ~/.codex/config.toml" ;; *) act_en="check for an opencode.json in your project folder or OPENCODE_CONFIG in your terminal: they override the global file" act_ru="проверьте, нет ли opencode.json в папке проекта или переменной OPENCODE_CONFIG в терминале: они перекрывают общий файл" ;; esac ;; esac } kumo_main() { # #533 r2 (codex-533-script-r2.log #1): every variable that ever holds the key or a text carrying it is unset BEFORE # its first assignment. An assignment keeps the export attribute of a variable the caller exported under the same # name, so «key=…» under an inherited exported «key» would hand the key to every child. The audited set: key, arg # (a key given on the line), KUMO_PATCH (sealed), KUMO_SLOTTED and mi_out (no key, unset alike), kumo_table (Codex's # table with the key), KUMO_SHOWN and kumo_shown (the mask, unset alike). # BUG_FIX_CONTEXT (#533 r2): «unset KUMO_KEY» alone left these names to the caller's export flags (measured in sh, dash). unset key arg KUMO_PATCH KUMO_SLOTTED mi_out kumo_table KUMO_SHOWN kumo_shown # The key from the environment of sh, then out of it at once: no child process this script starts inherits it. key=$KUMO_KEY envkey=0 if [ -n "$KUMO_KEY" ]; then envkey=1; fi unset KUMO_KEY # #533 Codex r2 #2: the test-only switch for a plain-http loopback gateway (base_ok), read once and out of the # environment at once; the page never prints it. KUMO_HTTP_OK=0 if [ "$KUMO_TEST_ALLOW_HTTP" = 1 ]; then KUMO_HTTP_OK=1; fi unset KUMO_TEST_ALLOW_HTTP # #533 r2 #2: this run's slot word — 32 hex from /dev/urandom; without a readable source, the pid and the time # (merge_into still refuses a file that holds the word, and checks the count before it fills). Drawn only after # KUMO_KEY is unset: od and tr are children too. ks_hex=$(od -An -N16 -tx1 /dev/urandom 2>/dev/null /dev/null) case $ks_hex in ''|*[!0-9a-f]*) ks_hex="$$$(date +%s 2>/dev/null)" ;; esac KUMO_KS="__KUMO_KEY_SLOT_$ks_hex""__" for arg in "$@"; do if [ "$arg" = --ru ]; then KUMO_RU=1; fi done trap kumo_cleanup EXIT trap 'kumo_cleanup; exit 130' INT TERM HUP client="" model="" models="" xfable="" xopus="" xsonnet="" xhaiku="" abase="" obase="" install=1 verify=1 probe=1 askkey=0 mclient="" mid="" while [ $# -gt 0 ]; do case $1 in --model|--models|--fable|--opus|--sonnet|--haiku|--base|--openai-base) if [ $# -lt 2 ]; then usage >&2 die 2 "$1 needs a value" "$1: нужно значение" fi case $1 in --model) model=$2 ;; --models) models=$2 ;; --fable) xfable=$2 ;; --opus) xopus=$2 ;; --sonnet) xsonnet=$2 ;; --haiku) xhaiku=$2 ;; --base) abase=$2 ;; --openai-base) obase=$2 ;; esac shift 2 ;; --no-install) install=0; shift ;; --no-verify) verify=0; shift ;; --no-probe) probe=0; shift ;; --ask-key) askkey=1; shift ;; --ru) shift ;; -h|--help) usage; exit 0 ;; -*) usage >&2; die 2 "unknown option $1" "неизвестный параметр $1" ;; *) if [ -z "$client" ]; then client=$1 elif [ "$client" = model ] && [ -z "$mclient" ]; then mclient=$1 elif [ "$client" = model ] && [ -z "$mid" ]; then mid=$1 elif [ -z "$key" ] && [ "$envkey" = 0 ]; then key=$1 else usage >&2; die 2 "unexpected argument $1" "лишний аргумент $1" fi shift ;; esac done case $client in claude-code|codex|opencode|openclaw|pi) name=$(client_name "$client") ;; all|model) name="" ;; *,*) # A comma list (the card's «Также подключить»): every name one of the five, none twice. name="" seen=" " for one in $(printf '%s' "$client" | tr ',' ' '); do case $one in claude-code|codex|opencode|openclaw|pi) ;; *) usage >&2; die 2 "unknown client $one" "неизвестный клиент $one" ;; esac case $seen in *" $one "*) usage >&2; die 2 "$one is named twice" "$one указан дважды" ;; esac seen="$seen$one " done ;; "") usage >&2; die 2 "name a client" "укажите клиента" ;; *) usage >&2; die 2 "unknown client $client" "неизвестный клиент $client" ;; esac if [ "$askkey" = 1 ] && [ -n "$key" ]; then usage >&2 die 2 "with --ask-key the key is typed in the terminal, not given in the command" \ "с --ask-key ключ вводится в терминале, а не в команде" fi for value in "$model" "$models" "$xfable" "$xopus" "$xsonnet" "$xhaiku" "$abase" "$obase" "$mid"; do case $value in *[[:space:]]*|*[[:cntrl:]]*) die 2 "a value holds a space or a control character: $value" "в значении пробел или управляющий символ: $value" ;; esac done # R11-M5 (round 12b): every tool the writers need is here before anything is checked, sent or written. # BUG_FIX_CONTEXT (round 12b, R11-M5, sol-edge-r11.md): without awk, fill_key / seal_patch turned into empty strings # unnoticed, and settings.json was published as one newline while the run said the key was saved. # Round 15: dd (probe_read's byte cap, A14-1) and ln (the no-replace backup, A14-3) joined the list. for need in awk sed grep tr mktemp chmod mv ln cat cmp dd mkdir dirname rm; do if ! has "$need"; then die 1 "This script needs awk, sed, grep, tr, mktemp, chmod, mv, ln, cat, cmp, dd, mkdir, dirname and rm, and $need is not here, so nothing was checked or changed. Install it (on Alpine: apk add $need; on Debian/Ubuntu it comes with the base system), then run the command again." \ "Скрипту нужны awk, sed, grep, tr, mktemp, chmod, mv, ln, cat, cmp, dd, mkdir, dirname и rm, а $need здесь нет, поэтому ничего не проверено и не изменено. Установите его (Alpine: apk add $need; в Debian/Ubuntu он входит в базовую систему) и выполните команду ещё раз." fi done check_models if [ -n "$abase" ]; then abase=$(printf '%s' "$abase" | sed 's#/*$##') if [ -z "$obase" ]; then obase="$abase/v1"; fi else abase=$KUMO_DEFAULT_ANTHROPIC if [ -z "$obase" ]; then obase=$KUMO_DEFAULT_OPENAI; fi fi obase=$(printf '%s' "$obase" | sed 's#/*$##') # #533 final Codex P1: the key goes to these bases (the key check, the model list read with the key, the tool's own # requests once its config names them), so neither may be plain http — before ANY request or write (base_ok). for value in "$abase" "$obase"; do if ! base_ok "$value"; then usage >&2 die 2 "$value is not an https:// address, so the key is not sent to it and nothing was changed." \ "$value — не адрес https://, поэтому ключ туда не отправлен и ничего не изменено." fi done if [ "$client" = model ]; then kumo_model return 0 fi if [ -z "$HOME" ]; then die 1 "HOME is not set" "переменная HOME не задана" fi if [ "$askkey" = 1 ]; then read_key; fi case $key in "") usage >&2; die 2 "the key is missing" "не указан ключ" ;; '') die 2 "replace with your API key from the console" "замените на ваш API-ключ из консоли" ;; *[[:space:]]*|*[[:cntrl:]]*) die 2 "the key holds a space or a control character: copy it again" "в ключе пробел или управляющий символ: скопируйте его заново" ;; esac umask 077 KUMO_TMP=$TMPDIR if [ -z "$KUMO_TMP" ] || [ ! -d "$KUMO_TMP" ]; then KUMO_TMP=/tmp; fi # Sol R7-M1 (round 8): every path this run may write is checked before the key check, an install or a write. if [ "$client" = all ]; then guard_list="claude-code codex opencode openclaw pi"; else guard_list=$(printf '%s' "$client" | tr ',' ' '); fi # shellcheck disable=SC2086 — the list is five fixed client names, split on purpose. paths_guard $guard_list KUMO_ENGINE=$(pick_engine) KUMO_BIN_DIR="" KUMO_PATH_ADDED=0 KUMO_INSTALLED_NOW="" if [ "$client" = all ]; then say "Connecting every installed tool to Kumo ($abase)." "Подключаю к Kumo ($abase) все установленные инструменты." elif [ -z "$name" ] && [ "$install" = 0 ]; then say "Connecting the installed tools of $(printf '%s' "$client" | sed 's/,/, /g') to Kumo ($abase)." \ "Подключаю к Kumo ($abase) те из этих инструментов, что установлены: $(printf '%s' "$client" | sed 's/,/, /g')." elif [ -z "$name" ]; then # m-F (round 11, owner 2026-11-05): an explicit list installs the missing ones, as the single-tool path does. say "Connecting $(printf '%s' "$client" | sed 's/,/, /g') to Kumo ($abase); a missing one is installed first." \ "Подключаю к Kumo ($abase): $(printf '%s' "$client" | sed 's/,/, /g'); недостающие сначала установлю." else say "Connecting $name to Kumo ($abase)." "Подключаю $name к Kumo ($abase)." fi # S1: the key first. A refused key ends the run here, before an installer runs or a file is touched. check_key if [ -z "$name" ]; then kumo_list return 0 fi # P1: the named model against THIS gateway's list (Claude Code with no --model keeps its own model fields). #533: a # tool that cannot start without a model (model_need auto) and OpenCode's list get the gateway's pick, unannounced as a # choice; Claude Code with none named gets nothing. KUMO_NAMED=$model if [ -n "$model" ] || [ "$(model_need "$client")" != none ]; then check_model; fi # A named model the check swapped is still the one the command asked for: its replacement goes first and is the default. if [ -n "$KUMO_NAMED" ]; then KUMO_NAMED=$model; fi claude_auto if [ -n "$KUMO_SKIP" ]; then # R5-M1: no model of this gateway is proven with the tool — nothing is written, and the run says so. say "⚠ Not connected: nothing was written for $name, because this gateway has no model proven with it yet. Try again later, or set it up by hand (console → Connect → «Manual»)." \ "⚠ Не подключено: для $name ничего не записано — у этого шлюза пока нет модели, проверенной с ним. Попробуйте позже или настройте вручную (консоль → «Подключение» → «Вручную»)." exit 1 fi if { [ -z "$model" ] && [ "$(model_need "$client")" != slots ]; } || [ -n "$KUMO_UNREAD" ]; then # #533: no model named and the gateway's list unreadable — nothing to register for this tool, so nothing is written. # Final Codex P2: Claude Code too (KUMO_UNREAD, claude_auto) — no key or gateway is written without its slots. die 1 "The model list of $obase could not be read, so $name was not set up: nothing was changed. Check your network, then run the command again." \ "Список моделей $obase прочитать не удалось, поэтому $name не настроен: ничего не изменено. Проверьте сеть и выполните команду ещё раз." fi install_tool configure if [ -n "$KUMO_SIDE" ]; then if [ -n "$KUMO_BIN_DIR" ]; then path_hint; fi undo_finish run_line say "⚠ Almost done: merge $KUMO_SIDE by hand as shown above, then start $name as said below." \ "⚠ Почти готово: перенесите $KUMO_SIDE вручную, как показано выше, затем запустите $name, как написано ниже." # R7-m2 (round 8): the launch line by state ends every outcome, this one too. say "$run_line_en" "$run_line_ru" if [ -n "$run_also_en" ]; then say "$run_also_en" "$run_also_ru"; fi exit 0 fi if [ -n "$KUMO_BIN_DIR" ]; then path_hint; fi # N2: one short undo command for everything this run changed (config, marker, start-up file). undo_finish probe_tool run_line if [ "$KUMO_WARNED" = 1 ]; then say "(See the notes marked ! above.)" "(Смотрите заметки с ! выше.)" fi if [ "$KUMO_PROBE" = ok ]; then probe_said_ok if [ "$KUMO_PROBE_KNOWN" = 1 ]; then # U13: precise — the check is free, the tool's own requests are paid from the balance. say "✅ Done: $name answers through Kumo (the check is free; model requests are paid from your balance)." \ "✅ Готово: $name отвечает через Kumo (проверка бесплатна; запросы к модели списываются с баланса)." else # n-7 (round 13, audit-opus-r12.md): Kumo's words from a program outside the vendor's places are a heuristic, not # proof that this tool reached Kumo, so the verdict is qualified too — not only the «Checked via» line. Exit 0. # BUG_FIX_CONTEXT (round 13, n-7): an echo stub printing «No live model answers…» got «⚠ … not a standard install # location» and then an unqualified «✅ Done: Claude Code answers through Kumo», with no request made at all. say "⚠ Almost done: the answer came via $(sq "$(tilde "$KUMO_PROBE_EXE")") — check it with your first question." \ "⚠ Почти готово: ответ получен через $(sq "$(tilde "$KUMO_PROBE_EXE")") — проверьте первым вопросом." fi # N1: the run ends on ONE launch instruction (and, quieter, the full path for this very window). say "$run_line_en" "$run_line_ru" if [ -n "$run_also_en" ]; then say "$run_also_en" "$run_also_ru"; fi else probe_words say "⚠ Almost done: Kumo's settings are written, but $why_en." "⚠ Почти готово: настройки Kumo записаны, но $why_ru." say " What to do: $act_en." " Что сделать: $act_ru." if [ -n "$KUMO_PROBE_SAID" ]; then say " Details: $KUMO_PROBE_SAID" " Подробности: $KUMO_PROBE_SAID"; fi funds_line # BUG_FIX_CONTEXT (Sol R7-m2): only the «Done» branch printed the launch line, so a run that set no new windows up # and ended «Almost done» said «start it by its full path (below)» with nothing below. Every outcome ends on it. say "$run_line_en" "$run_line_ru" if [ -n "$run_also_en" ]; then say "$run_also_en" "$run_also_ru"; fi fi } # U1 / D4: the tools of a comma list (claude-code,codex) or of all, each with its own model (--models) and the same # key, checked once. An explicit list installs a missing tool (m-F, round 11); «all» and --no-install name it with its # installer instead. Round 12: each configured tool's launch line by state is gathered (M-3); nothing configured and # no side file is «❌ Nothing was set up», exit 2 (m-2). P1: a tool the # command names no model for gets the gateway's best proven model for ITS protocol — one shared model is never forced # on tools that speak different protocols. R6: the end counts what was configured, what needs a hand merge (a side # file is never «connected») and what was skipped; P2: «Done» only when every configured tool answered through Kumo. kumo_list() { wanted_all=0 if [ "$client" = all ]; then wanted="claude-code codex opencode openclaw pi"; wanted_all=1; else wanted=$(printf '%s' "$client" | tr ',' ' '); fi configured=0 manual=0 absent=0 nomodel=0 started="" missing="" unproved_en="" unproved_ru="" failed="" failed_n=0 hung="" side=0 off_path=0 runs_en="" runs_ru="" # BUG_FIX_CONTEXT (round 11, m-F, owner decision 2026-11-05): a list skipped every missing tool («Ничего не # устанавливает»), so a reader who ticked two tools on the quick page and had one ran the vendor installer and the # command a second time. An explicit list now installs each missing tool exactly as the single-tool path does # (install_tool: the same installer, its log, the PATH line); «all» and --no-install stay detect-only. KUMO_LISTING=1 for client in $wanted; do name=$(client_name "$client") KUMO_BIN_DIR="" KUMO_INSTALLED_NOW="" KUMO_INSTALL_FAILED="" need_install=0 if ! client_installed "$client"; then if [ -n "$KUMO_PI_HUNG" ]; then # R11-M6: a «pi» that does not answer is neither installed nor absent: said, nothing installed over it. pi_hung_words warn "$pi_en" "$pi_ru" manual=$((manual + 1)) hung="$hung $client" continue fi if [ "$wanted_all" = 1 ] || [ "$install" = 0 ]; then absent=$((absent + 1)) missing="$missing $client" continue fi need_install=1 fi say "" "" say "── $name" "── $name" model=$(model_for "$client") KUMO_NAMED=$model # #533 (owner 2026-11-08): Claude Code with no model named gets none (model_need none) — not the gateway's pick. if [ -n "$model" ] || [ "$(model_need "$client")" != none ]; then check_model if [ -n "$KUMO_NAMED" ]; then KUMO_NAMED=$model; fi claude_auto else KUMO_SKIP="" KUMO_UNREAD="" KUMO_MODEL_NOTE="" KUMO_PICK_FABLE="" KUMO_PICK_OPUS="" KUMO_PICK_SONNET="" KUMO_PICK_HAIKU="" fi if [ -n "$KUMO_SKIP" ]; then # R5-M1: no proven model for this tool here — nothing written for it, counted as a manual setup. manual=$((manual + 1)) say " $name: nothing was written — set it up by hand once this gateway has a model proven with it." \ " $name: ничего не записано — настройте его вручную, когда у шлюза появится проверенная с ним модель." continue fi if { [ -z "$model" ] && [ "$client" != claude-code ]; } || [ -n "$KUMO_UNREAD" ]; then warn "$name is installed, but the command names no model for it and the gateway's list could not be read, so it was skipped." \ "$name установлен, но в команде нет модели для него, а список моделей шлюза прочитать не удалось, поэтому он пропущен." nomodel=$((nomodel + 1)) continue fi if [ "$need_install" = 1 ]; then install_tool if [ -n "$KUMO_INSTALL_FAILED" ] || [ -z "$KUMO_INSTALLED_NOW" ]; then manual=$((manual + 1)) failed="$failed $client" failed_n=$((failed_n + 1)) continue fi fi configure if [ -n "$KUMO_BIN_DIR" ]; then path_hint; fi undo_finish if [ -n "$KUMO_SIDE" ]; then manual=$((manual + 1)) side=$((side + 1)) say " $name: merge $KUMO_SIDE by hand as shown above." " $name: перенесите $KUMO_SIDE вручную, как показано выше." elif [ -n "$KUMO_WROTE" ]; then configured=$((configured + 1)) if [ -z "$started" ]; then started=$next; else started="$started · $next"; fi # M-3 (round 12): this tool's own launch line by state (run_line, as the single path), kept for the end. run_line if [ -n "$KUMO_BIN_DIR" ]; then off_path=1; fi runs_en="$runs_en $name: $run_line_en" runs_ru="$runs_ru $name: $run_line_ru" if [ -n "$run_also_en" ]; then runs_en="$runs_en $run_also_en" runs_ru="$runs_ru $run_also_ru" fi probe_tool if [ "$KUMO_PROBE" = ok ]; then probe_said_ok # n-7 (round 13): outside the vendor's places the answer is not proof — the list ends «Almost done» naming it. if [ "$KUMO_PROBE_KNOWN" != 1 ]; then unproved_en="$unproved_en $name: the answer came via $(sq "$(tilde "$KUMO_PROBE_EXE")") — check it with your first question." unproved_ru="$unproved_ru $name: ответ получен через $(sq "$(tilde "$KUMO_PROBE_EXE")") — проверьте первым вопросом." fi else probe_words unproved_en="$unproved_en $name: $why_en. What to do: $act_en." unproved_ru="$unproved_ru $name: $why_ru. Что сделать: $act_ru." if [ -n "$KUMO_PROBE_SAID" ]; then say " Details: $KUMO_PROBE_SAID" " Подробности: $KUMO_PROBE_SAID"; fi fi fi done say "" "" if [ "$configured" = 0 ] && [ "$manual" = 0 ] && [ "$nomodel" = 0 ]; then say "None of these tools is installed here, so nothing was changed. Install the one you need with its official installer, then run this command again:" \ "Ни один из этих инструментов здесь не установлен, поэтому ничего не изменено. Установите нужный его официальным установщиком и выполните эту команду ещё раз:" for one in $missing; do say " $(client_name "$one"): $(client_how "$one")" " $(client_name "$one"): $(client_how "$one")" done exit 1 fi line_en="Configured $configured · needs a manual merge $manual · skipped $absent (not installed)" line_ru="Настроено $configured · нужно вручную $manual · пропущено $absent (не установлено)" if [ "$nomodel" != 0 ]; then line_en="$line_en · skipped $nomodel (no model)" line_ru="$line_ru · пропущено $nomodel (нет модели)" fi say "$line_en" "$line_ru" for one in $missing; do say " not installed: $(client_name "$one") — $(client_how "$one")" " не установлен: $(client_name "$one") — $(client_how "$one")" done for one in $failed; do say " install by hand: $(client_name "$one") — $(client_how "$one")" " нужно поставить вручную: $(client_name "$one") — $(client_how "$one")" done for one in $hung; do say " check by hand: $(client_name "$one") — «pi --version» did not answer" " проверьте вручную: $(client_name "$one") — «pi --version» не ответил" done # m-2 (round 12, audit-opus-r11.md): with nothing configured and no side file to merge, «Almost done» was false — the # run says so plainly and exits 2. Failed installs get their own action (install, then run again). if [ "$configured" = 0 ] && [ "$side" = 0 ]; then say "❌ Nothing was set up:" "❌ Ничего не настроено:" list_actions if [ "$KUMO_WARNED" = 1 ]; then say "(See the notes marked ! above.)" "(Смотрите заметки с ! выше.)" fi exit 2 fi # M-3 (round 12, audit-opus-r11.md): a tool this run installed off this window's PATH cannot start as a bare # command here; the end then says, per tool, the single path's launch line by state (a new window, the full path). if [ "$configured" != 0 ] && [ -z "$unproved_en" ] && [ "$manual" = 0 ]; then if [ "$off_path" = 1 ]; then say "✅ Done: every configured tool answers through Kumo (the check is free; model requests are paid from your balance). How to start each:" \ "✅ Готово: каждый настроенный инструмент отвечает через Kumo (проверка бесплатна; запросы к модели списываются с баланса). Как запустить каждый:" list_runs else say "✅ Done: every configured tool answers through Kumo (the check is free; model requests are paid from your balance). Start any of them: $started" \ "✅ Готово: каждый настроенный инструмент отвечает через Kumo (проверка бесплатна; запросы к модели списываются с баланса). Запуск: $started" fi else say "⚠ Almost done:" "⚠ Почти готово:" list_actions if [ -n "$unproved_en" ]; then if [ "$KUMO_RU" = 1 ]; then printf '%s\n' "$unproved_ru" | sed 1d; else printf '%s\n' "$unproved_en" | sed 1d; fi fi if [ "$configured" != 0 ]; then funds_line; fi if [ "$off_path" = 1 ]; then say "How to start each:" "Как запустить каждый:" list_runs elif [ -n "$started" ]; then say "Start any of them: $started" "Запуск: $started" fi fi if [ "$KUMO_WARNED" = 1 ]; then say "(See the notes marked ! above.)" "(Смотрите заметки с ! выше.)" fi } # The «what to do» lines of a list's end (kumo_list's counters): a failed install — install by hand, then run again # (round 12, m-2); a tool with no proven model or a side file — set it up by hand; no model — add --models. list_actions() { if [ -n "$failed" ]; then say " install the tools named above by hand, then run this command again." " установите вручную программы, названные выше, затем выполните эту команду ещё раз." fi if [ "$manual" != "$failed_n" ]; then say " set up by hand the tools named above." " настройте вручную инструменты, названные выше." fi if [ "$nomodel" != 0 ]; then say " the tools skipped above need a model: add --models =." " для пропущенных выше инструментов нужна модель: добавьте --models <клиент>=." fi } # M-3 (round 12): each configured tool's launch line by state, gathered by kumo_list (runs_en / runs_ru). list_runs() { if [ "$KUMO_RU" = 1 ]; then printf '%s\n' "$runs_ru" | sed 1d; else printf '%s\n' "$runs_en" | sed 1d; fi } # Sol R5-m4: --models is read ONCE, before anything is written, into a checked client → model map: every entry is # = with one of the five clients, a model, and no client twice — else the run stops (exit 2), the # same in connect.ps1. model_for then reads that checked list. check_models() { if [ -z "$models" ]; then return 0; fi case $models in ,*|*,|*,,*) usage >&2; die 2 "--models has an empty entry: $models" "в --models пустой элемент: $models" ;; esac seen_m=" " for entry in $(printf '%s' "$models" | tr ',' ' '); do case $entry in *=*) ;; *) usage >&2; die 2 "--models: $entry is not =" "--models: $entry — не <клиент>=" ;; esac mc=$(printf '%s' "$entry" | sed 's/=.*$//') mm=$(printf '%s' "$entry" | sed 's/^[^=]*=//') if [ -z "$mc" ]; then usage >&2; die 2 "--models: $entry names no client" "--models: в $entry не указан клиент"; fi case $mc in claude-code.*) # Round 12b: one of Claude Code's four slots, in a run that has Claude Code — the same slot as --fable / --opus / # --sonnet / --haiku (slot ownership as on the single path), never both, never twice. ms=$(printf '%s' "$mc" | sed 's/^claude-code\.//') case $ms in fable|opus|sonnet|haiku) ;; *) usage >&2; die 2 "--models: unknown Claude Code slot $mc (claude-code.fable, .opus, .sonnet or .haiku)" "--models: неизвестный слот Claude Code $mc (claude-code.fable, .opus, .sonnet или .haiku)" ;; esac case ",$client," in *,claude-code,*|,all,) ;; *) usage >&2; die 2 "--models: $mc names a Claude Code slot, but claude-code is not among the clients" "--models: $mc — слот Claude Code, но claude-code нет среди клиентов" ;; esac if [ -z "$mm" ]; then usage >&2; die 2 "--models: no model for $mc" "--models: не указана модель для $mc"; fi case $seen_m in *" $mc "*) usage >&2; die 2 "--models: $mc is named twice" "--models: $mc указан дважды" ;; esac seen_m="$seen_m$mc " case $ms in fable) ms_had=$xfable ;; opus) ms_had=$xopus ;; sonnet) ms_had=$xsonnet ;; *) ms_had=$xhaiku ;; esac if [ -n "$ms_had" ]; then usage >&2; die 2 "--models: $mc and --$ms both name that slot" "--models: $mc и --$ms задают один и тот же слот"; fi case $ms in fable) xfable=$mm ;; opus) xopus=$mm ;; sonnet) xsonnet=$mm ;; *) xhaiku=$mm ;; esac continue ;; claude-code|codex|opencode|openclaw|pi) ;; *) usage >&2; die 2 "--models: unknown client $mc" "--models: неизвестный клиент $mc" ;; esac if [ -z "$mm" ]; then usage >&2; die 2 "--models: no model for $mc" "--models: не указана модель для $mc"; fi case $seen_m in *" $mc "*) usage >&2; die 2 "--models: $mc is named twice" "--models: $mc указан дважды" ;; esac seen_m="$seen_m$mc " done } # The model --models names for one client (claude-code=a,codex=b,…), or nothing. model_for() { printf '%s\n' "$models" | tr ',' '\n' | KUMO_C=$1 awk -F= '$1 == ENVIRON["KUMO_C"] { sub(/^[^=]*=/, ""); print; exit }' } # The «model» verb's config.toml, to stdout (key_write's command: the file holds the key): the new model line, then the # reader's file with only its model line dropped. model_toml_body() { printf 'model = %s\n' "$(js "$model")" awk -v mode=write -v only=model "$KUMO_TOML_AWK" "$target" /dev/null || return "$?" } # Kumo is not set up for this client here: say so and where to connect it (the «model» verb never adds a key). not_configured() { die 1 "Kumo is not set up for $name in $1, so nothing was changed. Connect it first: $KUMO_CONSOLE/#connect" \ "Kumo не настроен для $name в $1, поэтому ничего не изменено. Сначала подключите его: $KUMO_CONSOLE/#connect" } # «model [--base ]»: switch the model of a client Kumo is already set up for — no key, only # the model fields (Claude Code: model and each slot that held the model Kumo wrote before — P3; Codex: the top-level # model; OpenCode, OpenClaw: the default; Pi: its default provider and model in settings.json). The model is checked # against this gateway's list first (P1). m5: Claude Code counts as set up only when its ANTHROPIC_BASE_URL is THIS # gateway's (the default, or --base) — another provider's base is not Kumo's, and its file is not touched. The same # backup, atomic write, validation and summary. kumo_model() { client=$mclient case $client in claude-code|codex|opencode|openclaw|pi) name=$(client_name "$client") ;; "") usage >&2; die 2 "name a client: model " "укажите клиента: model <клиент> " ;; *) usage >&2; die 2 "unknown client $client" "неизвестный клиент $client" ;; esac if [ -z "$mid" ]; then usage >&2; die 2 "name the model: model $client " "укажите модель: model $client "; fi if [ -n "$key" ]; then usage >&2; die 2 "the model verb takes no key" "для смены модели ключ не нужен"; fi if [ -z "$HOME" ]; then die 1 "HOME is not set" "переменная HOME не задана"; fi umask 077 KUMO_TMP=$TMPDIR if [ -z "$KUMO_TMP" ] || [ ! -d "$KUMO_TMP" ]; then KUMO_TMP=/tmp; fi # Sol R7-M1 (round 8): the same path check as a setup, before anything is written. paths_guard "$client" KUMO_ENGINE=$(pick_engine) model=$mid KUMO_WROTE="" KUMO_SIDE="" KUMO_BACKUP="" KUMO_CHANGED=0 KUMO_DROP="" KUMO_KEPT="" config_file check_plain "$file" case $client in codex) if [ ! -s "$file" ]; then not_configured "$file"; fi resolve_target "$file" target=$KUMO_REAL # R14-M1 (round 15): a scan that failed changes nothing and says so (the same words as an unsure file). scan_rc=0 facts=$(awk -v mode=scan "$KUMO_TOML_AWK" "$target" /dev/null) || scan_rc=$? if [ "$scan_rc" != 0 ]; then warn "$target could not be edited safely (this system's awk could not read it to the end), so it was not touched." "$target не удалось безопасно изменить (awk этой системы не смог дочитать его), поэтому файл не тронут." say "Set the first line of $target to: model = $(js "$model")" "Поставьте первой строкой $target: model = $(js "$model")" exit 0 fi old_model=$(printf '%s\n' "$facts" | sed -n 1p) provider=$(printf '%s\n' "$facts" | sed -n 4p) unsure=$(printf '%s\n' "$facts" | sed -n 5p) if [ "$(printf '%s\n' "$facts" | sed -n 2p)" != 1 ] || [ "$provider" != kumo ]; then not_configured "$file"; fi check_model if [ -n "$KUMO_SKIP" ]; then die 1 "Nothing was changed: this gateway has no model proven with $name yet." "Ничего не изменено: у этого шлюза пока нет модели, проверенной с $name."; fi if [ -n "$unsure" ]; then warn "$target could not be edited safely ($unsure), so it was not touched." "$target не удалось безопасно изменить ($unsure), поэтому файл не тронут." say "Set the first line of $target to: model = $(js "$model")" "Поставьте первой строкой $target: model = $(js "$model")" exit 0 fi key_temp "$target" tmp=$KUMO_T # Opus n15-5 (round 16): a rewrite that awk cannot finish is the scan failure's twin — the same words and the line # to set by hand, exit 0 — not «cannot write »; a key_write refusal (120–123) still stops as said. # BUG_FIX_CONTEXT (round 16, n15-5, audit-opus-r15.md): model_toml_body's awk status reached key_write_or_stop, # which named the stage's temp file and said nothing to do. kw_rc=0 key_write "$tmp" model_toml_body || kw_rc=$? if [ "$kw_rc" != 0 ]; then key_write_said "$kw_rc" "$tmp" rm -f "$tmp" warn "$target could not be edited safely (this system's awk could not rewrite it), so it was not touched." "$target не удалось безопасно изменить (awk этой системы не смог его переписать), поэтому файл не тронут." say "Set the first line of $target to: model = $(js "$model")" "Поставьте первой строкой $target: model = $(js "$model")" exit 0 fi rc=0 why=$(toml_check "$tmp") || rc=$? if [ "$rc" != 0 ]; then rm -f "$tmp" warn "$target could not be edited safely ($why), so it was not touched." "$target не удалось безопасно изменить ($why), поэтому файл не тронут." say "Set the first line of $target to: model = $(js "$model")" "Поставьте первой строкой $target: model = $(js "$model")" exit 0 fi install_or_stop "$target" "$tmp" KUMO_WROTE=$target new_model=$model ;; *) case $client in claude-code) probe_path=env.ANTHROPIC_BASE_URL; old_path=model ;; opencode) probe_path=provider.kumo; old_path=model ;; openclaw) probe_path=models.providers.kumo; old_path=agents.defaults.model.primary ;; pi) probe_path=providers.kumo ;; esac if [ "$KUMO_ENGINE" = none ]; then warn "There is no node or python3 to edit $file safely, so it was not touched." "Нет node или python3, чтобы безопасно изменить $file, поэтому файл не тронут." say "Set the model there by hand to: $model" "Поставьте там модель вручную: $model" exit 0 fi found=$(json_get "$file" "$probe_path") if [ -z "$found" ]; then not_configured "$file"; fi if [ "$client" = claude-code ] && [ "$(printf '%s' "$found" | sed 's#/*$##')" != "$abase" ]; then die 1 "$file points Claude Code at $found, not at Kumo ($abase), so nothing was changed. Connect it to Kumo first: $KUMO_CONSOLE/#connect" \ "$file направляет Claude Code на $found, а не на Kumo ($abase), поэтому ничего не изменено. Сначала подключите его к Kumo: $KUMO_CONSOLE/#connect" fi check_model if [ -n "$KUMO_SKIP" ]; then die 1 "Nothing was changed: this gateway has no model proven with $name yet." "Ничего не изменено: у этого шлюза пока нет модели, проверенной с $name."; fi case $client in claude-code) new_model=$model old_model=$(json_get "$file" model) # P3 / U2 / Sol R5-M4: a slot follows the model only when it held the model Kumo wrote before (the marker's, # else — a file from before the marker — the file's old model); a slot on Kumo's own tier pick stays on it, # and a slot the reader set stays too. The summary lists what moved and what was left, each by name. marker=$(claude_marker) mk_model=$(json_get "$marker" model) mk_slots=$(json_get "$marker" slots) kumo_old=$mk_model if [ -z "$kumo_old" ]; then kumo_old=$old_model; fi entries="" fable="" opus="" sonnet="" haiku="" KUMO_MOVED="model" KUMO_LEFT="" for tier in FABLE OPUS SONNET HAIKU; do cur=$(json_get "$file" "env.ANTHROPIC_DEFAULT_$tier"_MODEL) mk_slot="" if [ -n "$mk_model" ]; then mk_slot=$(json_get "$marker" "slots.$tier"); fi wrote=$mk_slot if [ -z "$cur" ] || [ "$cur" = "{}" ]; then wrote="" else case $tier in FABLE) pick=$KUMO_PICK_FABLE ;; OPUS) pick=$KUMO_PICK_OPUS ;; SONNET) pick="" ;; HAIKU) pick=$KUMO_PICK_HAIKU ;; esac # BUG_FIX_CONTEXT (Sol R6-M2): «held the model Kumo wrote» was judged by value alone, so a reader's slot # that equalled Kumo's model (and that the marker records as NOT Kumo's) moved with it. With a marker, a # slot follows only when the marker names that value for that slot; without one, the old rule stands. follows=0 if [ -n "$mk_model" ]; then if [ -n "$mk_slot" ] && [ "$cur" = "$mk_slot" ] && [ "$cur" = "$mk_model" ]; then follows=1 elif [ "$mk_slots" != "{}" ] && [ "$cur" = "$mk_model" ]; then follows=1 fi elif [ -n "$kumo_old" ] && [ "$cur" = "$kumo_old" ]; then follows=1 fi if [ "$follows" = 1 ]; then entry=" \"ANTHROPIC_DEFAULT_$tier""_MODEL\": $(js "$model")" if [ -z "$entries" ]; then entries=$entry; else entries="$entries, $entry"; fi now=$model wrote=$model KUMO_MOVED="$KUMO_MOVED, ANTHROPIC_DEFAULT_$tier""_MODEL" else now=$cur if { [ -n "$mk_slot" ] && [ "$cur" = "$mk_slot" ]; } || { [ -z "$mk_model" ] && [ -n "$pick" ] && [ "$cur" = "$pick" ]; }; then wrote=$cur KUMO_LEFT="$KUMO_LEFT ANTHROPIC_DEFAULT_$tier""_MODEL = $cur" else wrote="" KUMO_KEPT="$KUMO_KEPT ANTHROPIC_DEFAULT_$tier""_MODEL = $cur" fi fi case $tier in FABLE) fable=$now ;; OPUS) opus=$now ;; SONNET) sonnet=$now ;; HAIKU) haiku=$now ;; esac fi case $tier in FABLE) KUMO_MK_FABLE=$wrote ;; OPUS) KUMO_MK_OPUS=$wrote ;; SONNET) KUMO_MK_SONNET=$wrote ;; HAIKU) KUMO_MK_HAIKU=$wrote ;; esac done if [ -n "$entries" ]; then KUMO_PATCH=$(printf '{\n "env": {\n%s\n },\n "model": %s\n}' "$entries" "$(js "$model")") else KUMO_PATCH=$(printf '{\n "model": %s\n}' "$(js "$model")") fi ;; opencode) new_model="kumo/$model" old_model=$(json_get "$file" model) KUMO_PATCH=$(printf '{\n "model": %s\n}' "$(js "kumo/$model")") ;; openclaw) new_model="kumo/$model" old_model=$(json_get "$file" agents.defaults.model.primary) KUMO_PATCH=$(printf '{\n "agents": {\n "defaults": {\n "model": {\n "primary": %s\n }\n }\n }\n}' "$(js "kumo/$model")") ;; pi) new_model=$model file="$HOME/.pi/agent/settings.json" check_plain "$file" old_model=$(json_get "$file" defaultModel) KUMO_PATCH=$(printf '{\n "defaultProvider": "kumo",\n "defaultModel": %s\n}' "$(js "$model")") ;; esac KUMO_SHOWN=$KUMO_PATCH # #533 r1: merge_into hands the engine KUMO_SLOTTED; the model verb's patch holds no key and no slot, so it is that. # BUG_FIX_CONTEXT (#533 r1): without this line the verb merged an empty patch and changed no model field. KUMO_SLOTTED=$KUMO_PATCH write_json "$file" if [ -n "$KUMO_SIDE" ]; then say "⚠ Almost done: merge $KUMO_SIDE by hand as shown above." "⚠ Почти готово: перенесите $KUMO_SIDE вручную, как показано выше." exit 0 fi ;; esac if [ "$KUMO_CHANGED" = 0 ]; then say "Already on $new_model: $(tilde "$KUMO_WROTE") is unchanged." "Модель уже $new_model: $(tilde "$KUMO_WROTE") не изменился." return 0 fi say "Changed $(tilde "$KUMO_WROTE"):" "Изменён $(tilde "$KUMO_WROTE"):" if [ -n "$old_model" ] && [ "$old_model" != "$new_model" ] && [ "$old_model" != "{}" ]; then say " model: was $old_model → now $new_model" " model: было $old_model → стало $new_model" else say " model: $new_model" " model: $new_model" fi if [ "$client" = claude-code ]; then # U2: exactly what moved, and what was left — by name. say " moved to $model: $KUMO_MOVED" " переведены на $model: $KUMO_MOVED" if [ -n "$KUMO_LEFT" ]; then printf '%s\n' "$KUMO_LEFT" | while IFS= read -r left; do if [ -n "$left" ]; then say " left on Kumo's model of its tier: $left" " оставлен на модели Kumo своего класса: $left"; fi done fi if [ -n "$KUMO_KEPT" ]; then printf '%s\n' "$KUMO_KEPT" | while IFS= read -r kept; do if [ -n "$kept" ]; then say " kept as you set it: $kept" " оставлен как вы задали: $kept"; fi done fi write_marker "$model" main_override "$model" fi undo_finish say "If $name is already running, close it and start it again: it reads the new model when it starts." \ "Если $name уже запущен, закройте его и запустите снова: новую модель он прочитает при запуске." } kumo_main "$@"