From d305c3340a837d0f9163bd0109368e4e425c02e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:04:51 +0700 Subject: [PATCH] T27 OS: the PTY server reads the Keychain itself, so a pane spawned with no window connected still has its key Measured 2026-10-20 on the bench Mac: after the key store moved to /usr/bin/security (no Keychain dialog), three Claude Code panes spawned with T27OS_CTL=spawn all showed "key -" in T27OS_CTL=list. The server learned keys only from the window (OP_SETKEY), so a pane spawned while the window was not connected, and every pane of a restarted server, started keyless. pty_server.t27: load_keys reads every number of every provider through keys_host kc_get_n (the same /usr/bin/security reader, scan_goes_on) and puts each key in the table by put_key, the code path OP_SETKEY now shares (extracted, not copied). It runs once at start, after the socket listens and before panes are restored, so a restored pane's wait for a key resolves at once; and at a spawn that finds no key for the pane's agent when the last read is older than a minute. A window's OP_SETKEY still adds and overrides (the same key again keeps its cooldown). The start prints a count, never a key. pty_logic.t27: the decisions, with tests: load_all_mask, load_mask_agent (claude: the four profile providers; others: AGENT_PROVIDERS), load_wants, keys_reload_due, KEYS_RELOAD_MS, G_KLOAD (table word 228). 34 -> 37 tests; sealed again. keys_probe.t27 (new, moved from keys_host.t27, not copied): the check button with its CoreFoundation and Objective-C externs. A module's @extern imports travel with it and t27b put CFRelease, CFStringCreateWithBytes and NSClassFromString into pty_server.o, which links libSystem only; keys_host.t27 is now libSystem only. glass_window imports probe from keys_probe; the server's nm -u lists _posix_spawn and _pipe and nothing from CoreFoundation. Refs #11265 Co-Authored-By: Claude Sonnet 5.5 --- .trinity/seals/t27os_T27osPtyLogic.json | 18 ++-- docs/t27os/keys-orchestration.md | 5 +- docs/t27os/run-local.md | 9 +- specs/t27os/glass_window.t27 | 2 +- specs/t27os/keys_host.t27 | 102 ++---------------- specs/t27os/keys_probe.t27 | 117 +++++++++++++++++++++ specs/t27os/pty_logic.t27 | 131 ++++++++++++++++++++++++ specs/t27os/pty_server.t27 | 115 ++++++++++++++++----- specs/t27os/router.t27 | 2 +- 9 files changed, 368 insertions(+), 133 deletions(-) create mode 100644 specs/t27os/keys_probe.t27 diff --git a/.trinity/seals/t27os_T27osPtyLogic.json b/.trinity/seals/t27os_T27osPtyLogic.json index 19f1f324e7..1e509380b1 100644 --- a/.trinity/seals/t27os_T27osPtyLogic.json +++ b/.trinity/seals/t27os_T27osPtyLogic.json @@ -1,5 +1,5 @@ { - "built_by": "t27c-bootstrap@0.5.2+e3bb9ffec", + "built_by": "t27c-bootstrap@0.5.2+1417e3bee", "closure": [ { "spec": "specs/t27os/agents.t27", @@ -27,23 +27,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:efb132b84e42963b545ad4950c81cd65a3bb9fd74ece35db758f4af5522e5351", - "gen_hash_rust": "sha256:c5e7da1a403caadc39e1f8a6065e45da5021ebb865d7f42de658aa0d6019ab5e", - "gen_hash_verilog": "sha256:6cd1f9c0f7b5b571e5b1a93641bc68ea44b24634a3652abda34de4ec4c5de93f", - "gen_hash_zig": "sha256:ce84121284bc0007cad586e55c9bfc00b6cb072c3a374a300095ef52476b3aab", + "gen_hash_c": "sha256:ea264f26eee4a971f4fbf7cd9f7de6de72f55cfe82fa9ae98e5ccaa3cfe9f922", + "gen_hash_rust": "sha256:54815c023a8034256634bffc1894fb481027faba8e8915c0c674363efa8877db", + "gen_hash_verilog": "sha256:2ac6b6aa132ffa0bd68f68b896247fb411d29e9d736eb39ab725cd42c563a553", + "gen_hash_zig": "sha256:f5f02c802c02af613dd90592c3e77dd525cc64da594a57c9274f66208a2cbbf3", "module": "T27osPtyLogic", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-20T07:50:06Z", + "sealed_at": "2026-10-20T08:04:30Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:8b07d4889aa1226c25eda31972d98cde7b8a8271b2f419f1a78c0a320089f4af", + "spec_hash": "sha256:2167ea0959df3a657b97cf7055bf89f622f379b71e73852a3da1d0bacf1e53fa", "spec_path": "specs/t27os/pty_logic.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 34, - "total": 34, + "passed": 37, + "total": 37, "vacuous": 0 }, "toolchain": { diff --git a/docs/t27os/keys-orchestration.md b/docs/t27os/keys-orchestration.md index 10b895d3d7..2746e1c907 100644 --- a/docs/t27os/keys-orchestration.md +++ b/docs/t27os/keys-orchestration.md @@ -1,7 +1,8 @@ # T27 OS key orchestration, compared with Free Claude Code Issue: gHashTag/t27#11224. Code: `specs/t27os/keys.t27` (decisions and data), the host side in -`keys_host.t27`, `pty_server.t27` and `glass_window.t27`. Stage 2 (a local router) is +`keys_host.t27` (the Keychain and the push to the server), `keys_probe.t27` (the check button), +`pty_server.t27` and `glass_window.t27`. Stage 2 (a local router) is `specs/t27os/router.t27`, decisions only. No key value appears in this file or in any spec; every test uses fake values. @@ -49,7 +50,7 @@ No proxy is involved. | Per-tier overrides (opus, sonnet, haiku) | Yes, as data, one column per tier; an empty field falls back to the default; the small-fast variable follows haiku | `MODELS_OPUS/SONNET/HAIKU`, `tier_model`, `profile_var_name/text` | A fourth tier (the one the OpenRouter and Kimi pages call fable) is not mapped; changing a tier is a data edit, not a screen | | `provider/model` syntax | A profile is a provider; its models sit in the tier table | `PROFILE_NAMES`, `PROFILE_PROVIDER` | The combined one-line syntax; it exists in Free Claude Code because its proxy picks the upstream per request | | Many providers | Four profiles today: z.ai, Anthropic direct, OpenRouter, Ollama cloud, over the nine key rows | `PROFILE_PROVIDER`, `PROFILE_BASE_URLS`, `PROFILE_KEY_VARS` | A provider that does not speak the Anthropic protocol needs the local router and `specs/provider/transform.t27` (#10902). DeepSeek, Moonshot and MiniMax speak it but need key rows of their own (see below) | -| Numbered keys, key 1 first | Naming and choice are decided: key 1 keeps the existing Keychain item, keys 2 to 8 add `#2` to `#8`; least-recently-used rotation with a rest time | `keychain_label_n`, `MAX_KEYS`, `rotate_pick`, `rotate_pick_except` | Built: the host reads every numbered key, up to 8 per provider (`MAX_KEYS`), only through `/usr/bin/security`, never through SecItem; the KEYS tab adds the next free number, deletes the newest key and checks every key (`next_free_key`, `newest_key`, `provider_status`) | +| Numbered keys, key 1 first | Naming and choice are decided: key 1 keeps the existing Keychain item, keys 2 to 8 add `#2` to `#8`; least-recently-used rotation with a rest time | `keychain_label_n`, `MAX_KEYS`, `rotate_pick`, `rotate_pick_except` | Built: the host reads every numbered key, up to 8 per provider (`MAX_KEYS`), only through `/usr/bin/security`, never through SecItem; the KEYS tab adds the next free number, deletes the newest key and checks every key (`next_free_key`, `newest_key`, `provider_status`). The PTY server reads the keys itself the same way, at its start and at a spawn that finds no key for the agent (`load_mask_agent`, `keys_reload_due`), so it does not depend on the window being connected; the window's push still adds and overrides | | A fallback entry names a key number | Decided: a throttled key (429) with a spare key swaps the key and keeps the profile | `fo_action` = `FA_RESPAWN_KEY`, `fo_target` | The host does not pass the key number to the respawn yet | | Ordered fallback list after retries | Yes, per pane: the profile list is walked forward only | `next_profile`, `fo_action` = `FA_RESPAWN_NEXT`, `MAX_FAILOVERS` | Per request, inside one conversation turn: needs the local router. A respawn does not replay the request that failed; the person continues in the resumed session | | Retries before the fallback | Not ours: Claude Code retries by itself, and the failover starts when the error text reaches the screen | `classify_line` | Nothing to build | diff --git a/docs/t27os/run-local.md b/docs/t27os/run-local.md index 908830a5d8..3e3e57451e 100644 --- a/docs/t27os/run-local.md +++ b/docs/t27os/run-local.md @@ -22,8 +22,11 @@ The window does not start the server and does not bundle it. They meet on one Un pane in the window says "offline" and echoes keys. Provider keys (the KEYS tab, up to 8 per provider) live in the macOS Keychain, service `ai.t27.os`, account -``, then `#2` .. `#8`. The window reaches them only by running `/usr/bin/security` -(`specs/t27os/keys_host.t27`: posix_spawn, pipes, poll, waitpid), never through the SecItem calls, so +``, then `#2` .. `#8`. The window and the PTY server reach them only by running +`/usr/bin/security` (`specs/t27os/keys_host.t27`: posix_spawn, pipes, poll, waitpid; the server reads the +Keychain itself at its start and at a spawn that finds no key for the pane's agent, at most once a minute, +so a pane spawned from a script while the window is closed still has its key; the log line is +`keys read from the Keychain: N`, a count, never a key), never through the SecItem calls, so **Security.framework is not linked** and the link line in section 3 is complete (measured 2026-10-20: the SecItem build did not link without `-framework Security`; this one does). The reason: the window is signed ad hoc, every rebuild is a new code identity to macOS, and each SecItem read asked for the login password @@ -92,7 +95,7 @@ refuses to connect to the socket). ``` # 1. the server first, detached from the tab. PATH must contain the directory of the `claude` CLI. cd ~ && PATH=$HOME/.local/bin:$PATH /target/t27os/t27os-pty < /dev/null > /pty-server.log 2>&1 & -# the log then reads: t27os-server: listening, restored 0 +# the log then reads: t27os-server: keys read from the Keychain: N / t27os-server: listening, restored 0 # 2. the app open /target/t27os/T27OS.app ``` diff --git a/specs/t27os/glass_window.t27 b/specs/t27os/glass_window.t27 index d4a97e9159..321ad3f16b 100644 --- a/specs/t27os/glass_window.t27 +++ b/specs/t27os/glass_window.t27 @@ -126,7 +126,7 @@ use t27os::keys_host::kc_put_n; use t27os::keys_host::kc_get_n; use t27os::keys_host::kc_del_n; use t27os::keys_host::kc_has; -use t27os::keys_host::probe; +use t27os::keys_probe::probe; use t27os::keys_host::push_keys; use t27os::pty_client::client_set_key; use t27os::editors::EDITOR_COUNT; diff --git a/specs/t27os/keys_host.t27 b/specs/t27os/keys_host.t27 index 96b8bb36aa..075800d8c0 100644 --- a/specs/t27os/keys_host.t27 +++ b/specs/t27os/keys_host.t27 @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// T27 OS keys_host: where the key manager (keys.t27) touches the machine. Three jobs, no decisions: +// T27 OS keys_host: where the key manager (keys.t27) touches the machine through libSystem. Two jobs, no +// decisions (the check button, which needs Foundation, is keys_probe.t27): // - the macOS Keychain, one generic-password item per key (up to MAX_KEYS a provider; account = provider // name, then "#2" .. "#8") under the service "ai.t27.os": kc_get_n / kc_put_n / kc_del_n. // The Keychain is reached ONLY by running /usr/bin/security (posix_spawn, pipes, poll, waitpid), never @@ -7,12 +8,15 @@ // SecItem read of an item asked for the login password again; the items trust /usr/bin/security, which // Apple signs and no rebuild changes. A key is never written to a file, never put on argv (ps shows it: // an add feeds `security -i` one line on standard input) and never reaches the repo. -// - the check button: probe() sends the cheapest authenticated request of a provider (a GET; for z.ai one one-token POST) (table: keys.t27) and -// hands back the HTTP status; keys.t27 status_after_probe decides what it means. The key travels in a -// request header, never in the URL. // - push_keys(): hand every stored key, with its number, to the PTY server (OP_SETKEY) so a pane it spawns // is logged in and the server can rotate over the keys of a provider. // +// Two programs import this module: the window (glass_window.t27) and the PTY server (pty_server.t27, which +// reads the Keychain itself with kc_get_n so it never depends on the window being connected). A module's +// @extern imports travel with it, so everything here is libSystem only (posix_spawn, pipe, poll, waitpid): the +// server links against libSystem alone. Nothing may be added here that needs CoreFoundation or Foundation; +// that is why the check button moved to keys_probe.t27. +// // Specs holding @extern cannot run tests under t27c; every rule these fns follow is a tested fn in keys.t27 // (argument lists, the add line, which keys are safe for it, the exit-status table, the output -> key rule). // This file only spawns, pipes, reads and waits. @@ -27,7 +31,6 @@ module T27osKeysHost; use t27os::keys::PROVIDER_COUNT; use t27os::keys::MAX_KEYS; use t27os::keys::KEYCHAIN_SERVICE; -use t27os::keys::PROBE_TIMEOUT_S; use t27os::keys::SEC_OP_READ; use t27os::keys::SEC_OP_WRITE; use t27os::keys::SEC_OP_DELETE; @@ -45,13 +48,6 @@ use t27os::keys::sec_key_from_output; use t27os::keys::scan_goes_on; use t27os::keys::keychain_account_n; use t27os::keys::setkey_byte; -use t27os::keys::probe_url; -use t27os::keys::probe_has; -use t27os::keys::probe_header_name; -use t27os::keys::probe_header_value; -use t27os::keys::probe_post; -use t27os::keys::probe_body; -use t27os::keys::probe_needs_version; use t27os::pty_proto::get_u64; use t27os::pty_proto::MAX_FRAME; use t27os::pty_logic::put_pollfd; @@ -66,7 +62,6 @@ use t27os::pty_sys::sys_kill; use t27os::pty_sys::sys_waitpid; use t27os::pty_client::client_set_key; -pub const UTF8_ENC : u32 = 134217984; pub const SIGNAL_PIPE : i32 = 13; pub const SIGNAL_CHLD : i32 = 20; pub const HANDLER_DFL : u64 = 0; @@ -74,16 +69,6 @@ pub const HANDLER_IGN : u64 = 1; pub const SIGKILL_NUMBER : i32 = 9; fn addr_of(dst: []u8, src: []const u8, n: u64) -> u64 { return @extern("memmove"); } -fn cf_str(alloc: u64, bytes: []const u8, n: usize, enc: u32, ext: bool) -> u64 { return @extern("CFStringCreateWithBytes"); } -fn cf_data(alloc: u64, bytes: []const u8, n: u64) -> u64 { return @extern("CFDataCreate"); } -fn cf_release(o: u64) -> void { @extern("CFRelease"); } -fn class_named(name: u64) -> u64 { return @extern("NSClassFromString"); } -fn sel_named(name: u64) -> u64 { return @extern("NSSelectorFromString"); } -fn msg0(o: u64, s: u64) -> u64 { return @extern("objc_msgSend"); } -fn msg1(o: u64, s: u64, a: u64) -> u64 { return @extern("objc_msgSend"); } -fn msg2(o: u64, s: u64, a: u64, b: u64) -> u64 { return @extern("objc_msgSend"); } -fn msg3(o: u64, s: u64, a: u64, b: u64, c: u64) -> u64 { return @extern("objc_msgSend"); } -fn msg_f(o: u64, s: u64, t: f64) -> u64 { return @extern("objc_msgSend"); } /// pipe(2): fds[0] is the read end, fds[1] the write end. fn sys_pipe(fds: [2]u32) -> i32 { return @extern("pipe"); } @@ -95,24 +80,6 @@ fn fa_dup2(fa: []u8, fd: i32, to: i32) -> i32 { return @extern("posix_spawn_file fn fa_close(fa: []u8, fd: i32) -> i32 { return @extern("posix_spawn_file_actions_addclose"); } fn fa_open(fa: []u8, fd: i32, path: []const u8, flags: i32, mode: u32) -> i32 { return @extern("posix_spawn_file_actions_addopen"); } -fn cf(s: []const u8) -> u64 { - return cf_str(0, s, s.len, UTF8_ENC, false); -} - -fn cls(s: []const u8) -> u64 { - const n : u64 = cf(s); - const c : u64 = class_named(n); - cf_release(n); - return c; -} - -fn sel(s: []const u8) -> u64 { - const n : u64 = cf(s); - const c : u64 = sel_named(n); - cf_release(n); - return c; -} - fn close_fd(fd: u32) -> void { sys_close(@as(i32, @intCast(fd))); } @@ -369,59 +336,6 @@ pub fn kc_has(p: u32) -> bool { return n > 0; } -fn add_header(req: u64, name: []const u8, value: []const u8) -> void { - const n : u64 = cf(name); - const v : u64 = cf(value); - msg2(req, sel("setValue:forHTTPHeaderField:"), v, n); - cf_release(n); - cf_release(v); -} - -/// The check button: one authenticated GET for provider p with `key`; the HTTP status, 0 when nothing -/// answered (no network, timeout) and 1 when the provider has no probe. Blocks up to PROBE_TIMEOUT_S. -pub fn probe(p: u32, key: []const u8) -> u32 { - if (probe_has(p) == false) { return 1; } - var ub : [160]u8 = undefined; - const un : usize = probe_url(p, ub[0..160]); - const us : u64 = cf(ub[0..un]); - const url : u64 = msg1(cls("NSURL"), sel("URLWithString:"), us); - cf_release(us); - if (url == 0) { return 0; } - const req : u64 = msg1(cls("NSMutableURLRequest"), sel("requestWithURL:"), url); - if (req == 0) { return 0; } - msg_f(req, sel("setTimeoutInterval:"), @as(f64, @floatFromInt(PROBE_TIMEOUT_S))); - var hn : [24]u8 = undefined; - var hv : [300]u8 = undefined; - const nl : usize = probe_header_name(p, hn[0..24]); - const vl : usize = probe_header_value(p, key, hv[0..300]); - add_header(req, hn[0..nl], hv[0..vl]); - if (probe_needs_version(p)) { add_header(req, "anthropic-version", "2023-06-01"); } - if (probe_post(p)) { - var bb : [200]u8 = undefined; - const bl : usize = probe_body(p, bb[0..200]); - const data : u64 = cf_data(0, bb[0..bl], @as(u64, bl)); - const post : u64 = cf("POST"); - msg1(req, sel("setHTTPMethod:"), post); - msg1(req, sel("setHTTPBody:"), data); - add_header(req, "content-type", "application/json"); - cf_release(post); - cf_release(data); - } - add_header(req, "User-Agent", "t27os"); - var resp : [8]u8 = undefined; - var err : [8]u8 = undefined; - var i : usize = 0; - while (i < 8) { - resp[i] = 0; - err[i] = 0; - i = i + 1; - } - msg3(cls("NSURLConnection"), sel("sendSynchronousRequest:returningResponse:error:"), req, addr_of(resp[0..8], resp[0..8], 0), addr_of(err[0..8], err[0..8], 0)); - const r : u64 = get_u64(resp[0..8], 0); - if (r == 0) { return 0; } - return @as(u32, @intCast(msg0(r, sel("statusCode")))); -} - /// Hand every stored key to the PTY server on `fd`, each with its number; the count pushed. The numbered /// accounts of every provider are read (a scan stops after two misses in a row, keys.t27 scan_goes_on) and the /// number rides in the OP_SETKEY pane byte (keys.t27 setkey_byte), so the server holds all of them and diff --git a/specs/t27os/keys_probe.t27 b/specs/t27os/keys_probe.t27 new file mode 100644 index 0000000000..523fc76ce7 --- /dev/null +++ b/specs/t27os/keys_probe.t27 @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS keys_probe: the check button of the KEYS tab -- the one host call of the key manager that needs +// Foundation. probe() sends the cheapest authenticated request of a provider (a GET; for z.ai one one-token +// POST) (table: keys.t27) and hands back the HTTP status; keys.t27 status_after_probe decides what it means. +// The key travels in a request header, never in the URL. +// +// It lives apart from keys_host.t27 (the Keychain and the push to the PTY server, libSystem only) because a +// module's @extern imports travel with it: the PTY server imports keys_host to read the Keychain itself, and a +// server that linked CoreFoundation and the Objective-C runtime for a probe it never sends would not link +// against libSystem alone. Only the window imports this module. +// +// Specs holding @extern cannot run tests under t27c; every rule probe follows is a tested fn in keys.t27 +// (url, header name and value, body, whether a probe exists). This file only builds the request and sends it. +// +// Refs gHashTag/t27#10782, gHashTag/t27#11241. +module T27osKeysProbe; + +use t27os::keys::PROBE_TIMEOUT_S; +use t27os::keys::probe_url; +use t27os::keys::probe_has; +use t27os::keys::probe_header_name; +use t27os::keys::probe_header_value; +use t27os::keys::probe_post; +use t27os::keys::probe_body; +use t27os::keys::probe_needs_version; +use t27os::pty_proto::get_u64; +use t27os::pty_sys::sys_addr; + +pub const UTF8_ENC : u32 = 134217984; + +fn cf_str(alloc: u64, bytes: []const u8, n: usize, enc: u32, ext: bool) -> u64 { return @extern("CFStringCreateWithBytes"); } +fn cf_data(alloc: u64, bytes: []const u8, n: u64) -> u64 { return @extern("CFDataCreate"); } +fn cf_release(o: u64) -> void { @extern("CFRelease"); } +fn class_named(name: u64) -> u64 { return @extern("NSClassFromString"); } +fn sel_named(name: u64) -> u64 { return @extern("NSSelectorFromString"); } +fn msg0(o: u64, s: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg1(o: u64, s: u64, a: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg2(o: u64, s: u64, a: u64, b: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg3(o: u64, s: u64, a: u64, b: u64, c: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg_f(o: u64, s: u64, t: f64) -> u64 { return @extern("objc_msgSend"); } + +fn cf(s: []const u8) -> u64 { + return cf_str(0, s, s.len, UTF8_ENC, false); +} + +fn cls(s: []const u8) -> u64 { + const n : u64 = cf(s); + const c : u64 = class_named(n); + cf_release(n); + return c; +} + +fn sel(s: []const u8) -> u64 { + const n : u64 = cf(s); + const c : u64 = sel_named(n); + cf_release(n); + return c; +} + +fn add_header(req: u64, name: []const u8, value: []const u8) -> void { + const n : u64 = cf(name); + const v : u64 = cf(value); + msg2(req, sel("setValue:forHTTPHeaderField:"), v, n); + cf_release(n); + cf_release(v); +} + +/// The check button: one authenticated GET for provider p with `key`; the HTTP status, 0 when nothing +/// answered (no network, timeout) and 1 when the provider has no probe. Blocks up to PROBE_TIMEOUT_S. +pub fn probe(p: u32, key: []const u8) -> u32 { + if (probe_has(p) == false) { return 1; } + var ub : [160]u8 = undefined; + const un : usize = probe_url(p, ub[0..160]); + const us : u64 = cf(ub[0..un]); + const url : u64 = msg1(cls("NSURL"), sel("URLWithString:"), us); + cf_release(us); + if (url == 0) { return 0; } + const req : u64 = msg1(cls("NSMutableURLRequest"), sel("requestWithURL:"), url); + if (req == 0) { return 0; } + msg_f(req, sel("setTimeoutInterval:"), @as(f64, @floatFromInt(PROBE_TIMEOUT_S))); + var hn : [24]u8 = undefined; + var hv : [300]u8 = undefined; + const nl : usize = probe_header_name(p, hn[0..24]); + const vl : usize = probe_header_value(p, key, hv[0..300]); + add_header(req, hn[0..nl], hv[0..vl]); + if (probe_needs_version(p)) { add_header(req, "anthropic-version", "2023-06-01"); } + if (probe_post(p)) { + var bb : [200]u8 = undefined; + const bl : usize = probe_body(p, bb[0..200]); + const data : u64 = cf_data(0, bb[0..bl], @as(u64, bl)); + const post : u64 = cf("POST"); + msg1(req, sel("setHTTPMethod:"), post); + msg1(req, sel("setHTTPBody:"), data); + add_header(req, "content-type", "application/json"); + cf_release(post); + cf_release(data); + } + add_header(req, "User-Agent", "t27os"); + var resp : [8]u8 = undefined; + var err : [8]u8 = undefined; + var i : usize = 0; + while (i < 8) { + resp[i] = 0; + err[i] = 0; + i = i + 1; + } + msg3(cls("NSURLConnection"), sel("sendSynchronousRequest:returningResponse:error:"), req, sys_addr(resp[0..8], resp[0..8], 0), sys_addr(err[0..8], err[0..8], 0)); + const r : u64 = get_u64(resp[0..8], 0); + if (r == 0) { return 0; } + return @as(u32, @intCast(msg0(r, sel("statusCode")))); +} + +test the_probe_module_is_declared { + var b : [4]u8 = undefined; + b[0] = 1; + assert b[0] == 1; +} diff --git a/specs/t27os/pty_logic.t27 b/specs/t27os/pty_logic.t27 index 7d132bf151..aee8e51731 100644 --- a/specs/t27os/pty_logic.t27 +++ b/specs/t27os/pty_logic.t27 @@ -172,6 +172,9 @@ pub const G_WINSZ : usize = 248; /// is not read (after a give-up) and its restore state (W_*). Key slot (p, k) is word p * MAX_KEYS + k. /// G_START is the millisecond the server started at (a restored pane waits for keys from then on). pub const G_START : usize = 227; +/// The millisecond the server last read the Keychain itself (pty_server load_keys); 0 = never. Word 228 was +/// free: G_START is 227 and the connection tables begin at G_CFD. +pub const G_KLOAD : usize = 228; pub const G_KLAST : usize = 256; pub const G_KREST : usize = 328; pub const G_PANEK : usize = 400; @@ -194,6 +197,10 @@ pub const W_KEYLESS : u64 = 8; pub const W_DONE : u64 = 9; /// How long a restored pane waits for the window's keys before it starts as it always did. pub const RESTORE_WAIT_MS : u64 = 20000; +/// A spawn that finds no key for its agent reads the Keychain again only when the last read is older than this +/// (a key added from a terminal is picked up at the next spawn after it; a table with no key at all does not +/// run /usr/bin/security at every spawn). +pub const KEYS_RELOAD_MS : u64 = 60000; /// A pane's screen is not read for this long after it starts: a resumed Claude Code session replays its /// transcript, which may hold an earlier error row. pub const SCAN_GRACE_MS : u64 = 10000; @@ -849,6 +856,56 @@ pub fn keyless_plan(a: u32, sid: []const u8) -> u32 { return p; } +// ---- the server reads the Keychain itself ---- +// The window pushes the keys (OP_SETKEY) only while it is connected. A pane spawned with no window (it is +// minimized, on the home screen, or a script or the Queen spawns it) would start with no key, and so would every +// pane of a server that just restarted. So the server reads the keys itself, through the same /usr/bin/security +// reader the window uses (keys_host kc_get_n): once at its start for every provider, and at a spawn that finds no +// key its agent can start with, for the providers that agent reads, at most once per KEYS_RELOAD_MS. What a read +// finds goes into the table the way an OP_SETKEY would put it. These are the decisions; the reads are in +// pty_server.t27. + +/// Every provider, as a bit mask: the read at the server's start (a later spawn may be any agent). +pub fn load_all_mask() -> u32 { + var m : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + m = m + (1 << p); + p = p + 1; + } + return m; +} + +/// The providers a spawn of agent a can start with, as a bit mask: for the claude cell the provider of every +/// profile (z.ai, Anthropic, OpenRouter, Ollama cloud), for every other agent the providers it reads (keys.t27 +/// AGENT_PROVIDERS); 0 for the shell and editor cells and an unknown agent, which read none. +pub fn load_mask_agent(a: u32) -> u32 { + if (a >= AGENT_COUNT) { return 0; } + if (profile_agent(a) == false) { return spawn_mask(a, load_all_mask()); } + var m : u32 = 0; + var r : u32 = 0; + while (r < PROFILE_COUNT) { + const prov : u32 = PROFILE_PROVIDER[r]; + if ((m >> prov) % 2 == 0) { m = m + (1 << prov); } + r = r + 1; + } + return m; +} + +/// Does a read with provider mask `mask` cover provider p? +pub fn load_wants(mask: u32, p: u32) -> bool { + return p < PROVIDER_COUNT and (mask >> p) % 2 == 1; +} + +/// Is a read due for a spawn of agent a at `now` ms, `last` being G_KLOAD (0 = never read)? Only when the agent +/// reads a key, the table holds none it can start with, and no read ran in the last KEYS_RELOAD_MS. +pub fn keys_reload_due(st: []const u64, a: u32, now: u64, last: u64) -> bool { + if (needs_key(a) == false) { return false; } + if (has_key_for(st, a)) { return false; } + if (last == 0) { return true; } + return now >= last + KEYS_RELOAD_MS; +} + // ---- tests: each runs and asserts on values the decisions compute ---- @@ -1172,6 +1229,8 @@ test the_server_tables_and_the_arena_line_up_for_eight_keys_a_provider { assert G_PANEK + 16 * PK <= ST_SIZE; assert G_START < G_CFD; assert G_START > G_BINLEN; + assert G_KLOAD > G_START; + assert G_KLOAD < G_CFD; assert 16 * PF <= G_LISTEN; assert key_slot(0, 0) == O_KEYS; assert key_slot(4, 2) == O_KEYS + (4 * 8 + 2) * KEY_CAP; @@ -1562,3 +1621,75 @@ test a_restored_pane_whose_wait_runs_out_starts_keyless_and_is_respawned_once_wh assert keyless_plan(0, "-") == R_RELAUNCH; assert keyless_plan(0, "") == R_RELAUNCH; } + +test the_server_reads_the_providers_a_spawn_can_start_with { + // at its start the server reads every provider: bits 0 to 8 + assert load_all_mask() == 511; + // the claude cell starts on a profile: z.ai (4), Anthropic (0), OpenRouter (3), Ollama cloud (5) + assert load_mask_agent(0) == 57; + assert load_wants(load_mask_agent(0), P_ZAI); + assert load_wants(load_mask_agent(0), P_ANTHROPIC); + assert load_wants(load_mask_agent(0), P_OPENAI) == false; + // every other agent reads the providers keys.t27 AGENT_PROVIDERS names + assert load_mask_agent(1) == 2; + assert load_mask_agent(2) == 4; + assert load_mask_agent(3) == 11; + assert load_mask_agent(4) == 1; + assert load_mask_agent(5) == 63; + assert load_mask_agent(6) == 128; + assert load_mask_agent(7) == 15; + assert load_mask_agent(8) == 64; + // the shell and editor cells read no key; an unknown agent none either + assert load_mask_agent(SHELL_CELL) == 0; + assert load_mask_agent(AGENT_COUNT + 3) == 0; + // a provider past the last one is never wanted + assert load_wants(511, PROVIDER_COUNT - 1); + assert load_wants(511, PROVIDER_COUNT) == false; + assert load_wants(0, 0) == false; +} + +test a_spawn_that_finds_no_key_reads_the_keychain_again_after_a_minute { + var st : [512]u64 = undefined; + t_table(st[0..512]); + // nothing was read yet: due at once + assert keys_reload_due(st[0..512], 0, 5000, 0); + // read 10 s ago and still no key: not yet; due exactly KEYS_RELOAD_MS after the read + assert KEYS_RELOAD_MS == 60000; + assert keys_reload_due(st[0..512], 0, 1010000, 1000000) == false; + assert keys_reload_due(st[0..512], 0, 1059999, 1000000) == false; + assert keys_reload_due(st[0..512], 0, 1060000, 1000000); + // a shell or an editor reads no key: never due + assert keys_reload_due(st[0..512], SHELL_CELL, 1060000, 1000000) == false; + assert keys_reload_due(st[0..512], SHELL_CELL, 5000, 0) == false; + // a z.ai key is a key the claude cell starts with: nothing to look for + t_key(st[0..512], P_ZAI, 0); + assert keys_reload_due(st[0..512], 0, 1060000, 1000000) == false; + assert keys_reload_due(st[0..512], 0, 5000, 0) == false; + // codex reads OpenAI only: the z.ai key is no use to it, so a read is due for it + assert keys_reload_due(st[0..512], 1, 1060000, 1000000); + assert keys_reload_due(st[0..512], 1, 1010000, 1000000) == false; + t_key(st[0..512], P_OPENAI, 0); + assert keys_reload_due(st[0..512], 1, 1060000, 1000000) == false; +} + +test a_read_of_the_keychain_fills_the_table_like_a_window_push { + // the server's read goes through key_put exactly as OP_SETKEY does: the claude cell has a profile after it + var st : [512]u64 = undefined; + t_table(st[0..512]); + assert has_key_for(st[0..512], 0) == false; + assert restore_waits(st[0..512], 0); + key_put(st[0..512], P_ZAI, 0, true, false); + key_put(st[0..512], P_ZAI, 1, true, false); + assert has_key_for(st[0..512], 0); + assert restore_waits(st[0..512], 0) == false; + plan_pane_keys(st[0..512], 5, 0, 1000000); + assert t_prof(st[0..512], 5) == PR_ZAI; + assert pane_key_shown(st[0..512], 5, 0) == 1; + // the window pushes the same keys a moment later: nothing changes, the key just used keeps its time + key_put(st[0..512], P_ZAI, 0, true, true); + assert st[G_KLAST + key_index(P_ZAI, 0)] == 1000000; + // a key that came from a terminal later is one more slot + assert key_count(st[0..512], P_ZAI) == 2; + key_put(st[0..512], P_ZAI, 2, true, false); + assert key_count(st[0..512], P_ZAI) == 3; +} diff --git a/specs/t27os/pty_server.t27 b/specs/t27os/pty_server.t27 index d0b3d02c14..7df1bd7012 100644 --- a/specs/t27os/pty_server.t27 +++ b/specs/t27os/pty_server.t27 @@ -23,8 +23,15 @@ // pane picks its key by rotation at each start; the pane record keeps the key NUMBER, never the key. The // claude cell's screen is read for an error row (pty_logic screen_text, keys.t27 error_row and classify_line): // the failed key rests, the pane is respawned (resumed) on the next key or profile, or stays with a line in its -// scrollback. A restored pane waits for the window's keys (or 20 s) before it starts. T27OS_CTL=list prints -// "key N" for each pane. +// scrollback. A restored pane waits for a key (or 20 s) before it starts. T27OS_CTL=list prints "key N" for +// each pane. +// The server does not depend on the window for its keys: it reads the Keychain itself, through the same +// /usr/bin/security reader the window uses (keys_host kc_get_n, no SecItem call, no dialog), once at its start +// before it restores panes and again at a spawn that finds no key for its agent when the last read is older than +// a minute (pty_logic keys_reload_due, load_mask_agent). A read puts each key in the table by the code path of +// OP_SETKEY (put_key), so a pane spawned while the window is closed, minimized or never connected is logged in, +// a restarted server is not keyless, and a key added from a terminal is picked up. A window's OP_SETKEY still +// works and overrides or adds. // // Every decision is a fn with a test that runs; the host loop only moves bytes through pty_sys.t27. // @@ -154,6 +161,11 @@ use t27os::pty_logic::O_KEYS; use t27os::pty_logic::KEY_CAP; use t27os::pty_proto::OP_SETKEY; use t27os::pty_logic::G_PANEK; +use t27os::pty_logic::G_KLOAD; +use t27os::pty_logic::load_all_mask; +use t27os::pty_logic::load_mask_agent; +use t27os::pty_logic::load_wants; +use t27os::pty_logic::keys_reload_due; use t27os::pty_logic::G_START; use t27os::pty_logic::PK; use t27os::pty_logic::PK_PROF; @@ -194,6 +206,8 @@ use t27os::keys::FA_KEEP; use t27os::keys::FA_GIVE_UP; use t27os::keys::setkey_provider; use t27os::keys::setkey_number; +use t27os::keys::scan_goes_on; +use t27os::keys_host::kc_get_n; use t27os::keys::agent_uses; use t27os::keys::provider_env; use t27os::keys::env_stripped; @@ -653,6 +667,69 @@ fn scan_pane(st: []u64, mem: []u8, i: usize, n: usize) -> void { if (cls != FC_NONE) { failover(st, mem, i, cls, now); } } +/// Put key number sk (0 = key 1) of provider sp into its slot from mem[src .. src + plen]: the one path both a +/// window's OP_SETKEY frame and a read of the Keychain (load_keys) take. An empty key (plen 0) empties the slot. +/// False when the provider or number is out of range or the bytes are no key (keys.t27 key_fits); nothing +/// changes then. The table of times and rests follows the slot: the same key put again keeps its cooldown. +fn put_key(st: []u64, mem: []u8, sp: u32, sk: u32, src: usize, plen: usize) -> bool { + if (sp >= PROVIDER_COUNT or sk >= MAX_KEYS or plen > KEY_CAP - 2) { return false; } + if (plen > 0 and !key_fits(mem[src .. src + plen])) { return false; } + const ka : usize = key_slot(sp, sk); + var same : bool = plen > 0 and @as(usize, mem[ka]) == plen; + var j : usize = 0; + while (j < plen) { + if (mem[ka + 1 + j] != mem[src + j]) { same = false; } + mem[ka + 1 + j] = mem[src + j]; + j = j + 1; + } + mem[ka] = @as(u8, @intCast(plen)); + // the rest of the slot is cleared, so a shorter or deleted key leaves none of the old one behind + var wp : usize = plen + 1; + while (wp < KEY_CAP) { + mem[ka + wp] = 0; + wp = wp + 1; + } + key_put(st, sp, sk, plen > 0, same); + return true; +} + +/// Read the Keychain's keys of the providers in `mask` (pty_logic load_mask_agent, load_all_mask) into the table, +/// each by put_key as an OP_SETKEY would put it. The reader is keys_host kc_get_n, which runs /usr/bin/security +/// (never SecItem, so there is no dialog); a scan of one provider ends after two numbers in a row that hold +/// nothing (keys.t27 scan_goes_on). A key is read into the scratch at O_TMP2 + 4096, which nothing else uses, and +/// the scratch is wiped when the call ends. Sets G_KLOAD to now. Returns how many keys were put. It blocks the +/// loop for the length of the reads (about 23 ms each, a few per provider): only the start and a spawn that +/// found no key call it. +fn load_keys(st: []u64, mem: []u8, mask: u32) -> u32 { + const at : usize = O_TMP2 + 4096; + var got : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (load_wants(mask, p)) { + var k : u32 = 0; + var misses : u32 = 0; + while (scan_goes_on(k, misses)) { + const n : usize = kc_get_n(p, k, mem[at .. at + KEY_CAP]); + if (n > 0) { + misses = 0; + if (put_key(st, mem, p, k, at, n)) { got = got + 1; } + } else { + misses = misses + 1; + } + k = k + 1; + } + } + p = p + 1; + } + var w : usize = 0; + while (w < KEY_CAP) { + mem[at + w] = 0; + w = w + 1; + } + st[G_KLOAD] = now_ms(mem); + return got; +} + fn reply(fd: i32, mem: []u8, op: u8, pane: u8, plen: usize) -> void { put_header(mem[O_OUT .. O_OUT + 8], op, pane, plen); sys_write(fd, mem[O_OUT .. O_OUT + HEADER + plen], @as(u64, HEADER + plen)); @@ -711,6 +788,10 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { // the record; without one the pane learns it from its SessionStart hook or, on restore, its transcript mem[O_MODEL + @as(usize, slot) * MODEL_CAP] = 0; if (3 + cl < plen) { set_model(mem, @as(usize, slot), p0 + 4 + cl, plen - 4 - cl); } + // no key in the table for this agent (the window has not pushed one, or is not there at all): read the + // Keychain for the providers the agent reads, unless that was done in the last minute (keys_reload_due) + const spawn_agent : u32 = @as(u32, mem[p0]); + if (keys_reload_due(st, spawn_agent, now_ms(mem), st[G_KLOAD])) { load_keys(st, mem, load_mask_agent(spawn_agent)); } launch(st, mem, @as(usize, slot), R_RELAUNCH); save_pane(st, mem, @as(usize, slot)); reply(fd, mem, RE_OK, @as(u8, @intCast(slot)), 0); @@ -719,27 +800,7 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { if (op == OP_SETKEY) { // the pane byte is the provider in its low nibble and the key number (0 = key 1) in the high one // (keys.t27 setkey_byte): key 1 is the frame this op always had, an older client changes nothing - const sp : u32 = setkey_provider(pane); - const sk : u32 = setkey_number(pane); - if (sp >= PROVIDER_COUNT or sk >= MAX_KEYS or plen > KEY_CAP - 2) { reply(fd, mem, RE_ERR, 0, 0); return; } - if (plen > 0 and !key_fits(mem[p0 .. p0 + plen])) { reply(fd, mem, RE_ERR, 0, 0); return; } - const ka : usize = key_slot(sp, sk); - var same : bool = plen > 0 and @as(usize, mem[ka]) == plen; - var j : usize = 0; - while (j < plen) { - if (mem[ka + 1 + j] != mem[p0 + j]) { same = false; } - mem[ka + 1 + j] = mem[p0 + j]; - j = j + 1; - } - mem[ka] = @as(u8, @intCast(plen)); - // the rest of the slot is cleared, so a shorter or deleted key leaves none of the old one behind - var wp : usize = plen + 1; - while (wp < KEY_CAP) { - mem[ka + wp] = 0; - wp = wp + 1; - } - // the table of times and rests follows the slot; the same key sent again keeps its cooldown - key_put(st, sp, sk, plen > 0, same); + if (!put_key(st, mem, setkey_provider(pane), setkey_number(pane), p0, plen)) { reply(fd, mem, RE_ERR, 0, 0); return; } reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); return; } @@ -1086,6 +1147,14 @@ fn run_server(st: []u64, mem: []u8) -> void { say("t27os-server: cannot open the socket\n"); return; } + // the server reads the Keychain itself before it restores a pane, so a restored pane sees its key at once + // and does not wait for a window that may not be there (a number only is printed, never a key) + const loaded : u32 = load_keys(st, mem, load_all_mask()); + say("t27os-server: keys read from the Keychain: "); + var lb : [8]u8 = undefined; + const ln : usize = put_dec(lb[0..8], 0, @as(u64, loaded)); + lb[ln] = 10; + say(lb[0 .. ln + 1]); // the moment a restored pane starts its wait for the window's keys from (pty_logic RESTORE_WAIT_MS) st[G_START] = now_ms(mem); const back : u32 = restore_all(st, mem); diff --git a/specs/t27os/router.t27 b/specs/t27os/router.t27 index 846adb9eeb..7c85031918 100644 --- a/specs/t27os/router.t27 +++ b/specs/t27os/router.t27 @@ -15,7 +15,7 @@ // What t27b must offer, and what is checked (t27c gap, issue-shaped if missing): // - sockets: AF_INET + bind + listen + accept. pty_server.t27 already does AF_UNIX bind/listen/accept // through pty_sys.t27, so only the sockaddr_in layout (family, port, 127.0.0.1) is new. -// - an outbound HTTPS client with a streaming body. keys_host.t27 uses NSURLConnection synchronously, which +// - an outbound HTTPS client with a streaming body. keys_probe.t27 uses NSURLConnection synchronously, which // covers a non-streaming probe; a streamed /v1/messages reply needs NSURLSession or a raw TLS socket. // That is the real gap. Until it is closed the router cannot stream, and stage 2 stops at these decisions. // - the app.t27.ai route (the user's server-side keys, no secret on this Mac) is a second upstream of the