From 75a463fe41a68a850be24ad98cfe7d35c123db50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:00:19 +0700 Subject: [PATCH 1/4] t27os keys: every stored key in rotation, failover on overload, restored panes wait for their keys (Closes #11241) The rotation lane's four commits on the T27 OS stack (claude/t27os-keyrot-11241, head 30d60ebc5), applied onto master, whose copies of these five files equal the stack head the lane built on. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- specs/t27os/glass_window.t27 | 78 ++- specs/t27os/keys.t27 | 346 ++++++++++++- specs/t27os/keys_host.t27 | 124 +++-- specs/t27os/pty_logic.t27 | 926 ++++++++++++++++++++++++++++++++++- specs/t27os/pty_server.t27 | 265 ++++++++-- 5 files changed, 1640 insertions(+), 99 deletions(-) diff --git a/specs/t27os/glass_window.t27 b/specs/t27os/glass_window.t27 index 221fd10ff9..2f26aa3a22 100644 --- a/specs/t27os/glass_window.t27 +++ b/specs/t27os/glass_window.t27 @@ -67,6 +67,10 @@ use t27os::layout::cap_mid; use t27os::layout::same_line; use t27os::agents::AGENT_NAMES; use t27os::keys::PROVIDER_COUNT; +use t27os::keys::PROFILE_COUNT; +use t27os::keys::MAX_KEYS; +use t27os::keys::setkey_byte; +use t27os::keys::key_count_text; use t27os::keys::MAX_KEY_CHARS; use t27os::keys::KEYS_WORDS; use t27os::keys::KY_NONE; @@ -112,7 +116,8 @@ use t27os::keys::circuit_fails; use t27os::keys::status_shown; use t27os::keys_host::kc_put; use t27os::keys_host::kc_get; -use t27os::keys_host::kc_del; +use t27os::keys_host::kc_get_n; +use t27os::keys_host::kc_del_all; use t27os::keys_host::kc_has; use t27os::keys_host::probe; use t27os::keys_host::push_keys; @@ -2328,29 +2333,46 @@ fn bytes_w(kit: [48]u64, s: []const u8, n: usize, size: f64, weight: f64, mono: return w; } -/// The Keychain is read here and nowhere else in the window: which providers hold a key, and the masked -/// text of each (16 bytes a provider). The full key is wiped from the buffer before the next one is read. +/// The Keychain is read here and nowhere else in the window: how many keys each provider holds (stored[p], +/// 0 to MAX_KEYS, every numbered account is read) and the masked text of its first key (16 bytes a +/// provider). The full key is wiped from the buffer before the next one is read. fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { var key : [256]u8 = undefined; var p : u32 = 0; while (p < PROVIDER_COUNT) { - const n : usize = kc_get(p, key[0..256]); const at : usize = @as(usize, p) * 16; mlen[p] = 0; stored[p] = 0; - if (n > 0) { - stored[p] = 1; - mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); - } - var i : usize = 0; - while (i < 256) { - key[i] = 0; - i = i + 1; + var k : u32 = 0; + while (k < MAX_KEYS) { + const n : usize = kc_get_n(p, k, key[0..256]); + if (n > 0) { + if (stored[p] == 0) { mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); } + stored[p] = stored[p] + 1; + } + var i : usize = 0; + while (i < 256) { + key[i] = 0; + i = i + 1; + } + k = k + 1; } p = p + 1; } } +/// Hand the server every stored key with its number (keys_host push_keys), then take back the first key of each +/// provider whose last check failed, as the check button does: a refresh must not undo that. +fn keys_sync(fd: i32, kst: []const u32, out: []u8) -> void { + push_keys(fd); + const bad : u32 = bad_mask(kst); + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if ((bad >> p) % 2 == 1) { client_set_key(fd, setkey_byte(p, 0), "", out); } + p = p + 1; + } +} + fn keys_wipe(buf: []u8) -> void { var i : usize = 0; while (i < buf.len) { @@ -2412,11 +2434,18 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto var p : u32 = 0; while (p < PROVIDER_COUNT) { const y : f64 = ky_row_top(t, p); - const has : bool = stored[p] == 1; + const has : bool = stored[p] > 0; const st : u32 = status_shown(has, kst[p], kfail[p], kopen[p], now); cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); n = provider_name(p, w[0..48]); - draw_bytes(c, kit, w[0..48], n, x, y + 14.0, 11.0, WEIGHT_SEMIBOLD, false); + const nw : f64 = draw_bytes(c, kit, w[0..48], n, x, y + 14.0, 11.0, WEIGHT_SEMIBOLD, false); + // how many keys the provider holds ("zai 7 keys"), right after its name + n = key_count_text(stored[p], w[0..48]); + if (n > 0) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); + draw_bytes(c, kit, w[0..48], n, x + nw + 8.0, y + 14.0, 9.5, 0.0, false); + } + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); draw_dot(c, status_dot(st), x + 112.0, y + 10.0, 1.0); n = csv_field(STATUS_NAMES, st, w[0..48]); cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); @@ -2454,7 +2483,7 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto var sm : u32 = 0; var pi : u32 = 0; while (pi < PROVIDER_COUNT) { - if (stored[pi] == 1) { sm = sm + (1 << pi); } + if (stored[pi] > 0) { sm = sm + (1 << pi); } pi = pi + 1; } var wy : [96]u8 = undefined; @@ -3761,7 +3790,7 @@ pub fn main() -> void { const kh : u32 = ky_hit(mx, my, kl, QP_W, QP_PAD, WS_TOP); const kq : u32 = kh / 4; if (kh != KY_NONE) { - const kact : u32 = ky_click(kh % 4, kstored[kq] == 1, probe_has(kq), karm, kq); + const kact : u32 = ky_click(kh % 4, kstored[kq] > 0, probe_has(kq), karm, kq); if (kact == KA_EDIT) { kedit = kq; klen = 0; @@ -3771,12 +3800,19 @@ pub fn main() -> void { kedit = KY_NONE; klen = 0; } else if (kact == KA_DELETE) { - kc_del(kq); + // the row's DELETE removes every key of the provider, from the Keychain and the server + kc_del_all(kq); karm = KY_NONE; kst[kq] = ST_STORED; kfail[kq] = 0; keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); - if (tfd >= 0) { client_set_key(tfd, kq, "", tout[0..4100]); } + if (tfd >= 0) { + var kdk : u32 = 0; + while (kdk < MAX_KEYS) { + client_set_key(tfd, setkey_byte(kq, kdk), "", tout[0..4100]); + kdk = kdk + 1; + } + } } else if (kact == KA_CHECK) { kbusy = kq; kpend = 0; @@ -3893,6 +3929,8 @@ pub fn main() -> void { god = false; dp = false; keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); + // the refresh also hands the server every numbered key again (a server that restarted forgot them) + if (tfd >= 0) { keys_sync(tfd, kst[0..9], tout[0..4100]); } const khn : usize = ns_bytes(home_dir(), khome[0..256]); kemask = installed_mask(khome[0..khn]); ktpick = term_pick(term_mask()); @@ -4130,8 +4168,8 @@ pub fn main() -> void { } } // K. pre-flight (M12): once at start, check the stored key behind each claude profile, in the order - // keys.t27 PROFILE_PROVIDER gives, so the first pane already avoids a dead key - if (kbusy == KY_NONE and kpre < 2) { + // keys.t27 PROFILE_PROVIDER gives (all PROFILE_COUNT of them), so the first pane already avoids a dead key + if (kbusy == KY_NONE and kpre < PROFILE_COUNT) { const kpp : u32 = PROFILE_PROVIDER[kpre]; kpre = kpre + 1; if (probe_has(kpp) and kc_has(kpp)) { diff --git a/specs/t27os/keys.t27 b/specs/t27os/keys.t27 index 38d52134fa..d3f4f6ed75 100644 --- a/specs/t27os/keys.t27 +++ b/specs/t27os/keys.t27 @@ -5,7 +5,8 @@ ; Owner, 2026-10-19: a KEYS tab modelled on trios, every key managed by one manager written in .t27. ; Mirrors the trios SR-00 decisions (not the Swift): ModelProvider, ModelKeyRotation (least recently used ; plus cooldown), ProviderCircuitBreaker, ProviderStatusService, KeychainSecrets. -; Storage is the macOS Keychain, service "ai.t27.os", one item per provider (account = provider name). +; Storage is the macOS Keychain, service "ai.t27.os", one item per key (account = provider name for key 1, +; "#2" .. "#8" for the others, keychain_account_n). ; Never a plain file and never the repo. This spec holds no key and no syscall: the Keychain, the probe and ; the child environment are host calls (keys_host.t27, pty_server.t27); only the decisions live here, so ; they run their tests (a module with @extern imports cannot). Every test uses fake values. @@ -822,12 +823,13 @@ pub fn pick_profile(stored: u32, bad: u32) -> u32 { return next_profile(PR_NONE, stored, bad); } -/// How many keys one provider may hold, numbered from 1 (Free Claude Code: "key 1 first"). -pub const MAX_KEYS : u32 = 3; +/// How many keys one provider may hold, numbered from 1 (Free Claude Code: "key 1 first"). Eight, since the +/// owner stores seven z.ai keys and rotates over all of them (gHashTag/t27#11241). +pub const MAX_KEYS : u32 = 8; /// The Keychain item of key number k (0-based; the panel says "key k+1") of provider p. Key 1 keeps the -/// plain label of keychain_label, so a key stored before numbering stays where it is; keys 2 and 3 add -/// "#2" and "#3". 0 for an unknown provider or a number past MAX_KEYS. +/// plain label of keychain_label, so a key stored before numbering stays where it is; keys 2 to 8 add +/// "#2" to "#8". 0 for an unknown provider or a number past MAX_KEYS. pub fn keychain_label_n(p: u32, k: u32, out: []u8) -> usize { if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return 0; } var n : usize = keychain_label(p, out); @@ -836,6 +838,43 @@ pub fn keychain_label_n(p: u32, k: u32, out: []u8) -> usize { return put_dec(out, n, @as(u64, k) + 1); } +/// The Keychain account of key number k of provider p: the provider's name for key 1 and "#" for +/// the others ("zai", "zai#2", ...); the service is KEYCHAIN_SERVICE for all. 0 when p or k is out of range. +pub fn keychain_account_n(p: u32, k: u32, out: []u8) -> usize { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return 0; } + var n : usize = provider_name(p, out); + if (k == 0) { return n; } + n = put_bytes(out, n, "#"); + return put_dec(out, n, @as(u64, k) + 1); +} + +/// What a KEYS row says next to the provider's name: nothing for no key, "1 key", "7 keys". +pub fn key_count_text(n: u32, out: []u8) -> usize { + if (n == 0) { return 0; } + const c : usize = put_dec(out, 0, @as(u64, n)); + if (n == 1) { return put_bytes(out, c, " key"); } + return put_bytes(out, c, " keys"); +} + +/// A key travels to the PTY server in an OP_SETKEY frame whose pane byte names the provider; the key number +/// rides in the high nibble of that byte (k * 16 + p), so key 1 is the old frame byte for byte and an +/// older server refuses the others instead of misreading them. +pub const SETKEY_STEP : u32 = 16; + +pub fn setkey_byte(p: u32, k: u32) -> u32 { + return k * SETKEY_STEP + p; +} + +/// The provider and the key number an OP_SETKEY pane byte names; the provider is PROVIDER_COUNT or more when +/// the byte is not one the server knows (the caller refuses it), the number is MAX_KEYS or more when too big. +pub fn setkey_provider(b: u32) -> u32 { + return b % SETKEY_STEP; +} + +pub fn setkey_number(b: u32) -> u32 { + return b / SETKEY_STEP; +} + /// The bad mask from the providers' statuses (st[p]): invalid and circuit-open keys are not offered. pub fn bad_mask(st: []const u32) -> u32 { var m : u32 = 0; @@ -883,7 +922,10 @@ pub const FC_NO_BALANCE : u32 = 2; pub const FC_SUBSCRIPTION : u32 = 3; pub const FC_LOGIN_REJECTED : u32 = 4; pub const FC_OTHER : u32 = 5; -pub const FC_WORDS : str = "no error,rate limited (429),out of balance,subscription disabled,login rejected (401/403),other error"; +/// The provider shed the request: HTTP 529, z.ai code 1305 "service may be temporarily overloaded". +/// Measured 2026-10-20: on the free flash model five of seven z.ai keys answered it at once and one answered 200. +pub const FC_OVERLOADED : u32 = 6; +pub const FC_WORDS : str = "no error,rate limited (429),out of balance,subscription disabled,login rejected (401/403),other error,overloaded (529)"; /// What to do about it. pub const FA_KEEP : u32 = 0; @@ -933,18 +975,50 @@ pub fn has_code(hay: []const u8, code: []const u8) -> bool { /// The failure class of one screen line, by plain byte matching. The two phrases are specific enough alone; /// a bare status number counts only on a line that also says "error" (so "Edited 429 lines" is not a 429). -/// "Insufficient balance" wins over 429: the provider sends both for an empty account. An "API Error" line -/// that matches nothing is FC_OTHER; any other line is FC_NONE. +/// "Insufficient balance" (or its code, 1113) wins over 429: the provider sends both for an empty account. +/// An overload (529, code 1305, or the word "overloaded") wins over 429 too: z.ai sends 1305 under either +/// status, and a throttled key rests longer than a busy one. An "API Error" line that matches nothing is +/// FC_OTHER; any other line is FC_NONE. pub fn classify_line(line: []const u8) -> u32 { if (has_bytes(line, "disabled Claude subscription")) { return FC_SUBSCRIPTION; } if (has_bytes(line, "Insufficient balance")) { return FC_NO_BALANCE; } if (has_bytes(line, "rror") == false) { return FC_NONE; } + if (has_code(line, "1113")) { return FC_NO_BALANCE; } + if (has_code(line, "529") or has_code(line, "1305") or has_bytes(line, "verloaded")) { return FC_OVERLOADED; } if (has_code(line, "429")) { return FC_RATE_LIMITED; } if (has_code(line, "401") or has_code(line, "403")) { return FC_LOGIN_REJECTED; } if (has_bytes(line, "API Error")) { return FC_OTHER; } return FC_NONE; } +/// Does `hay` start with `needle`? +pub fn begins_with(hay: []const u8, needle: []const u8) -> bool { + if (needle.len > hay.len) { return false; } + var i : usize = 0; + while (i < needle.len) { + if (hay[i] != needle[i]) { return false; } + i = i + 1; + } + return true; +} + +/// Is this screen line the error row Claude Code prints, and the final one? classify_line may be run on it +/// only then, never on the body of the transcript: a diff or a quoted line that shows "Insufficient balance" +/// or "API Error: 429" starts with something else (a +, a quote, a word) and would otherwise make the pane +/// leave a healthy provider. Leading blanks are skipped. A row that says "Retrying" is Claude Code's own retry +/// in flight and the turn is still alive; the failover waits for the final row, when nothing is left to lose. +pub fn error_row(line: []const u8) -> bool { + var a : usize = 0; + while (a < line.len and line[a] == 32) { a = a + 1; } + const t : []const u8 = line[a .. line.len]; + if (has_bytes(t, "Retrying")) { return false; } + if (begins_with(t, "API Error")) { return true; } + if (begins_with(t, "Error")) { return true; } + if (begins_with(t, "Request failed")) { return true; } + if (begins_with(t, "Your organization has disabled")) { return true; } + return begins_with(t, "Insufficient balance"); +} + /// The bad mask after a failure of class `class` on profile pr: the profile's provider joins it. A line that /// is no failure (FC_NONE, FC_OTHER) or a pane with no profile changes nothing. pub fn fo_bad(class: u32, pr: u32, bad: u32) -> u32 { @@ -959,13 +1033,13 @@ pub fn fo_bad(class: u32, pr: u32, bad: u32) -> u32 { /// started without a key, which is where the "disabled Claude subscription access" line comes from). /// `stored` and `bad` are the provider masks (bad: providers that failed for this pane or their check), /// `done` the failovers this pane has had, `alt` whether another key of pr's provider is usable now -/// (rotate_pick_except over the keys, the failed one skipped). A throttled key (429) with another key at hand -/// swaps the key and keeps the profile; every other failure leaves the profile for the next one in the chain; -/// when none is left, or the cap is reached, the pane stays as it is. +/// (rotate_pick_except over the keys, the failed one skipped). A throttled (429) or overloaded (529) key with +/// another key at hand swaps the key and keeps the profile; every other failure leaves the profile for the +/// next one in the chain; when none is left, or the cap is reached, the pane stays as it is. pub fn fo_action(class: u32, pr: u32, stored: u32, bad: u32, done: u32, alt: bool) -> u32 { if (class == FC_NONE or class == FC_OTHER) { return FA_KEEP; } if (done >= MAX_FAILOVERS) { return FA_GIVE_UP; } - if (class == FC_RATE_LIMITED and alt and pr < PROFILE_COUNT) { return FA_RESPAWN_KEY; } + if ((class == FC_RATE_LIMITED or class == FC_OVERLOADED) and alt and pr < PROFILE_COUNT) { return FA_RESPAWN_KEY; } if (next_profile(pr, stored, fo_bad(class, pr, bad)) == PR_NONE) { return FA_GIVE_UP; } return FA_RESPAWN_NEXT; } @@ -1004,6 +1078,45 @@ pub fn fo_why(class: u32, pr: u32, act: u32, nxt: u32, done: u32, out: []u8) -> return put_bytes(out, n, ", no profile left in the chain, the pane stays"); } +/// How long a key rests after it caused a failure of class `class` (seconds): a throttled key 30 s, an +/// overloaded one 60 s (the service is busy, not the key, but the same key would meet the same queue), a key +/// with no balance 10 min, a rejected key an hour; a failure that names no key rests none. +pub const OVERLOAD_COOLDOWN_S : u64 = 60; +pub const BALANCE_REST_S : u64 = 600; +pub const REJECT_REST_S : u64 = 3600; + +pub fn key_rest_after(class: u32) -> u64 { + if (class == FC_RATE_LIMITED) { return RATE_COOLDOWN_S; } + if (class == FC_OVERLOADED) { return OVERLOAD_COOLDOWN_S; } + if (class == FC_NO_BALANCE) { return BALANCE_REST_S; } + if (class == FC_LOGIN_REJECTED) { return REJECT_REST_S; } + return 0; +} + +/// The rest_until of a key slot that holds no key: no clock reaches it, so rotation never picks it. +pub const KEY_NEVER : u64 = 99999999999; + +/// The key a new pane starts with: rotate_pick_except, and when every key is resting, the one whose rest ends +/// first (a pane with a resting key is better than a pane with none). Slots that hold no key carry KEY_NEVER +/// and are never taken. Returns n when the provider holds no usable key but the skipped one. +pub fn rotate_pick_soft(n: u32, skip: u32, last_used: []const u64, rest_until: []const u64, now: u64) -> u32 { + const k : u32 = rotate_pick_except(n, skip, last_used, rest_until, now); + if (k != n) { return k; } + var best : u32 = n; + var i : u32 = 0; + while (i < n) { + if (rest_until[i] < KEY_NEVER and i != skip) { + if (best == n) { + best = i; + } else if (rest_until[i] < rest_until[best]) { + best = i; + } + } + i = i + 1; + } + return best; +} + /// z.ai has no cheap GET that proves a key opens the Anthropic-protocol route, so its check is one /// one-token message to the flash model (free); the answer is read by status only. pub fn probe_post(p: u32) -> bool { @@ -1243,13 +1356,220 @@ test a_screen_line_is_sorted_into_a_failure_class { assert classify_line("Your organization has disabled Claude subscription access") == FC_SUBSCRIPTION; assert classify_line("API Error: 401 invalid credentials") == FC_LOGIN_REJECTED; assert classify_line("Request failed with error 403 forbidden") == FC_LOGIN_REJECTED; - assert classify_line("API Error: 500 upstream overloaded") == FC_OTHER; + assert classify_line("API Error: 500 upstream failure") == FC_OTHER; assert classify_line("Edited 429 lines in 3 files") == FC_NONE; assert classify_line("error: read 14290 tokens") == FC_NONE; assert classify_line("Reading specs/t27os/keys.t27") == FC_NONE; assert classify_line("") == FC_NONE; } +test an_overload_is_its_own_class_and_the_balance_code_still_marks_the_key_bad { + // what z.ai answered on the free flash model, 2026-10-20 (code 1305, HTTP 529 or 429) + assert classify_line("API Error: 529 {\"error\":{\"code\":\"1305\",\"message\":\"The service may be temporarily overloaded\"}}") == FC_OVERLOADED; + assert classify_line("API Error: 429 {\"error\":{\"code\":\"1305\",\"message\":\"overloaded, retry\"}}") == FC_OVERLOADED; + assert classify_line("API Error (529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}})") == FC_OVERLOADED; + assert classify_line("Request failed with error 1305") == FC_OVERLOADED; + assert classify_line("error 529") == FC_OVERLOADED; + // the paid models answered 1113: still out of balance, even next to a 429 or the word overloaded + assert classify_line("API Error: 429 {\"error\":{\"code\":\"1113\",\"message\":\"Insufficient balance\"}}") == FC_NO_BALANCE; + assert classify_line("API Error: 429 {\"error\":{\"code\":\"1113\"") == FC_NO_BALANCE; + assert classify_line("API Error: 429 code 1113 overloaded") == FC_NO_BALANCE; + // a number glued to digits, or a line that is no error, is no overload + assert classify_line("error 5290") == FC_NONE; + assert classify_line("error 13050") == FC_NONE; + assert classify_line("Edited 529 lines in 3 files") == FC_NONE; + assert classify_line("the service was overloaded earlier") == FC_NONE; + assert classify_line("API Error: 429 rate limit reached") == FC_RATE_LIMITED; +} + +test only_an_error_row_is_ever_classified_never_the_body_of_the_transcript { + assert error_row("API Error: 529 {\"error\":{\"code\":\"1305\"}}"); + assert error_row(" API Error: 429 Insufficient balance"); + assert error_row("Error: 401 invalid credentials"); + assert error_row("Request failed with error 403"); + assert error_row("Your organization has disabled Claude subscription access"); + assert error_row("Insufficient balance or no resource package"); + // a diff or a quoted line that merely shows the phrases + assert error_row("+ assert classify_line(\"API Error: 429 Insufficient balance\") == FC_NO_BALANCE;") == false; + assert error_row("The log said API Error: 529 yesterday") == false; + assert error_row(" - Error handling for 429 added") == false; + assert error_row("Reading specs/t27os/keys.t27") == false; + assert error_row("") == false; + assert error_row(" ") == false; + // Claude Code's own retry in flight: the turn is alive, the failover waits + assert error_row("API Error (529 overloaded_error) Retrying in 4 seconds (attempt 2/10)") == false; + assert begins_with("abc", "ab"); + assert begins_with("abc", "abcd") == false; + assert begins_with("abc", ""); + assert begins_with("", "a") == false; +} + +test an_overloaded_key_with_a_spare_swaps_the_key_and_keeps_the_profile { + assert fo_action(FC_OVERLOADED, PR_ZAI, 57, 0, 0, true) == FA_RESPAWN_KEY; + assert fo_target(FC_OVERLOADED, PR_ZAI, 57, 0, 0, true) == PR_ZAI; + // no other key usable now: the pane leaves the profile, as for a throttled key with no spare + assert fo_action(FC_OVERLOADED, PR_ZAI, 57, 0, 0, false) == FA_RESPAWN_NEXT; + assert fo_target(FC_OVERLOADED, PR_ZAI, 57, 0, 0, false) == PR_ANTHROPIC; + assert fo_action(FC_OVERLOADED, PR_ZAI, 16, 0, 0, false) == FA_GIVE_UP; + assert fo_action(FC_OVERLOADED, PR_NONE, 57, 0, 0, true) == FA_RESPAWN_NEXT; + assert fo_action(FC_OVERLOADED, PR_ZAI, 57, 0, MAX_FAILOVERS, true) == FA_GIVE_UP; + var done : u32 = 0; + while (done < 40 and fo_action(FC_OVERLOADED, PR_ZAI, 57, 0, done, true) == FA_RESPAWN_KEY) { + done = done + 1; + } + assert done == MAX_FAILOVERS; +} + +test the_overload_why_line_names_the_class_and_never_a_key { + var o : [128]u8 = undefined; + const n : usize = fo_why(FC_OVERLOADED, PR_ZAI, FA_RESPAWN_KEY, PR_ZAI, 1, o[0..128]); + assert has_bytes(o[0..n], "claude: zai overloaded (529), same profile with the next key"); + const m : usize = fo_why(FC_OVERLOADED, PR_ZAI, FA_RESPAWN_NEXT, PR_ANTHROPIC, 1, o[0..128]); + assert has_bytes(o[0..m], "zai overloaded (529), respawn on anthropic-api"); + assert csv_fields(FC_WORDS) == FC_OVERLOADED + 1; +} + +test a_failed_key_rests_by_the_kind_of_failure { + assert key_rest_after(FC_RATE_LIMITED) == RATE_COOLDOWN_S; + assert key_rest_after(FC_OVERLOADED) == OVERLOAD_COOLDOWN_S; + assert key_rest_after(FC_NO_BALANCE) == BALANCE_REST_S; + assert key_rest_after(FC_LOGIN_REJECTED) == REJECT_REST_S; + assert key_rest_after(FC_NONE) == 0; + assert key_rest_after(FC_OTHER) == 0; + assert key_rest_after(FC_SUBSCRIPTION) == 0; + assert key_rest_after(FC_OVERLOADED) > key_rest_after(FC_RATE_LIMITED); + assert key_rest_after(FC_NO_BALANCE) > key_rest_after(FC_OVERLOADED); + assert key_rest_after(FC_LOGIN_REJECTED) > key_rest_after(FC_NO_BALANCE); +} + +test a_pane_takes_a_resting_key_only_when_no_key_is_free { + var used : [4]u64 = [50, 10, 30, 0]; + var rest : [4]u64 = [0, 0, 0, KEY_NEVER]; + // slot 3 holds no key: never taken; the key used longest ago wins + assert rotate_pick_soft(4, 4, used[0..4], rest[0..4], 100) == 1; + assert rotate_pick_soft(4, 1, used[0..4], rest[0..4], 100) == 2; + // every present key rests: the one that is free first + rest[0] = 300; + rest[1] = 200; + rest[2] = 250; + assert rotate_pick_except(4, 4, used[0..4], rest[0..4], 100) == 4; + assert rotate_pick_soft(4, 4, used[0..4], rest[0..4], 100) == 1; + assert rotate_pick_soft(4, 1, used[0..4], rest[0..4], 100) == 2; + // only the skipped key is left: none + rest[0] = KEY_NEVER; + rest[2] = KEY_NEVER; + assert rotate_pick_soft(4, 1, used[0..4], rest[0..4], 100) == 4; + assert rotate_pick_soft(4, 4, used[0..4], rest[0..4], 100) == 1; + rest[1] = KEY_NEVER; + assert rotate_pick_soft(4, 4, used[0..4], rest[0..4], 100) == 4; +} + +test eight_keys_rotate_so_each_is_used_once_before_any_is_used_twice { + var used : [8]u64 = [0, 0, 0, 0, 0, 0, 0, 0]; + var rest : [8]u64 = [0, 0, 0, 0, 0, 0, 0, KEY_NEVER]; + var seen : u32 = 0; + var t : u64 = 1; + var round : u32 = 0; + while (round < 7) { + const k : u32 = rotate_pick(8, used[0..8], rest[0..8], t); + assert k < 7; + assert (seen >> k) % 2 == 0; + seen = seen + (1 << k); + used[k] = t; + t = t + 1; + round = round + 1; + } + assert seen == 127; + assert rotate_pick(8, used[0..8], rest[0..8], t) == 0; +} + +test the_account_of_key_k_is_the_provider_name_with_a_number { + var a : [32]u8 = undefined; + assert keychain_account_n(P_ZAI, 0, a[0..32]) == 3; + assert a[0] == 122 and a[2] == 105; + assert keychain_account_n(P_ZAI, 1, a[0..32]) == 5; + assert a[3] == 35 and a[4] == 50; + assert keychain_account_n(P_ZAI, 6, a[0..32]) == 5; + assert a[3] == 35 and a[4] == 55; + assert keychain_account_n(P_ANTHROPIC, 7, a[0..32]) == 11; + assert a[9] == 35 and a[10] == 56; + assert keychain_account_n(P_ZAI, MAX_KEYS, a[0..32]) == 0; + assert keychain_account_n(PROVIDER_COUNT, 0, a[0..32]) == 0; + assert MAX_KEYS == 8; + var l : [48]u8 = undefined; + assert keychain_label_n(P_ZAI, 7, l[0..48]) == 15; + assert l[13] == 35 and l[14] == 56; +} + +test each_agent_cli_gets_the_variable_of_every_provider_it_reads_and_no_other { + // The pane's environment is built from AGENT_PROVIDERS and PROVIDER_ENVS only (pty_server inject_keys), one + // variable per provider the cell reads, carrying the key the rotation picked. These are the pairs. + var o : [40]u8 = undefined; + var n : usize = provider_env(P_OPENAI, o[0..40]); + assert agent_uses(1, P_OPENAI) and has_bytes(o[0..n], "OPENAI_API_KEY") and n == 14; + n = provider_env(P_GEMINI, o[0..40]); + assert agent_uses(2, P_GEMINI) and has_bytes(o[0..n], "GEMINI_API_KEY") and n == 14; + n = provider_env(P_ZAI, o[0..40]); + assert agent_uses(5, P_ZAI) and has_bytes(o[0..n], "ZAI_API_KEY") and n == 11; + n = provider_env(P_CURSOR, o[0..40]); + assert agent_uses(6, P_CURSOR) and has_bytes(o[0..n], "CURSOR_API_KEY") and n == 14; + n = provider_env(P_GITHUB, o[0..40]); + assert agent_uses(8, P_GITHUB) and has_bytes(o[0..n], "GITHUB_TOKEN") and n == 12; + n = provider_env(P_OPENROUTER, o[0..40]); + assert agent_uses(3, P_OPENROUTER) and agent_uses(7, P_OPENROUTER) and has_bytes(o[0..n], "OPENROUTER_API_KEY"); + // a cell never gets a variable of a provider it does not read + assert agent_uses(1, P_ZAI) == false; + assert agent_uses(2, P_OPENAI) == false; + assert agent_uses(8, P_ANTHROPIC) == false; + assert agent_uses(6, P_GITHUB) == false; + // Claude Code is the one cell with profile variables, and its z.ai profile reads the z.ai provider's keys + assert profile_agent(0) and profile_agent(5) == false; + assert PROFILE_PROVIDER[PR_ZAI] == P_ZAI; + // every provider a cell reads has a variable name + var a : u32 = 0; + while (a < AGENT_COUNT) { + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (agent_uses(a, p)) { assert provider_env(p, o[0..40]) > 0; } + p = p + 1; + } + a = a + 1; + } +} + +test a_keys_row_says_how_many_keys_the_provider_holds { + var o : [24]u8 = undefined; + assert key_count_text(0, o[0..24]) == 0; + assert key_count_text(1, o[0..24]) == 5; + assert o[0] == 49 and o[2] == 107 and o[4] == 121; + assert key_count_text(7, o[0..24]) == 6; + assert o[0] == 55 and o[5] == 115; + assert key_count_text(MAX_KEYS, o[0..24]) == 6; + assert o[0] == 56; +} + +test the_key_number_rides_in_the_high_nibble_of_the_setkey_byte { + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + var k : u32 = 0; + while (k < MAX_KEYS) { + const b : u32 = setkey_byte(p, k); + assert b < 256; + assert setkey_provider(b) == p; + assert setkey_number(b) == k; + k = k + 1; + } + p = p + 1; + } + // key 1 is the old frame byte + assert setkey_byte(P_ZAI, 0) == P_ZAI; + assert setkey_number(P_GONKA) == 0; + // a byte from a newer client that this table cannot hold reads as unknown + assert setkey_provider(setkey_byte(11, 0)) >= PROVIDER_COUNT; + assert PROVIDER_COUNT <= SETKEY_STEP; + assert MAX_KEYS * SETKEY_STEP <= 256; +} + test a_failure_marks_the_provider_bad_for_this_pane { assert fo_bad(FC_NO_BALANCE, PR_ZAI, 0) == 16; assert fo_bad(FC_LOGIN_REJECTED, PR_ANTHROPIC, 16) == 17; diff --git a/specs/t27os/keys_host.t27 b/specs/t27os/keys_host.t27 index d7923ee5fc..0f90dec80f 100644 --- a/specs/t27os/keys_host.t27 +++ b/specs/t27os/keys_host.t27 @@ -1,12 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // T27 OS keys_host: where the key manager (keys.t27) touches the machine. Three jobs, no decisions: -// - the macOS Keychain: kc_put / kc_get / kc_del, one generic-password item per provider under the -// service "ai.t27.os" (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete). A key is never -// written to a file and never reaches the repo. +// - the macOS Keychain: kc_put_n / kc_get_n / kc_del_n, one generic-password item per key (up to MAX_KEYS a +// provider; account = provider name, then "#2" .. "#8") under the service "ai.t27.os" +// (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete); kc_put / kc_get / kc_del are key 1. +// A key is never written to a file 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 to the PTY server (OP_SETKEY) so a pane it spawns is logged in. +// - 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. // // The kSec* constants are global CFStringRef variables, not functions, so an @extern call cannot read them: // dlsym finds the variable's address and sys_peek copies the pointer out. Specs holding @extern cannot run @@ -16,9 +18,11 @@ 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::provider_name; +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; @@ -107,13 +111,14 @@ fn set_text(d: u64, key_name: []const u8, text: []const u8) -> void { cf_release(s); } -/// The query that names provider p's item: generic password, service "ai.t27.os", account = provider name. -fn item_query(p: u32) -> u64 { +/// The query that names key k of provider p (k = 0 is key 1): generic password, service "ai.t27.os", account +/// = the provider name, then "#2" .. "#8" for the others (keys.t27 keychain_account_n). +fn item_query(p: u32, k: u32) -> u64 { const d : u64 = new_dict(); cf_dict_set(d, global_ref("kSecClass"), global_ref("kSecClassGenericPassword")); set_text(d, "kSecAttrService", KEYCHAIN_SERVICE); - var nm : [24]u8 = undefined; - const n : usize = provider_name(p, nm[0..24]); + var nm : [32]u8 = undefined; + const n : usize = keychain_account_n(p, k, nm[0..32]); set_text(d, "kSecAttrAccount", nm[0..n]); return d; } @@ -124,16 +129,16 @@ fn set_data(d: u64, key: []const u8) -> void { cf_release(data); } -/// Store provider p's key (replacing an earlier one). True when the Keychain accepted it. -pub fn kc_put(p: u32, key: []const u8) -> bool { - if (p >= PROVIDER_COUNT) { return false; } +/// Store key k (0-based) of provider p, replacing an earlier one. True when the Keychain accepted it. +pub fn kc_put_n(p: u32, k: u32, key: []const u8) -> bool { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return false; } if (key.len == 0) { return false; } - const a : u64 = item_query(p); + const a : u64 = item_query(p, k); set_data(a, key); var st : i32 = sec_add(a, 0); cf_release(a); if (st == SEC_DUPLICATE) { - const q : u64 = item_query(p); + const q : u64 = item_query(p, k); const u : u64 = new_dict(); set_data(u, key); st = sec_update(q, u); @@ -143,10 +148,15 @@ pub fn kc_put(p: u32, key: []const u8) -> bool { return st == 0; } -/// Read provider p's key into out; its length, 0 when none is stored. -pub fn kc_get(p: u32, out: []u8) -> usize { - if (p >= PROVIDER_COUNT) { return 0; } - const q : u64 = item_query(p); +/// Store provider p's first key. +pub fn kc_put(p: u32, key: []const u8) -> bool { + return kc_put_n(p, 0, key); +} + +/// Read key k (0-based) of provider p into out; its length, 0 when none is stored. +pub fn kc_get_n(p: u32, k: u32, out: []u8) -> usize { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return 0; } + const q : u64 = item_query(p, k); cf_dict_set(q, global_ref("kSecReturnData"), global_ref("kCFBooleanTrue")); cf_dict_set(q, global_ref("kSecMatchLimit"), global_ref("kSecMatchLimitOne")); var res : [8]u8 = undefined; @@ -162,19 +172,63 @@ pub fn kc_get(p: u32, out: []u8) -> usize { return @as(usize, n); } -/// Delete provider p's item. True when it is gone (also when none was there). -pub fn kc_del(p: u32) -> bool { - if (p >= PROVIDER_COUNT) { return false; } - const q : u64 = item_query(p); +/// Read provider p's first key into out. +pub fn kc_get(p: u32, out: []u8) -> usize { + return kc_get_n(p, 0, out); +} + +/// Delete key k (0-based) of provider p. True when it is gone (also when none was there). +pub fn kc_del_n(p: u32, k: u32) -> bool { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return false; } + const q : u64 = item_query(p, k); const st : i32 = sec_delete(q); cf_release(q); return st == 0 or st == -25300; } -/// True when the Keychain holds a key for provider p (reads it, then drops the copy). +/// Delete provider p's first key. +pub fn kc_del(p: u32) -> bool { + return kc_del_n(p, 0); +} + +/// Delete every key of provider p (a KEYS row's DELETE removes the provider's whole set). True when all are gone. +pub fn kc_del_all(p: u32) -> bool { + var ok : bool = true; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (kc_del_n(p, k) == false) { ok = false; } + k = k + 1; + } + return ok; +} + +/// True when the Keychain holds a first key for provider p (reads it, then drops the copy). pub fn kc_has(p: u32) -> bool { var b : [256]u8 = undefined; - return kc_get(p, b[0..256]) > 0; + const n : usize = kc_get(p, b[0..256]); + var i : usize = 0; + while (i < 256) { + b[i] = 0; + i = i + 1; + } + return n > 0; +} + +/// How many of the provider's MAX_KEYS accounts hold a key (a gap in the numbers does not stop the count). +pub fn kc_count(p: u32) -> u32 { + var b : [256]u8 = undefined; + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (kc_get_n(p, k, b[0..256]) > 0) { c = c + 1; } + k = k + 1; + } + var i : usize = 0; + while (i < 256) { + b[i] = 0; + i = i + 1; + } + return c; } fn add_header(req: u64, name: []const u8, value: []const u8) -> void { @@ -230,20 +284,32 @@ pub fn probe(p: u32, key: []const u8) -> u32 { return @as(u32, @intCast(msg0(r, sel("statusCode")))); } -/// Hand every stored key to the PTY server on `fd`; the count pushed. Called on connect and before a spawn, -/// because a server that restarted has forgotten them. +/// Hand every stored key to the PTY server on `fd`, each with its number; the count pushed. Every numbered +/// account of every provider is read, up to MAX_KEYS, and the number rides in the OP_SETKEY pane byte +/// (keys.t27 setkey_byte), so the server holds all of them and rotates over them. Called on connect, when +/// the KEYS tab opens and before a spawn, because a server that restarted has forgotten them. The copy of +/// each key in this stack frame is wiped when the call ends. pub fn push_keys(fd: i32) -> u32 { var key : [256]u8 = undefined; var reply : [MAX_FRAME]u8 = undefined; var pushed : u32 = 0; var p : u32 = 0; while (p < PROVIDER_COUNT) { - const n : usize = kc_get(p, key[0..256]); - if (n > 0) { - if (client_set_key(fd, p, key[0..n], reply[0..MAX_FRAME]) > 0) { pushed = pushed + 1; } + var k : u32 = 0; + while (k < MAX_KEYS) { + const n : usize = kc_get_n(p, k, key[0..256]); + if (n > 0) { + if (client_set_key(fd, setkey_byte(p, k), key[0..n], reply[0..MAX_FRAME]) > 0) { pushed = pushed + 1; } + } + k = k + 1; } p = p + 1; } + var i : usize = 0; + while (i < 256) { + key[i] = 0; + i = i + 1; + } return pushed; } diff --git a/specs/t27os/pty_logic.t27 b/specs/t27os/pty_logic.t27 index c3523485e7..8e8ca12b42 100644 --- a/specs/t27os/pty_logic.t27 +++ b/specs/t27os/pty_logic.t27 @@ -88,6 +88,45 @@ use t27os::pty_proto::ring_copy; use t27os::pty_proto::put_list_entry; use t27os::pty_proto::spawn_ok; use t27os::pty_proto::socket_path; +use t27os::keys::PROVIDER_COUNT; +use t27os::keys::MAX_KEYS; +use t27os::keys::KEY_NEVER; +use t27os::keys::PROFILE_COUNT; +use t27os::keys::PROFILE_PROVIDER; +use t27os::keys::PR_NONE; +use t27os::keys::PR_ZAI; +use t27os::keys::PR_ANTHROPIC; +use t27os::keys::MAX_FAILOVERS; +use t27os::keys::agent_uses; +use t27os::keys::spawn_mask; +use t27os::keys::P_ZAI; +use t27os::keys::P_ANTHROPIC; +use t27os::keys::P_OPENAI; +use t27os::keys::FC_OTHER; +use t27os::keys::FC_LOGIN_REJECTED; +use t27os::keys::profile_agent; +use t27os::keys::pick_profile; +use t27os::keys::rotate_pick_soft; +use t27os::keys::rotate_pick_except; +use t27os::keys::key_rest_after; +use t27os::keys::fo_action; +use t27os::keys::fo_target; +use t27os::keys::fo_bad; +use t27os::keys::fo_why; +use t27os::keys::classify_line; +use t27os::keys::error_row; +use t27os::keys::has_bytes; +use t27os::keys::FC_NONE; +use t27os::keys::FC_RATE_LIMITED; +use t27os::keys::FC_NO_BALANCE; +use t27os::keys::FC_SUBSCRIPTION; +use t27os::keys::FC_OVERLOADED; +use t27os::keys::FA_KEEP; +use t27os::keys::FA_RESPAWN_NEXT; +use t27os::keys::FA_RESPAWN_KEY; +use t27os::keys::FA_GIVE_UP; +use t27os::keys::setkey_provider; +use t27os::keys::setkey_number; use t27os::editors::TERM_COUNT; use t27os::editors::TERM_NONE; use t27os::editors::TERM_PATH_MAX; @@ -125,7 +164,42 @@ pub const G_CFD : usize = 232; pub const G_CLEN : usize = 240; /// Each pane's last RESIZE, two panes per word (pty_proto ws_pack); 8 words cover MAX_PANES. pub const G_WINSZ : usize = 248; -pub const ST_SIZE : usize = 256; +/// The table grew from 256 to 512 words for the key rotation (gHashTag/t27#11241): per key slot of every +/// provider (PROVIDER_COUNT * MAX_KEYS = 72) when it was last used (ms) and until when it rests (s, KEY_NEVER +/// for a slot that holds no key), then PK words per pane: the key numbers the pane holds, its profile, its bad +/// provider mask, its failover count, the key to skip at the next launch, the time until which its screen +/// 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; +pub const G_KLAST : usize = 256; +pub const G_KREST : usize = 328; +pub const G_PANEK : usize = 400; +pub const KEY_SLOTS : usize = 72; +pub const PK : usize = 7; +pub const PK_KEYS : usize = 0; +pub const PK_PROF : usize = 1; +pub const PK_BAD : usize = 2; +pub const PK_DONE : usize = 3; +pub const PK_SKIP : usize = 4; +pub const PK_STOP : usize = 5; +pub const PK_WAIT : usize = 6; +/// PK_PROF of a pane that has not chosen a profile yet. +pub const PK_UNSET : u64 = 254; +/// PK_WAIT: 0 nothing; 1 + plan a restored pane waiting for a key of its provider (its launch is deferred); +/// W_KEYLESS a restored pane that started with no key (its one respawn is due when a key arrives); W_DONE +/// that respawn was made. +pub const W_NONE : u64 = 0; +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 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; +/// After a give-up the pane's screen is read again only after this long, so one error row on screen is not +/// answered with a line in the scrollback at every redraw. +pub const GIVEUP_QUIET_MS : u64 = 60000; +pub const ST_SIZE : usize = 512; pub const MAX_CONNS : usize = 8; pub const SID_CAP : usize = 64; @@ -145,9 +219,17 @@ pub const O_BIN : usize = 92400; pub const MODEL_CAP : usize = 64; pub const O_MODEL : usize = 97280; pub const O_RING : usize = 98304; +/// The key table: KEY_SLOTS slots of KEY_CAP bytes (a length byte, the key, a NUL), slot (p, k) at +/// O_KEYS + (p * MAX_KEYS + k) * KEY_CAP (key_slot). Then each pane's unfinished screen line (a length byte +/// and up to CARRY_CAP bytes, CARRY_SLOT apart) and the scratch where a chunk's plain text is built. pub const O_KEYS : usize = 1146880; pub const KEY_CAP : usize = 256; -pub const MEM_SIZE : usize = 1149184; +pub const O_CARRY : usize = 1165312; +pub const CARRY_CAP : usize = 128; +pub const CARRY_SLOT : usize = 132; +pub const O_SCAN : usize = 1167424; +pub const SCAN_CAP : usize = 4232; +pub const MEM_SIZE : usize = 1171712; pub const TICK_MS : i32 = 500; pub const SIGHUP_RING : u64 = 65536; @@ -357,6 +439,419 @@ pub fn c_path(out: []u8, src: []const u8) -> usize { return n; } +// ---- key rotation (gHashTag/t27#11241): the server's key table, the keys a pane holds, the failover ---- +// The server holds up to MAX_KEYS keys per provider in memory (never on disk) with when each was last used +// and until when it rests. A pane spawn picks its key with rotate_pick_soft (the key used longest ago that is +// not resting; when all rest, the one whose rest ends first). The pane keeps the key NUMBER, never the key. +// When the pane's screen shows a failure the key rests and the pane is respawned by keys.t27 fo_action, on the +// next key of the same provider (rotate_pick_except, the failed key skipped) or on the next profile. + +fn kidx(p: u32, k: u32) -> usize { + return @as(usize, p) * @as(usize, MAX_KEYS) + @as(usize, k); +} + +fn pow16(n: u32) -> u64 { + var m : u64 = 1; + var j : u32 = 0; + while (j < n) { + m = m * 16; + j = j + 1; + } + return m; +} + +/// The offset in the arena of key slot (p, k): a length byte, the key, a NUL (OP_SETKEY fills it). +pub fn key_slot(p: u32, k: u32) -> usize { + return O_KEYS + kidx(p, k) * KEY_CAP; +} + +/// A pane that has chosen nothing: no keys, no profile, no failures, nothing to skip, nothing waiting. +pub fn pane_keys_reset(st: []u64, i: usize) -> void { + const pk : usize = G_PANEK + i * PK; + st[pk + PK_KEYS] = 0; + st[pk + PK_PROF] = PK_UNSET; + st[pk + PK_BAD] = 0; + st[pk + PK_DONE] = 0; + st[pk + PK_SKIP] = 0; + st[pk + PK_STOP] = 0; + st[pk + PK_WAIT] = W_NONE; +} + +/// The empty table: every slot rests until KEY_NEVER (holds no key), every pane is reset. +pub fn keys_init(st: []u64) -> void { + var s : usize = 0; + while (s < KEY_SLOTS) { + st[G_KLAST + s] = 0; + st[G_KREST + s] = KEY_NEVER; + s = s + 1; + } + var i : usize = 0; + while (i < 16) { + pane_keys_reset(st, i); + i = i + 1; + } +} + +pub fn key_present(st: []const u64, p: u32, k: u32) -> bool { + return st[G_KREST + kidx(p, k)] < KEY_NEVER; +} + +/// How many slots of provider p hold a key. +pub fn key_count(st: []const u64, p: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (key_present(st, p, k)) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// The providers that hold at least one key, as a bit mask. +pub fn keys_stored(st: []const u64) -> u32 { + var m : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (key_count(st, p) > 0) { m = m + (1 << p); } + p = p + 1; + } + return m; +} + +/// OP_SETKEY filled (present) or emptied slot (p, k). `same`: the bytes are those already held, so the key keeps +/// its last-use and rest times (a window that sends every key again must not end a cooldown). +pub fn key_put(st: []u64, p: u32, k: u32, present: bool, same: bool) -> void { + const ix : usize = kidx(p, k); + if (present == false) { + st[G_KLAST + ix] = 0; + st[G_KREST + ix] = KEY_NEVER; + return; + } + if (same and st[G_KREST + ix] < KEY_NEVER) { return; } + st[G_KLAST + ix] = 0; + st[G_KREST + ix] = 0; +} + +/// The key of provider p a new pane takes (MAX_KEYS when it holds none); `skip` is a key not to take. +pub fn key_pick(st: []const u64, p: u32, skip: u32, now_ms: u64) -> u32 { + const at : usize = kidx(p, 0); + return rotate_pick_soft(MAX_KEYS, skip, st[G_KLAST + at .. G_KLAST + at + 8], st[G_KREST + at .. G_KREST + at + 8], now_ms / 1000); +} + +/// A key of provider p that is usable right now and is not `skip` (MAX_KEYS when none): the test for a swap. +pub fn key_pick_free(st: []const u64, p: u32, skip: u32, now_ms: u64) -> u32 { + const at : usize = kidx(p, 0); + return rotate_pick_except(MAX_KEYS, skip, st[G_KLAST + at .. G_KLAST + at + 8], st[G_KREST + at .. G_KREST + at + 8], now_ms / 1000); +} + +pub fn key_used(st: []u64, p: u32, k: u32, now_ms: u64) -> void { + st[G_KLAST + kidx(p, k)] = now_ms; +} + +/// Key (p, k) rests until `until_s`; a longer rest it already has stays, and a slot that holds no key stays empty. +pub fn key_rest(st: []u64, p: u32, k: u32, until_s: u64) -> void { + const ix : usize = G_KREST + kidx(p, k); + if (st[ix] >= KEY_NEVER) { return; } + if (until_s > st[ix]) { st[ix] = until_s; } +} + +/// The key number + 1 pane i holds for provider p (0 = none): one nibble per provider. +pub fn pane_key_get(st: []const u64, i: usize, p: u32) -> u32 { + return @as(u32, @intCast(st[G_PANEK + i * PK + PK_KEYS] / pow16(p) % 16)); +} + +pub fn pane_key_set(st: []u64, i: usize, p: u32, v: u32) -> void { + const at : usize = G_PANEK + i * PK + PK_KEYS; + const m : u64 = pow16(p); + st[at] = st[at] - @as(u64, pane_key_get(st, i, p)) * m + @as(u64, v) * m; +} + +/// The key number + 1 a LIST shows for pane i (0 = none): for the claude cell the key of its profile's provider, +/// for every other agent the first provider it reads that it holds a key for. +pub fn pane_key_shown(st: []const u64, i: usize, a: u32) -> u32 { + if (profile_agent(a)) { + const pr : u64 = st[G_PANEK + i * PK + PK_PROF]; + if (pr >= @as(u64, PROFILE_COUNT)) { return 0; } + return pane_key_get(st, i, PROFILE_PROVIDER[@as(u32, @intCast(pr))]); + } + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + const v : u32 = pane_key_get(st, i, p); + if (v > 0) { return v; } + p = p + 1; + } + return 0; +} + +/// The flags byte of a LIST entry with the pane's key number + 1 in its high nibble (0 to 8). Bits 0 to 2 are +/// list_flags as before, so a client that reads only those is not disturbed. +pub fn list_flags_key(live: bool, w: u32, key1: u32) -> u32 { + return list_flags(live, w) + 16 * key1; +} + +/// Choose the keys of pane i about to launch (agent a, `now_ms`): fills the pane's key numbers and profile +/// from the table, marks each picked key used, clears the skip. The claude cell takes the profile the pane +/// asked for (a failover set it) or pick_profile over the providers that hold a key and have not failed for +/// this pane, then the key of that provider by rotation, skipping the key that just failed. Every other agent +/// takes a key of each provider it reads, by rotation. Nothing here holds a key value. +pub fn plan_pane_keys(st: []u64, i: usize, a: u32, now_ms: u64) -> void { + const pk : usize = G_PANEK + i * PK; + st[pk + PK_KEYS] = 0; + st[pk + PK_STOP] = 0; + if (profile_agent(a)) { + var pr : u32 = PR_NONE; + if (st[pk + PK_PROF] < @as(u64, PROFILE_COUNT)) { + pr = @as(u32, @intCast(st[pk + PK_PROF])); + } else { + pr = pick_profile(keys_stored(st), @as(u32, @intCast(st[pk + PK_BAD]))); + } + var skip : u32 = MAX_KEYS; + if (st[pk + PK_SKIP] > 0) { skip = @as(u32, @intCast(st[pk + PK_SKIP])) - 1; } + if (pr < PROFILE_COUNT) { + const prov : u32 = PROFILE_PROVIDER[pr]; + const k : u32 = key_pick(st, prov, skip, now_ms); + if (k < MAX_KEYS) { + key_used(st, prov, k, now_ms); + pane_key_set(st, i, prov, k + 1); + } else { + pr = PR_NONE; + } + } + st[pk + PK_PROF] = @as(u64, pr); + st[pk + PK_SKIP] = 0; + return; + } + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (agent_uses(a, p) and key_count(st, p) > 0) { + const k : u32 = key_pick(st, p, MAX_KEYS, now_ms); + if (k < MAX_KEYS) { + key_used(st, p, k, now_ms); + pane_key_set(st, i, p, k + 1); + } + } + p = p + 1; + } +} + +/// The failover for pane i whose screen showed failure `class` at `now_ms`. The key the pane used rests by the +/// kind of failure (keys.t27 key_rest_after), for every pane, not just this one. fo_action decides; on a respawn +/// the pane's profile, bad mask, failover count and key to skip are set for plan_pane_keys, and the why line +/// (keys.t27 fo_why, 128 bytes are enough) goes to `why` with its length in wl[0]. After a give-up the screen +/// is not read again for GIVEUP_QUIET_MS. Returns the action; FA_RESPAWN_* means "stop the process, launch again". +pub fn plan_failover(st: []u64, i: usize, class: u32, now_ms: u64, why: []u8, wl: []u64) -> u32 { + const pk : usize = G_PANEK + i * PK; + var pr : u32 = PR_NONE; + if (st[pk + PK_PROF] < @as(u64, PROFILE_COUNT)) { pr = @as(u32, @intCast(st[pk + PK_PROF])); } + const stored : u32 = keys_stored(st); + const bad : u32 = @as(u32, @intCast(st[pk + PK_BAD])); + const done : u32 = @as(u32, @intCast(st[pk + PK_DONE])); + var cur : u32 = MAX_KEYS; + var alt : bool = false; + if (pr < PROFILE_COUNT) { + const prov : u32 = PROFILE_PROVIDER[pr]; + const held : u32 = pane_key_get(st, i, prov); + if (held > 0) { cur = held - 1; } + const rest : u64 = key_rest_after(class); + if (cur < MAX_KEYS and rest > 0) { key_rest(st, prov, cur, now_ms / 1000 + rest); } + alt = key_pick_free(st, prov, cur, now_ms) != MAX_KEYS; + } + const act : u32 = fo_action(class, pr, stored, bad, done, alt); + var nxt : u32 = PR_NONE; + if (act == FA_RESPAWN_KEY) { + nxt = pr; + st[pk + PK_SKIP] = @as(u64, cur) + 1; + } + if (act == FA_RESPAWN_NEXT) { + nxt = fo_target(class, pr, stored, bad, done, alt); + st[pk + PK_BAD] = @as(u64, fo_bad(class, pr, bad)); + } + if (act == FA_RESPAWN_KEY or act == FA_RESPAWN_NEXT) { + st[pk + PK_PROF] = @as(u64, nxt); + st[pk + PK_DONE] = @as(u64, done) + 1; + } + if (act == FA_GIVE_UP) { st[pk + PK_STOP] = now_ms + GIVEUP_QUIET_MS; } + wl[0] = @as(u64, fo_why(class, pr, act, nxt, done, why)); + return act; +} + +// ---- reading a pane's screen: plain text lines out of terminal bytes ---- + +fn put_break(out: []u8, n: usize) -> usize { + if (n == 0) { return 0; } + if (out[n - 1] == 10) { return n; } + out[n] = 10; + return n + 1; +} + +/// Plain text of terminal output: printable ASCII kept, tab a space, CR and LF one line break (no empty +/// lines), cursor moves up, down or to a position a line break, a move right a space; every other escape +/// sequence (colours, modes, titles), control byte and non-ASCII byte dropped. out needs src.len + 2 bytes. +/// The result is a rough picture of the screen's rows, enough for keys.t27 to read an error row from. +pub fn screen_text(src: []const u8, out: []u8) -> usize { + var n : usize = 0; + var i : usize = 0; + while (i < src.len and n + 1 < out.len) { + const b : u8 = src[i]; + if (b == 27) { + var j : usize = i + 1; + if (j >= src.len) { + i = src.len; + } else if (src[j] == 91) { + j = j + 1; + while (j < src.len and (src[j] < 64 or src[j] > 126)) { j = j + 1; } + if (j < src.len) { + const f : u8 = src[j]; + if (f == 65 or f == 66 or f == 69 or f == 70 or f == 72 or f == 100 or f == 102 or f == 74) { + n = put_break(out, n); + } else if (f == 67 or f == 71) { + out[n] = 32; + n = n + 1; + } + } + i = j + 1; + } else if (src[j] == 93) { + j = j + 1; + while (j < src.len and src[j] != 7 and src[j] != 27) { j = j + 1; } + if (j < src.len and src[j] == 27) { j = j + 2; } else { j = j + 1; } + i = j; + } else if (src[j] >= 40 and src[j] <= 43) { + i = j + 2; + } else { + i = j + 1; + } + } else if (b == 10 or b == 13) { + n = put_break(out, n); + i = i + 1; + } else if (b == 9) { + out[n] = 32; + n = n + 1; + i = i + 1; + } else if (b >= 32 and b < 127) { + out[n] = b; + n = n + 1; + i = i + 1; + } else { + i = i + 1; + } + } + return n; +} + +/// The failure class of one row: keys.t27 classify_line, but only on an error row (error_row). +pub fn row_class(line: []const u8) -> u32 { + if (error_row(line) == false) { return FC_NONE; } + return classify_line(line); +} + +/// The failure class of the rows of `text` (lines split at LF): the last row that is a failure; FC_NONE when +/// there is none (a row classed FC_OTHER is no failure to act on). +pub fn text_class(text: []const u8) -> u32 { + var cls : u32 = FC_NONE; + var a : usize = 0; + var i : usize = 0; + while (i <= text.len) { + if (i == text.len or text[i] == 10) { + if (i > a) { + const c : u32 = row_class(text[a .. i]); + if (c != FC_NONE and c != FC_OTHER) { cls = c; } + } + a = i + 1; + } + i = i + 1; + } + return cls; +} + +/// Where the unfinished last row of `text` starts and how long it is, cut at CARRY_CAP: f[0], f[1]. The next +/// chunk is read with this row in front, so an error row split across two reads is still one row. +pub fn carry_cut(text: []const u8, f: []u64) -> void { + var a : usize = 0; + var i : usize = 0; + while (i < text.len) { + if (text[i] == 10) { a = i + 1; } + i = i + 1; + } + var l : usize = text.len - a; + if (l > CARRY_CAP) { l = CARRY_CAP; } + f[0] = @as(u64, a); + f[1] = @as(u64, l); +} + +/// Only the claude cell is read: its error rows are the ones keys.t27 knows, and only it has profiles. +pub fn scan_applies(a: u32) -> bool { + return profile_agent(a); +} + +/// Is pane i's screen read now? Not in the first SCAN_GRACE_MS after it starts (a resumed session replays its +/// transcript) and not while a give-up is quiet. +pub fn scan_ready(st: []const u64, i: usize, born: u64, now: u64) -> bool { + if (now < born + SCAN_GRACE_MS) { return false; } + return now >= st[G_PANEK + i * PK + PK_STOP]; +} + +// ---- a restored pane waits for its keys ---- +// After a server restart the panes come back from their records at once, but the window sends the keys only +// when it connects. A restored claude pane would start with the subscription variables stripped and no +// profile ("Not logged in"). So a restored pane whose agent reads a provider key waits for a key of that +// provider, or RESTORE_WAIT_MS since the server started, and then starts as it always did; a restored pane +// that started with no key is respawned once when its first key arrives. + +/// Does a pane of agent a read some provider's key (the shell cell and an editor read none)? +pub fn needs_key(a: u32) -> bool { + return spawn_mask(a, 511) != 0; +} + +/// Does the table hold a key a pane of agent a can start with? The claude cell reads the key of a profile's +/// provider (z.ai, Anthropic, OpenRouter, Ollama cloud), every other agent a provider of AGENT_PROVIDERS. +pub fn has_key_for(st: []const u64, a: u32) -> bool { + const stored : u32 = keys_stored(st); + if (profile_agent(a)) { return pick_profile(stored, 0) != PR_NONE; } + return spawn_mask(a, stored) != 0; +} + +/// A pane being restored waits instead of launching. +pub fn restore_waits(st: []const u64, a: u32) -> bool { + return needs_key(a) and has_key_for(st, a) == false; +} + +/// The wait ends: a key of the agent's provider arrived, or the time is up (`start` is G_START). +pub fn restore_release(st: []const u64, a: u32, now: u64, start: u64) -> bool { + return has_key_for(st, a) or now >= start + RESTORE_WAIT_MS; +} + +/// PK_WAIT of a pane that waits with launch plan `plan`, and the plan out of it (R_SKIP when not waiting). +pub fn wait_word(plan: u32) -> u64 { + return @as(u64, plan) + 1; +} + +pub fn waiting(w: u64) -> bool { + return w >= 1 and w <= 3; +} + +pub fn wait_plan(w: u64) -> u32 { + if (waiting(w) == false) { return R_SKIP; } + return @as(u32, @intCast(w - 1)); +} + +/// PK_WAIT after a deferred launch: nothing when a key was there, W_KEYLESS when the wait ran out. +pub fn after_wait(st: []const u64, a: u32) -> u64 { + if (has_key_for(st, a)) { return W_NONE; } + return W_KEYLESS; +} + +/// A restored pane that started keyless gets its one respawn when its first key arrives. +pub fn keyless_respawn(w: u64, st: []const u64, a: u32) -> bool { + return w == W_KEYLESS and has_key_for(st, a); +} + +/// The plan of that respawn: resume when the session is known, else relaunch fresh. +pub fn keyless_plan(a: u32, sid: []const u8) -> u32 { + const p : u32 = restore_plan(a, 0, sid); + if (p == R_SKIP) { return R_RELAUNCH; } + return p; +} + // ---- tests: each runs and asserts on values the decisions compute ---- @@ -643,3 +1138,430 @@ test an_editor_pane_survives_a_restart_in_its_record { // the path limit is the pane cwd's assert TERM_PATH_MAX == CWD_CAP - 1; } + +// ---- tests of the key rotation. Every key here is a slot number; no test holds a key value, real or fake. ---- + +fn t_table(st: []u64) -> void { + var z : usize = 0; + while (z < 512) { + st[z] = 0; + z = z + 1; + } + keys_init(st); +} + +fn t_key(st: []u64, p: u32, k: u32) -> void { + key_put(st, p, k, true, false); +} + +/// The profile pane i has chosen (PR_NONE when it holds no real profile). +fn t_prof(st: []const u64, i: usize) -> u32 { + return @as(u32, @intCast(st[G_PANEK + i * PK + PK_PROF])); +} + +/// The table of the owner's setup, in miniature: three z.ai keys and one Anthropic key. +fn t_zai3(st: []u64) -> void { + t_table(st); + t_key(st, P_ZAI, 0); + t_key(st, P_ZAI, 1); + t_key(st, P_ZAI, 2); + t_key(st, P_ANTHROPIC, 0); +} + +test the_server_tables_and_the_arena_line_up_for_eight_keys_a_provider { + assert KEY_SLOTS == @as(usize, PROVIDER_COUNT) * @as(usize, MAX_KEYS); + assert G_KLAST + KEY_SLOTS <= G_KREST; + assert G_KREST + KEY_SLOTS <= G_PANEK; + assert G_PANEK + 16 * PK <= ST_SIZE; + assert G_START < G_CFD; + assert G_START > G_BINLEN; + assert 16 * PF <= G_LISTEN; + assert key_slot(0, 0) == O_KEYS; + assert key_slot(4, 2) == O_KEYS + (4 * 8 + 2) * KEY_CAP; + assert key_slot(PROVIDER_COUNT - 1, MAX_KEYS - 1) + KEY_CAP <= O_CARRY; + assert O_CARRY + 16 * CARRY_SLOT <= O_SCAN; + assert O_SCAN + SCAN_CAP <= MEM_SIZE; + assert CARRY_SLOT == CARRY_CAP + 4; + assert SCAN_CAP >= CARRY_CAP + 4096 + 2; + assert MAX_KEYS <= 8; +} + +test an_empty_table_holds_no_key_and_a_put_fills_one_slot { + var st : [512]u64 = undefined; + t_table(st[0..512]); + assert keys_stored(st[0..512]) == 0; + assert key_count(st[0..512], P_ZAI) == 0; + assert key_present(st[0..512], P_ZAI, 3) == false; + t_key(st[0..512], P_ZAI, 3); + assert key_present(st[0..512], P_ZAI, 3); + assert key_count(st[0..512], P_ZAI) == 1; + assert keys_stored(st[0..512]) == 16; + t_key(st[0..512], P_OPENAI, 7); + assert keys_stored(st[0..512]) == 18; + key_put(st[0..512], P_ZAI, 3, false, false); + assert keys_stored(st[0..512]) == 2; + assert st[G_KREST + 4 * 8 + 3] == KEY_NEVER; +} + +test sending_the_same_key_again_keeps_its_cooldown_and_a_new_key_starts_fresh { + var st : [512]u64 = undefined; + t_table(st[0..512]); + t_key(st[0..512], P_ZAI, 0); + key_used(st[0..512], P_ZAI, 0, 777); + key_rest(st[0..512], P_ZAI, 0, 5000); + key_put(st[0..512], P_ZAI, 0, true, true); + assert st[G_KREST + 32] == 5000; + assert st[G_KLAST + 32] == 777; + key_put(st[0..512], P_ZAI, 0, true, false); + assert st[G_KREST + 32] == 0; + assert st[G_KLAST + 32] == 0; + // a rest never shortens, and an empty slot never gets one + key_rest(st[0..512], P_ZAI, 0, 900); + key_rest(st[0..512], P_ZAI, 0, 400); + assert st[G_KREST + 32] == 900; + key_rest(st[0..512], P_ZAI, 1, 900); + assert st[G_KREST + 33] == KEY_NEVER; +} + +test new_claude_panes_take_the_z_ai_keys_in_rotation { + var st : [512]u64 = undefined; + t_zai3(st[0..512]); + var n : usize = 0; + var want : [4]u32 = [1, 2, 3, 1]; + while (n < 4) { + plan_pane_keys(st[0..512], n, 0, 1000000 + @as(u64, n) * 100); + assert t_prof(st[0..512], n) == PR_ZAI; + assert pane_key_get(st[0..512], n, P_ZAI) == want[n]; + assert pane_key_get(st[0..512], n, P_ANTHROPIC) == 0; + assert pane_key_shown(st[0..512], n, 0) == want[n]; + n = n + 1; + } + // a key that rests is passed over + key_rest(st[0..512], P_ZAI, 1, 2000); + plan_pane_keys(st[0..512], 5, 0, 1000500); + assert pane_key_get(st[0..512], 5, P_ZAI) == 3; + // every key resting: the one whose rest ends first + key_rest(st[0..512], P_ZAI, 0, 3000); + key_rest(st[0..512], P_ZAI, 2, 2500); + plan_pane_keys(st[0..512], 6, 0, 1000600); + assert pane_key_get(st[0..512], 6, P_ZAI) == 2; +} + +test every_other_agent_gets_a_key_of_each_provider_it_reads { + var st : [512]u64 = undefined; + t_zai3(st[0..512]); + t_key(st[0..512], P_OPENAI, 0); + t_key(st[0..512], P_OPENAI, 1); + // OpenCode (cell 3) reads Anthropic, OpenAI, OpenRouter: OpenRouter holds none + plan_pane_keys(st[0..512], 0, 3, 1000000); + assert pane_key_get(st[0..512], 0, P_ANTHROPIC) == 1; + assert pane_key_get(st[0..512], 0, P_OPENAI) == 1; + assert pane_key_get(st[0..512], 0, 3) == 0; + assert pane_key_get(st[0..512], 0, P_ZAI) == 0; + // Codex (cell 1) reads OpenAI only, and takes the key used longest ago + plan_pane_keys(st[0..512], 1, 1, 1000100); + assert pane_key_get(st[0..512], 1, P_OPENAI) == 2; + assert pane_key_get(st[0..512], 1, P_ANTHROPIC) == 0; + assert pane_key_shown(st[0..512], 1, 1) == 2; + // Crush (cell 5) reads ZAI_API_KEY among others + plan_pane_keys(st[0..512], 2, 5, 1000200); + assert pane_key_get(st[0..512], 2, P_ZAI) > 0; + assert pane_key_get(st[0..512], 2, P_OPENAI) > 0; + // a shell and an editor read none; an agent with no key of its provider gets nothing + plan_pane_keys(st[0..512], 3, 9, 1000300); + assert pane_key_shown(st[0..512], 3, 9) == 0; + assert st[G_PANEK + 3 * PK + PK_KEYS] == 0; + plan_pane_keys(st[0..512], 4, 2, 1000400); + assert pane_key_shown(st[0..512], 4, 2) == 0; + // the key numbers of one pane do not touch another's + assert pane_key_get(st[0..512], 0, P_OPENAI) == 1; +} + +test an_overloaded_key_swaps_to_the_next_key_rests_and_is_passed_over_by_other_panes { + var st : [512]u64 = undefined; + var why : [128]u8 = undefined; + var wl : [2]u64 = undefined; + t_zai3(st[0..512]); + plan_pane_keys(st[0..512], 0, 0, 1000000); + assert pane_key_get(st[0..512], 0, P_ZAI) == 1; + const act : u32 = plan_failover(st[0..512], 0, FC_OVERLOADED, 1005000, why[0..128], wl[0..2]); + assert act == FA_RESPAWN_KEY; + assert st[G_KREST + 32] == 1005 + 60; + assert st[G_PANEK + PK_SKIP] == 1; + assert st[G_PANEK + PK_DONE] == 1; + assert t_prof(st[0..512], 0) == PR_ZAI; + assert has_bytes(why[0 .. @as(usize, @intCast(wl[0]))], "claude: zai overloaded (529), same profile with the next key"); + plan_pane_keys(st[0..512], 0, 0, 1006000); + assert pane_key_get(st[0..512], 0, P_ZAI) == 2; + assert st[G_PANEK + PK_SKIP] == 0; + // another pane started meanwhile does not take the resting key + plan_pane_keys(st[0..512], 1, 0, 1010000); + assert pane_key_get(st[0..512], 1, P_ZAI) == 3; + // and a rate limit rests the key for thirty seconds + const b : u32 = plan_failover(st[0..512], 1, FC_RATE_LIMITED, 1011000, why[0..128], wl[0..2]); + assert b == FA_RESPAWN_KEY; + assert st[G_KREST + 34] == 1011 + 30; +} + +test when_every_z_ai_key_is_overloaded_the_pane_moves_to_the_next_profile_then_gives_up { + var st : [512]u64 = undefined; + var why : [128]u8 = undefined; + var wl : [2]u64 = undefined; + t_zai3(st[0..512]); + plan_pane_keys(st[0..512], 0, 0, 1000000); + assert pane_key_get(st[0..512], 0, P_ZAI) == 1; + var a : u32 = plan_failover(st[0..512], 0, FC_OVERLOADED, 1001000, why[0..128], wl[0..2]); + assert a == FA_RESPAWN_KEY; + plan_pane_keys(st[0..512], 0, 0, 1001500); + assert pane_key_get(st[0..512], 0, P_ZAI) == 2; + a = plan_failover(st[0..512], 0, FC_OVERLOADED, 1002000, why[0..128], wl[0..2]); + assert a == FA_RESPAWN_KEY; + plan_pane_keys(st[0..512], 0, 0, 1002500); + assert pane_key_get(st[0..512], 0, P_ZAI) == 3; + // the third key is overloaded too and the other two still rest: next profile, z.ai marked bad + a = plan_failover(st[0..512], 0, FC_OVERLOADED, 1003000, why[0..128], wl[0..2]); + assert a == FA_RESPAWN_NEXT; + assert t_prof(st[0..512], 0) == PR_ANTHROPIC; + assert st[G_PANEK + PK_BAD] == 16; + assert has_bytes(why[0 .. @as(usize, @intCast(wl[0]))], "zai overloaded (529), respawn on anthropic-api"); + plan_pane_keys(st[0..512], 0, 0, 1003500); + assert pane_key_get(st[0..512], 0, P_ANTHROPIC) == 1; + assert pane_key_get(st[0..512], 0, P_ZAI) == 0; + // the Anthropic key is overloaded: no profile is left, the pane stays and its screen is not read for a minute + a = plan_failover(st[0..512], 0, FC_OVERLOADED, 1004000, why[0..128], wl[0..2]); + assert a == FA_GIVE_UP; + assert st[G_PANEK + PK_DONE] == 3; + assert st[G_PANEK + PK_STOP] == 1004000 + GIVEUP_QUIET_MS; + assert has_bytes(why[0 .. @as(usize, @intCast(wl[0]))], "no profile left in the chain"); + assert scan_ready(st[0..512], 0, 1003500, 1020000) == false; + assert scan_ready(st[0..512], 0, 1003500, 1064001); +} + +test an_empty_balance_leaves_the_provider_at_once_and_the_key_rests_ten_minutes { + var st : [512]u64 = undefined; + var why : [128]u8 = undefined; + var wl : [2]u64 = undefined; + t_zai3(st[0..512]); + plan_pane_keys(st[0..512], 0, 0, 1000000); + const a : u32 = plan_failover(st[0..512], 0, FC_NO_BALANCE, 1005000, why[0..128], wl[0..2]); + assert a == FA_RESPAWN_NEXT; + assert st[G_KREST + 32] == 1005 + 600; + assert st[G_PANEK + PK_BAD] == 16; + assert t_prof(st[0..512], 0) == PR_ANTHROPIC; + assert has_bytes(why[0 .. @as(usize, @intCast(wl[0]))], "zai out of balance, respawn on anthropic-api"); + // the failure cap ends any loop + st[G_PANEK + PK_DONE] = @as(u64, MAX_FAILOVERS); + const g : u32 = plan_failover(st[0..512], 0, FC_OVERLOADED, 1006000, why[0..128], wl[0..2]); + assert g == FA_GIVE_UP; + assert has_bytes(why[0 .. @as(usize, @intCast(wl[0]))], "failover limit reached"); +} + +test a_pane_that_started_without_a_key_takes_the_first_stored_profile_on_a_subscription_row { + var st : [512]u64 = undefined; + var why : [128]u8 = undefined; + var wl : [2]u64 = undefined; + t_table(st[0..512]); + plan_pane_keys(st[0..512], 0, 0, 1000000); + assert t_prof(st[0..512], 0) == PR_NONE; + assert pane_key_shown(st[0..512], 0, 0) == 0; + // nothing stored: nothing to move to + assert plan_failover(st[0..512], 0, FC_SUBSCRIPTION, 1001000, why[0..128], wl[0..2]) == FA_GIVE_UP; + // the window's keys arrive; the pane asks again after its quiet time + t_key(st[0..512], P_ZAI, 4); + st[G_PANEK + PK_STOP] = 0; + const a : u32 = plan_failover(st[0..512], 0, FC_SUBSCRIPTION, 1002000, why[0..128], wl[0..2]); + assert a == FA_RESPAWN_NEXT; + assert t_prof(st[0..512], 0) == PR_ZAI; + assert has_bytes(why[0 .. @as(usize, @intCast(wl[0]))], "no profile subscription disabled, respawn on zai"); + plan_pane_keys(st[0..512], 0, 0, 1003000); + assert pane_key_get(st[0..512], 0, P_ZAI) == 5; +} + +test a_new_spawn_starts_with_a_clean_pane_record { + var st : [512]u64 = undefined; + t_zai3(st[0..512]); + plan_pane_keys(st[0..512], 2, 0, 1000000); + st[G_PANEK + 2 * PK + PK_DONE] = 5; + st[G_PANEK + 2 * PK + PK_BAD] = 16; + pane_keys_reset(st[0..512], 2); + assert st[G_PANEK + 2 * PK + PK_DONE] == 0; + assert st[G_PANEK + 2 * PK + PK_BAD] == 0; + assert st[G_PANEK + 2 * PK + PK_PROF] == PK_UNSET; + assert st[G_PANEK + 2 * PK + PK_KEYS] == 0; + assert st[G_PANEK + 2 * PK + PK_WAIT] == W_NONE; + plan_pane_keys(st[0..512], 2, 0, 1000100); + assert t_prof(st[0..512], 2) == PR_ZAI; +} + +test a_list_entry_carries_the_key_number_in_the_high_nibble { + const w : u32 = pane_step(0, P_SPAWNED); + assert list_flags_key(true, w, 0) == list_flags(true, w); + assert list_flags_key(true, w, 3) % 16 == list_flags(true, w); + assert list_flags_key(true, w, 3) / 16 == 3; + assert list_flags_key(false, w, MAX_KEYS) / 16 == MAX_KEYS; + assert list_flags_key(true, hooked_status(w, "Notification"), MAX_KEYS) < 256; + // a client that reads bit 0, 1 or 2 only sees the old byte + assert list_flags_key(true, hooked_status(w, "Notification"), 5) % 2 == 1; +} + +test terminal_bytes_become_plain_rows { + var s : [200]u8 = undefined; + var o : [210]u8 = undefined; + var n : usize = put_bytes(s[0..200], 0, "ab"); + s[n] = 27; + n = put_bytes(s[0..200], n + 1, "[31;1mAPI Error: 529"); + s[n] = 27; + n = put_bytes(s[0..200], n + 1, "[0m"); + s[n] = 13; + s[n + 1] = 10; + n = put_bytes(s[0..200], n + 2, "next"); + var t : usize = screen_text(s[0..n], o[0..210]); + assert same_bytes(o[0..t], "abAPI Error: 529\nnext"); + // a cursor move up or to a position is a line break, one to the right a space + n = put_bytes(s[0..200], 0, "one"); + s[n] = 27; + n = put_bytes(s[0..200], n + 1, "[2K"); + s[n] = 27; + n = put_bytes(s[0..200], n + 1, "[1Atwo"); + s[n] = 27; + n = put_bytes(s[0..200], n + 1, "[3Cx"); + t = screen_text(s[0..n], o[0..210]); + assert same_bytes(o[0..t], "one\ntwo x"); + // a title (OSC) up to BEL, a charset switch, a tab, a bare ESC at the end, bytes above 127 + n = put_bytes(s[0..200], 0, "a"); + s[n] = 27; + n = put_bytes(s[0..200], n + 1, "]0;title"); + s[n] = 7; + s[n + 1] = 27; + n = put_bytes(s[0..200], n + 2, "(Bb"); + s[n] = 9; + s[n + 1] = 200; + s[n + 2] = 99; + s[n + 3] = 27; + t = screen_text(s[0..n + 4], o[0..210]); + assert same_bytes(o[0..t], "ab c"); + assert screen_text("", o[0..210]) == 0; + // the output never runs past its room + assert screen_text("abcdefgh", o[0..5]) == 4; + // empty lines collapse and a leading break is dropped + t = screen_text("\n\nx\n\ny", o[0..210]); + assert same_bytes(o[0..t], "x\ny"); +} + +test only_an_error_row_on_the_screen_names_a_failure { + assert text_class("API Error: 529 {\"error\":{\"code\":\"1305\"}}") == FC_OVERLOADED; + assert text_class("reading\nAPI Error: 429 Insufficient balance\nprompt") == FC_NO_BALANCE; + assert text_class("API Error: 401 bad key\nAPI Error: 529 busy") == FC_OVERLOADED; + // a diff or a quotation that shows the phrases is not a failure + assert text_class("+ assert classify_line(\"API Error: 429 Insufficient balance\") == FC_NO_BALANCE;") == FC_NONE; + assert text_class("the log said API Error: 529\nok") == FC_NONE; + // Claude Code's own retry is not final + assert text_class("API Error (529 overloaded_error) Retrying in 4 seconds") == FC_NONE; + assert text_class("API Error: 500 upstream failure") == FC_NONE; + assert text_class("") == FC_NONE; + assert text_class("\n\n") == FC_NONE; + assert row_class(" Error: 403 forbidden") == FC_LOGIN_REJECTED; +} + +test an_error_row_split_across_two_reads_is_still_one_row { + var o : [300]u8 = undefined; + var c : [300]u8 = undefined; + var f : [2]u64 = undefined; + // read 1 ends in the middle of the row; its unfinished row is carried + var t : usize = screen_text("Working...\nAPI Err", o[0..300]); + assert text_class(o[0..t]) == FC_NONE; + carry_cut(o[0..t], f[0..2]); + assert f[0] == 11; + assert f[1] == 7; + var cl : usize = put_bytes(c[0..300], 0, o[@as(usize, @intCast(f[0])) .. @as(usize, @intCast(f[0])) + @as(usize, @intCast(f[1]))]); + // read 2 finishes it + const t2 : usize = screen_text("or: 529 overloaded\n", o[0..300]); + cl = put_bytes(c[0..300], cl, o[0..t2]); + assert text_class(c[0..cl]) == FC_OVERLOADED; + // a long unfinished row is cut at the carry limit; a finished last row carries nothing + var big : [200]u8 = undefined; + var i : usize = 0; + while (i < 200) { + big[i] = 97; + i = i + 1; + } + carry_cut(big[0..200], f[0..2]); + assert f[0] == 0; + assert f[1] == CARRY_CAP; + carry_cut("abc\n", f[0..2]); + assert f[0] == 4; + assert f[1] == 0; +} + +test a_pane_screen_is_not_read_in_its_first_seconds_nor_while_a_give_up_is_quiet { + var st : [512]u64 = undefined; + t_table(st[0..512]); + assert scan_ready(st[0..512], 1, 1000, 5000) == false; + assert scan_ready(st[0..512], 1, 1000, 11000); + st[G_PANEK + PK + PK_STOP] = 70000; + assert scan_ready(st[0..512], 1, 1000, 60000) == false; + assert scan_ready(st[0..512], 1, 1000, 70000); + assert scan_applies(0); + assert scan_applies(1) == false; + assert scan_applies(SHELL_CELL) == false; +} + +test a_restored_pane_waits_for_a_key_of_its_provider_and_then_starts_with_it { + var st : [512]u64 = undefined; + t_table(st[0..512]); + // the window has not connected: claude, codex, queen wait; a shell and an editor do not + assert needs_key(0); + assert needs_key(1); + assert needs_key(4); + assert needs_key(SHELL_CELL) == false; + assert restore_waits(st[0..512], 0); + assert restore_waits(st[0..512], 1); + assert restore_waits(st[0..512], SHELL_CELL) == false; + assert wait_plan(wait_word(R_RESUME)) == R_RESUME; + assert wait_plan(wait_word(R_RELAUNCH)) == R_RELAUNCH; + assert waiting(wait_word(R_RESUME)) and waiting(wait_word(R_RELAUNCH)); + assert waiting(W_NONE) == false; + assert waiting(W_KEYLESS) == false; + assert waiting(W_DONE) == false; + assert wait_plan(W_NONE) == R_SKIP; + // 2 s after the server started, still nothing + assert restore_release(st[0..512], 0, 1002000, 1000000) == false; + // an OpenAI key arrives: claude reads no OpenAI key, codex does + t_key(st[0..512], P_OPENAI, 0); + assert restore_release(st[0..512], 0, 1003000, 1000000) == false; + assert restore_release(st[0..512], 1, 1003000, 1000000); + // a z.ai key arrives: claude's profile has a provider now, and the pane starts on it + t_key(st[0..512], P_ZAI, 0); + assert restore_release(st[0..512], 0, 1003500, 1000000); + assert after_wait(st[0..512], 0) == W_NONE; + plan_pane_keys(st[0..512], 3, 0, 1003600); + assert t_prof(st[0..512], 3) == PR_ZAI; + assert pane_key_get(st[0..512], 3, P_ZAI) == 1; +} + +test a_restored_pane_whose_wait_runs_out_starts_keyless_and_is_respawned_once_when_a_key_arrives { + var st : [512]u64 = undefined; + t_table(st[0..512]); + assert restore_release(st[0..512], 0, 1019999, 1000000) == false; + assert restore_release(st[0..512], 0, 1020000, 1000000); + assert after_wait(st[0..512], 0) == W_KEYLESS; + plan_pane_keys(st[0..512], 0, 0, 1020100); + assert t_prof(st[0..512], 0) == PR_NONE; + st[G_PANEK + PK_WAIT] = W_KEYLESS; + // still no key: no respawn + assert keyless_respawn(st[G_PANEK + PK_WAIT], st[0..512], 0) == false; + // the first key arrives + t_key(st[0..512], P_ZAI, 0); + assert keyless_respawn(st[G_PANEK + PK_WAIT], st[0..512], 0); + // the respawn: the pane takes the profile and the key, and the one respawn is spent + plan_pane_keys(st[0..512], 0, 0, 1030000); + st[G_PANEK + PK_WAIT] = W_DONE; + assert t_prof(st[0..512], 0) == PR_ZAI; + assert pane_key_shown(st[0..512], 0, 0) == 1; + assert keyless_respawn(st[G_PANEK + PK_WAIT], st[0..512], 0) == false; + assert keyless_respawn(W_NONE, st[0..512], 0) == false; + // resume when the session is known, else a fresh start + assert keyless_plan(0, "5f1c9a00-aaaa-bbbb-cccc-0123456789ab") == R_RESUME; + assert keyless_plan(0, "-") == R_RELAUNCH; + assert keyless_plan(0, "") == R_RELAUNCH; +} diff --git a/specs/t27os/pty_server.t27 b/specs/t27os/pty_server.t27 index d8416e7978..d0b3d02c14 100644 --- a/specs/t27os/pty_server.t27 +++ b/specs/t27os/pty_server.t27 @@ -18,6 +18,14 @@ // T27OS_INSTALL=1 write ~/Library/LaunchAgents/ai.t27.os-server.plist for this binary: it starts // the server at login, so panes come back (by session id, model kept) after a reboot. // +// Keys (gHashTag/t27#11241): the server holds up to MAX_KEYS numbered keys for each provider, in memory only, +// sent by the window with OP_SETKEY (the key number rides in the high nibble of the pane byte). Every agent +// 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. +// // Every decision is a fn with a test that runs; the host loop only moves bytes through pty_sys.t27. // // Refs gHashTag/t27#10782. @@ -145,16 +153,54 @@ use t27os::pty_sys::sys_unsetenv; 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_START; +use t27os::pty_logic::PK; +use t27os::pty_logic::PK_PROF; +use t27os::pty_logic::PK_WAIT; +use t27os::pty_logic::W_DONE; +use t27os::pty_logic::O_CARRY; +use t27os::pty_logic::CARRY_SLOT; +use t27os::pty_logic::O_SCAN; +use t27os::pty_logic::SCAN_CAP; +use t27os::pty_logic::CARRY_CAP; +use t27os::pty_logic::key_slot; +use t27os::pty_logic::keys_init; +use t27os::pty_logic::pane_keys_reset; +use t27os::pty_logic::pane_key_get; +use t27os::pty_logic::pane_key_shown; +use t27os::pty_logic::list_flags_key; +use t27os::pty_logic::key_put; +use t27os::pty_logic::plan_pane_keys; +use t27os::pty_logic::plan_failover; +use t27os::pty_logic::screen_text; +use t27os::pty_logic::text_class; +use t27os::pty_logic::carry_cut; +use t27os::pty_logic::scan_applies; +use t27os::pty_logic::scan_ready; +use t27os::pty_logic::restore_waits; +use t27os::pty_logic::restore_release; +use t27os::pty_logic::wait_word; +use t27os::pty_logic::waiting; +use t27os::pty_logic::wait_plan; +use t27os::pty_logic::after_wait; +use t27os::pty_logic::keyless_respawn; +use t27os::pty_logic::keyless_plan; use t27os::keys::PROVIDER_COUNT; +use t27os::keys::MAX_KEYS; +use t27os::keys::PROFILE_COUNT; +use t27os::keys::FC_NONE; +use t27os::keys::FA_KEEP; +use t27os::keys::FA_GIVE_UP; +use t27os::keys::setkey_provider; +use t27os::keys::setkey_number; use t27os::keys::agent_uses; use t27os::keys::provider_env; use t27os::keys::env_stripped; use t27os::keys::key_fits; use t27os::keys::profile_agent; use t27os::keys::claude_strips; -use t27os::keys::pick_profile; use t27os::keys::PROFILE_PROVIDER; -use t27os::keys::PR_NONE; use t27os::keys::profile_var_count; use t27os::keys::profile_var_name; use t27os::keys::profile_var_is_key; @@ -385,19 +431,18 @@ fn strip_claude_env(mem: []u8, agent: u32) -> void { } } -/// In the child, for the claude agent: pick the profile (keys.t27 pick_profile over the stored keys; the -/// window withholds a key that failed its check) and set exactly that profile's variables. No stored key -/// leaves the pane without one, and none of the other profile's variables is set. -fn inject_profile(mem: []u8) -> void { - var stored : u32 = 0; - var p : u32 = 0; - while (p < PROVIDER_COUNT) { - if (mem[O_KEYS + @as(usize, p) * KEY_CAP] != 0) { stored = stored + (1 << p); } - p = p + 1; - } - const pr : u32 = pick_profile(stored, 0); - if (pr == PR_NONE) { return; } - const at : usize = O_KEYS + @as(usize, PROFILE_PROVIDER[pr]) * KEY_CAP; +/// In the child, for the claude agent: set exactly the variables of the profile the parent chose for pane i +/// (pty_logic plan_pane_keys: pick_profile over the providers that hold a key, or the profile a failover moved +/// to) with the key of the number it picked by rotation. No usable key leaves the pane without one, and none of +/// the other profiles' variables is set. The key is read from its slot; its number is all the pane record holds. +fn inject_profile(st: []const u64, mem: []u8, i: usize) -> void { + const pr64 : u64 = st[G_PANEK + i * PK + PK_PROF]; + if (pr64 >= @as(u64, PROFILE_COUNT)) { return; } + const pr : u32 = @as(u32, @intCast(pr64)); + const prov : u32 = PROFILE_PROVIDER[pr]; + const kn : u32 = pane_key_get(st, i, prov); + if (kn == 0) { return; } + const at : usize = key_slot(prov, kn - 1); var k : u32 = 0; while (k < profile_var_count(pr)) { const nl : usize = profile_var_name(pr, k, mem[O_TMP2 .. O_TMP2 + 40]); @@ -413,19 +458,24 @@ fn inject_profile(mem: []u8) -> void { } } -/// In the child: set the environment variable of every stored key the agent reads (OP_SETKEY filled them). -fn inject_keys(mem: []u8, agent: u32) -> void { +/// In the child: set the environment variable (keys.t27 PROVIDER_ENVS) of each provider the agent reads +/// (AGENT_PROVIDERS) to the key plan_pane_keys picked for pane i from that provider's numbered keys. A +/// provider that holds no key leaves its variable unset. +fn inject_keys(st: []const u64, mem: []u8, i: usize, agent: u32) -> void { if (profile_agent(agent)) { - inject_profile(mem); + inject_profile(st, mem, i); return; } var p : u32 = 0; while (p < PROVIDER_COUNT) { - const at : usize = O_KEYS + @as(usize, p) * KEY_CAP; - if (agent_uses(agent, p) and mem[at] != 0) { - const nl : usize = provider_env(p, mem[O_TMP2 .. O_TMP2 + 40]); - mem[O_TMP2 + nl] = 0; - sys_setenv(mem[O_TMP2 .. O_TMP2 + nl + 1], mem[at + 1 .. at + 2 + @as(usize, mem[at])], 1); + const kn : u32 = pane_key_get(st, i, p); + if (agent_uses(agent, p) and kn > 0) { + const at : usize = key_slot(p, kn - 1); + if (mem[at] != 0) { + const nl : usize = provider_env(p, mem[O_TMP2 .. O_TMP2 + 40]); + mem[O_TMP2 + nl] = 0; + sys_setenv(mem[O_TMP2 .. O_TMP2 + nl + 1], mem[at + 1 .. at + 2 + @as(usize, mem[at])], 1); + } } p = p + 1; } @@ -440,6 +490,10 @@ fn launch(st: []u64, mem: []u8, i: usize, plan: u32) -> bool { const un : usize = uuid_text(mem[O_MISC + 64 .. O_MISC + 80], mem[O_SID + i * SID_CAP .. O_SID + i * SID_CAP + 40]); st[base + PF_SIDLEN] = @as(u64, un); } + // the keys of this start: the profile and the key number of each provider the agent reads, by rotation + // over the numbered keys (a failover has set what to skip); the unfinished screen row of the old process goes + plan_pane_keys(st, i, agent, now_ms(mem)); + mem[O_CARRY + i * CARRY_SLOT] = 0; const home : []const u8 = home_slice(st, mem); const cn : usize = launch_command(agent, plan, sid_of(st, mem, i), model_of(mem, i), home, cwd_of(st, mem, i), mem[O_TMP1 + 4096 .. O_TMP1 + 8000]); var f : [4]u64 = undefined; @@ -470,7 +524,7 @@ fn launch(st: []u64, mem: []u8, i: usize, plan: u32) -> bool { sys_chdir(mem[O_TMP1 + 3000 .. O_TMP1 + 3000 + cw + 1]); sys_setenv("T27OS_PANE", mem[O_TMP1 + 3300 .. O_TMP1 + 3300 + idn + 1], 1); strip_claude_env(mem, agent); - inject_keys(mem, agent); + inject_keys(st, mem, i, agent); sys_execv(mem[O_TMP1 .. O_TMP1 + 8], argv); sys_exit(127); return false; @@ -510,7 +564,8 @@ fn reap(st: []u64, mem: []u8, i: usize) -> void { save_pane(st, mem, i); } -fn kill_pane(st: []u64, mem: []u8, i: usize) -> void { +/// Stop pane i's process (SIGKILL, wait, close the master); the pane's record and slot stay. +fn stop_pane_process(st: []u64, i: usize) -> void { const base : usize = i * PF; if (st[base + PF_LIVE] != 0) { sys_kill(@as(i32, @intCast(st[base + PF_PID])), 9); @@ -520,11 +575,84 @@ fn kill_pane(st: []u64, mem: []u8, i: usize) -> void { sys_waitpid(@as(i32, @intCast(st[base + PF_PID])), ws, 0); sys_close(@as(i32, @intCast(st[base + PF_FD]))); } - drop_record(st, mem, i); st[base + PF_LIVE] = 0; +} + +fn kill_pane(st: []u64, mem: []u8, i: usize) -> void { + const base : usize = i * PF; + stop_pane_process(st, i); + drop_record(st, mem, i); st[base + PF_USED] = 0; } +/// Start pane i again under `plan` (a failover onto another key or profile, or the one respawn of a restored +/// pane that started without a key). The scrollback and its running count stay, so a window that reads the pane +/// goes on from where it was; launch sets the count to 0 and it is put back. +fn respawn_pane(st: []u64, mem: []u8, i: usize, plan: u32) -> void { + const total : u64 = st[i * PF + PF_TOTAL]; + stop_pane_process(st, i); + launch(st, mem, i, plan); + st[i * PF + PF_TOTAL] = total; + save_pane(st, mem, i); +} + +/// Pane i's screen showed failure `class`: pty_logic plan_failover decides (the key rests, the pane moves to the +/// next key or profile, or stays), the why line goes into the pane's scrollback, and on a respawn the pane is +/// started again (resumed when its session is known). A give-up writes the same line and leaves the pane as it is. +fn failover(st: []u64, mem: []u8, i: usize, class: u32, now: u64) -> void { + const base : usize = i * PF; + var wl : [2]u64 = undefined; + wl[0] = 0; + wl[1] = 0; + const act : u32 = plan_failover(st, i, class, now, mem[O_OUT + 2 .. O_OUT + 130], wl[0..2]); + if (act == FA_KEEP) { return; } + const n : usize = @as(usize, @intCast(wl[0])); + mem[O_OUT] = 13; + mem[O_OUT + 1] = 10; + mem[O_OUT + 2 + n] = 13; + mem[O_OUT + 3 + n] = 10; + st[base + PF_TOTAL] = ring_append(ring_of(mem, i), st[base + PF_TOTAL], mem[O_OUT .. O_OUT + n + 4], n + 4); + if (act == FA_GIVE_UP) { return; } + var plan : u32 = R_RELAUNCH; + if (st[base + PF_SIDLEN] > 0) { plan = R_RESUME; } + respawn_pane(st, mem, i, plan); +} + +/// Read the n new bytes of pane i (at O_MISC + 128) for a provider error row: only the claude cell, never in +/// the first seconds after it starts, never while a give-up is quiet. The bytes become plain rows (screen_text), +/// the unfinished last row of the previous read is put in front and the new one is kept for the next, and the +/// rows go through keys.t27 error_row and classify_line (pty_logic text_class). +fn scan_pane(st: []u64, mem: []u8, i: usize, n: usize) -> void { + const base : usize = i * PF; + if (scan_applies(@as(u32, @intCast(st[base + PF_AGENT]))) == false) { return; } + const now : u64 = now_ms(mem); + const ca : usize = O_CARRY + i * CARRY_SLOT; + if (scan_ready(st, i, st[base + PF_BORN], now) == false) { + mem[ca] = 0; + return; + } + const cl : usize = @as(usize, mem[ca]); + var k : usize = 0; + while (k < cl) { + mem[O_SCAN + k] = mem[ca + 1 + k]; + k = k + 1; + } + const tl : usize = screen_text(mem[O_MISC + 128 .. O_MISC + 128 + n], mem[O_SCAN + cl .. O_SCAN + cl + n + 2]); + const total : usize = cl + tl; + const cls : u32 = text_class(mem[O_SCAN .. O_SCAN + total]); + var f : [2]u64 = undefined; + carry_cut(mem[O_SCAN .. O_SCAN + total], f[0..2]); + const fs : usize = @as(usize, @intCast(f[0])); + const fl : usize = @as(usize, @intCast(f[1])); + mem[ca] = @as(u8, @intCast(fl)); + k = 0; + while (k < fl) { + mem[ca + 1 + k] = mem[O_SCAN + fs + k]; + k = k + 1; + } + if (cls != FC_NONE) { failover(st, mem, i, cls, now); } +} + 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)); @@ -543,7 +671,9 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { const b : usize = @as(usize, i) * PF; if (st[b + PF_USED] != 0) { const w : u32 = @as(u32, @intCast(st[b + PF_STATUS])); - at = put_list_entry(mem[O_OUT .. O_OUT + 128], at - O_OUT, i, @as(u32, @intCast(st[b + PF_AGENT])), shown_state(w), list_flags(st[b + PF_LIVE] != 0, w)) + O_OUT; + // the flags byte carries the pane's key number + 1 in its high nibble (0 = no key); bits 0 to 2 as before + const kshown : u32 = pane_key_shown(st, @as(usize, i), @as(u32, @intCast(st[b + PF_AGENT]))); + at = put_list_entry(mem[O_OUT .. O_OUT + 128], at - O_OUT, i, @as(u32, @intCast(st[b + PF_AGENT])), shown_state(w), list_flags_key(st[b + PF_LIVE] != 0, w, kshown)) + O_OUT; } i = i + 1; } @@ -570,6 +700,7 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { st[sb + PF_SLOT] = @as(u64, mem[p0 + 2]); st[sb + PF_SIDLEN] = 0; st[sb + PF_CWDLEN] = @as(u64, cl); + pane_keys_reset(st, @as(usize, slot)); st[G_WINSZ + @as(usize, slot) / 2] = ws_pack(st[G_WINSZ + @as(usize, slot) / 2], slot % 2, 0, 0); var k : usize = 0; while (k < cl) { @@ -586,16 +717,29 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { return; } if (op == OP_SETKEY) { - if (pane >= PROVIDER_COUNT or plen > KEY_CAP - 2) { reply(fd, mem, RE_ERR, 0, 0); return; } + // 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 = O_KEYS + @as(usize, pane) * KEY_CAP; - mem[ka] = @as(u8, @intCast(plen)); + 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 + 1 + plen] = 0; + 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); reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); return; } @@ -716,6 +860,35 @@ fn serve_pane(st: []u64, mem: []u8, i: usize) -> void { st[base + PF_TOTAL] = ring_append(ring_of(mem, i), st[base + PF_TOTAL], mem[O_MISC + 128 .. O_MISC + 128 + 4096], @as(usize, @intCast(n))); st[base + PF_LAST] = now_ms(mem); st[base + PF_STATUS] = @as(u64, pane_step(@as(u32, @intCast(st[base + PF_STATUS])), P_OUTPUT)); + // last, because a failover starts the pane again + scan_pane(st, mem, i, @as(usize, @intCast(n))); +} + +/// Once a tick: a restored pane that waits for a key of its provider starts when one arrived or the wait ran +/// out (a pane that ran out starts as it always did, without a key); a restored pane that started keyless is +/// started again, once, when its first key arrives. pty_logic holds both decisions. +fn wait_tick(st: []u64, mem: []u8) -> void { + const now : u64 = now_ms(mem); + var i : usize = 0; + while (i < MAX_PANES) { + const base : usize = i * PF; + const wk : usize = G_PANEK + i * PK + PK_WAIT; + const ag : u32 = @as(u32, @intCast(st[base + PF_AGENT])); + if (st[base + PF_USED] != 0) { + const w : u64 = st[wk]; + if (waiting(w)) { + if (restore_release(st, ag, now, st[G_START])) { + st[wk] = after_wait(st, ag); + launch(st, mem, i, wait_plan(w)); + save_pane(st, mem, i); + } + } else if (st[base + PF_LIVE] != 0 and keyless_respawn(w, st, ag)) { + st[wk] = W_DONE; + respawn_pane(st, mem, i, keyless_plan(ag, sid_of(st, mem, i))); + } + } + i = i + 1; + } } fn quiet_tick(st: []u64, mem: []u8) -> void { @@ -768,6 +941,7 @@ fn restore_all(st: []u64, mem: []u8) -> u32 { st[b + PF_SLOT] = f[F_SLOT]; st[b + PF_SIDLEN] = @as(u64, sl); st[b + PF_CWDLEN] = @as(u64, cl); + pane_keys_reset(st, @as(usize, i)); // the record's model (a v1 record has none), then the newer one the transcript names mem[O_MODEL + @as(usize, i) * MODEL_CAP] = 0; set_model(mem, @as(usize, i), O_TMP2 + 512 + @as(usize, @intCast(f[F_MODEL_AT])), @as(usize, @intCast(f[F_MODEL_LEN]))); @@ -777,6 +951,13 @@ fn restore_all(st: []u64, mem: []u8) -> u32 { var plan : u32 = restore_plan(@as(u32, @intCast(f[F_AGENT])), @as(u32, @intCast(f[F_STATE])), sidv); if (plan == R_SKIP) { st[b + PF_STATUS] = @as(u64, @intCast(f[F_STATE])); + } else if (restore_waits(st, @as(u32, @intCast(f[F_AGENT])))) { + // the window has not sent its keys yet: a pane started now would run without the profile and + // show "Not logged in". It waits for a key of its provider, or RESTORE_WAIT_MS since the + // server started (wait_tick), and starts then. + st[b + PF_STATUS] = @as(u64, @intCast(f[F_STATE])); + st[G_PANEK + @as(usize, i) * PK + PK_WAIT] = wait_word(plan); + back = back + 1; } else { if (launch(st, mem, @as(usize, i), plan)) { back = back + 1; } save_pane(st, mem, @as(usize, i)); @@ -895,6 +1076,7 @@ fn serve_loop(st: []u64, mem: []u8) -> void { } } quiet_tick(st, mem); + wait_tick(st, mem); } } @@ -904,6 +1086,8 @@ fn run_server(st: []u64, mem: []u8) -> void { say("t27os-server: cannot open the socket\n"); return; } + // 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); say("t27os-server: listening, restored "); var nb : [8]u8 = undefined; @@ -961,8 +1145,11 @@ fn run_ctl(st: []u64, mem: []u8, verb: u8) -> void { print_dec(@as(u64, out[at + 1]), mem); say(" state "); print_dec(@as(u64, out[at + 2]), mem); + // bits 0 to 2 of the flags byte as before; its high nibble is the key number (1 to 8) the pane holds say(" flags "); - print_dec(@as(u64, out[at + 3]), mem); + print_dec(@as(u64, out[at + 3] % 16), mem); + say(" key "); + if (out[at + 3] / 16 == 0) { say("-"); } else { print_dec(@as(u64, out[at + 3] / 16), mem); } say("\n"); at = at + 4; } @@ -1016,13 +1203,14 @@ fn run_install(st: []u64, mem: []u8) -> void { } pub fn main() -> void { - var st : [256]u64 = undefined; - var mem : [1149184]u8 = undefined; + var st : [512]u64 = undefined; + var mem : [1171712]u8 = undefined; var z : usize = 0; while (z < ST_SIZE) { st[z] = 0; z = z + 1; } + keys_init(st[0..ST_SIZE]); sys_signal(13, 1); st[G_HLEN] = @as(u64, env_text("HOME", mem[0 .. MEM_SIZE], O_HOME)); var bsz : [2]u32 = undefined; @@ -1054,5 +1242,12 @@ test the_arena_regions_do_not_overlap { assert O_MISC + 328 <= O_BIN; assert O_BIN + 256 <= O_RING; assert O_RING + 16 * 65536 <= O_KEYS; - assert O_KEYS + 9 * KEY_CAP <= MEM_SIZE; + // the key table holds MAX_KEYS keys for each of the PROVIDER_COUNT providers, then the screen carry rows + // and the scratch for a chunk's plain text; main() sizes its arrays with these two literals + assert O_KEYS + @as(usize, PROVIDER_COUNT) * @as(usize, MAX_KEYS) * KEY_CAP <= O_CARRY; + assert O_CARRY + 16 * CARRY_SLOT <= O_SCAN; + assert O_SCAN + SCAN_CAP <= MEM_SIZE; + assert MEM_SIZE == 1171712; + assert ST_SIZE == 512; + assert CARRY_SLOT == CARRY_CAP + 4; } From e92e9778e52e5feb6db0da9b4af71204bc26dd47 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:01:09 +0700 Subject: [PATCH 2/4] T27 OS keys: every stored key in rotation, failover on overload, restored panes wait for their keys (Closes #11241) Merge of master plus sealing, gates run on the merged tree with t27c from master: tests clean with vacuous passes 0, seal --verify MATCH, tri types ratchet CLEAN, dupe_scan failure set a subset of master's. Co-Authored-By: Claude Sonnet 5.5 --- .trinity/seals/t27os_T27osKeys.json | 37 +++++++++++++++++ .trinity/seals/t27os_T27osPtyLogic.json | 53 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 .trinity/seals/t27os_T27osKeys.json create mode 100644 .trinity/seals/t27os_T27osPtyLogic.json diff --git a/.trinity/seals/t27os_T27osKeys.json b/.trinity/seals/t27os_T27osKeys.json new file mode 100644 index 0000000000..cc3464fce5 --- /dev/null +++ b/.trinity/seals/t27os_T27osKeys.json @@ -0,0 +1,37 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+42a4860c4", + "closure": [ + { + "spec": "specs/t27os/agents.t27", + "spec_hash": "sha256:c4b89c1bd1dcb751fada2ae1f0330e250d17c5085ea73acdc38fac13548277ee" + }, + { + "spec": "specs/t27os/session_store.t27", + "spec_hash": "sha256:7d05d7dda14587458a77291c3d587e3f08516d8e678f0a4bd06dcaab7f6d1f2f" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:4d2ce37475c5fd9cfdbe564d68df7c92327f40be8d60faa04aa8382217dc60c5", + "gen_hash_rust": "sha256:65387d614d4714c7dff0227fb378c33bc7886c695cc42f4ea24458223de573c8", + "gen_hash_verilog": "sha256:9e29c63b98f00cd92e6b0466d356a9d3911bf444f26a652d5e2e6e6b576c6ade", + "gen_hash_zig": "sha256:d244fcdfb0f95fcf8a2742598dbc84d7e66c6c3aec8b4bc632483753564f8230", + "module": "T27osKeys", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-20T07:00:49Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:bc12487689ca25d46a0155a6cbb30e36983c4898a7e28486266d084c570f8d43", + "spec_path": "specs/t27os/keys.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 63, + "total": 63, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/t27os_T27osPtyLogic.json b/.trinity/seals/t27os_T27osPtyLogic.json new file mode 100644 index 0000000000..7e60f7c435 --- /dev/null +++ b/.trinity/seals/t27os_T27osPtyLogic.json @@ -0,0 +1,53 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+42a4860c4", + "closure": [ + { + "spec": "specs/t27os/agents.t27", + "spec_hash": "sha256:c4b89c1bd1dcb751fada2ae1f0330e250d17c5085ea73acdc38fac13548277ee" + }, + { + "spec": "specs/t27os/editors.t27", + "spec_hash": "sha256:9b4e3c083e7aa0d3d3b42ac77f6349820e123d34617abbdfc85358cd22420ede" + }, + { + "spec": "specs/t27os/keys.t27", + "spec_hash": "sha256:bc12487689ca25d46a0155a6cbb30e36983c4898a7e28486266d084c570f8d43" + }, + { + "spec": "specs/t27os/pane_state.t27", + "spec_hash": "sha256:c7c7ca610b0f69c66dd5518c93f045bfa3f666c1849c12ca2de0c6542d73d1fc" + }, + { + "spec": "specs/t27os/pty_proto.t27", + "spec_hash": "sha256:fe9dee96f99eccaa6273124bb9022a66ca916bc9a0f6c3f415c2f4b6ab964ab1" + }, + { + "spec": "specs/t27os/session_store.t27", + "spec_hash": "sha256:7d05d7dda14587458a77291c3d587e3f08516d8e678f0a4bd06dcaab7f6d1f2f" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:a1ba1cc53e826cbfe4123efe840b6b7d8ba3b3b9bcec14bcea82a64e6d77bad7", + "gen_hash_rust": "sha256:b1f9544872abbca303b2b4565a357e4134da53bf232b7efcf2d729d13c3a987a", + "gen_hash_verilog": "sha256:9c28b828e7de42515be178c72ec47fea0b3a20cb6840e9c1a4fbf8085862e08d", + "gen_hash_zig": "sha256:39ac7d141b3d9832cc4ec5e92e9f73e2cf030c4379c4f3253696d46f159efb62", + "module": "T27osPtyLogic", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-20T07:00:53Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:401e66c84055044c98546c093cbdb677ae5cb5203d524b01e172b37a7afc1e33", + "spec_path": "specs/t27os/pty_logic.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 34, + "total": 34, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file From fc01e475a06ebea7d696d35a4ed9cf0989838425 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:31:56 +0700 Subject: [PATCH 3/4] T27 OS keys: the Keychain only through /usr/bin/security, several keys per provider in the KEYS tab keys_host.t27 no longer calls SecItem*: the ad-hoc-signed window is a new code identity at every rebuild, so each read asked for the login password again. It runs /usr/bin/security (posix_spawn, pipes, poll with a timeout, waitpid): read = find-generic-password -w, delete = delete-generic-password, add = one line on the stdin of `security -i` (a key never on argv). SIGCHLD is set to default for a run (editors_host leaves it ignored, which breaks waitpid) and SIGPIPE is ignored while the line is written; a silent or chatty tool is killed. Security.framework is no longer linked. keys.t27 holds every decision, tested: argv building, the add line, the bytes `security -i` can carry (no space, quote, backslash, control), the exit-status table (44 = not found; quit is not a command there, EOF ends the session), output -> key, the scan rule (stop after two misses), and the KEYS decisions for several keys per provider: next free number, newest key to delete, "full", per-key check status, "6/7 valid", keys withheld from the server one by one. glass_window.t27: the KEYS row text and click handler only. add stores the next free account and never replaces; delete removes the newest key; check probes every key and keeps one status per key. Refs #11241 Co-Authored-By: Claude Sonnet 5.5 --- docs/t27os/run-local.md | 13 + specs/t27os/glass_window.t27 | 165 +++++--- specs/t27os/keys.t27 | 760 ++++++++++++++++++++++++++++++++++- specs/t27os/keys_host.t27 | 448 ++++++++++++++------- 4 files changed, 1146 insertions(+), 240 deletions(-) diff --git a/docs/t27os/run-local.md b/docs/t27os/run-local.md index 2787e81255..908830a5d8 100644 --- a/docs/t27os/run-local.md +++ b/docs/t27os/run-local.md @@ -21,6 +21,19 @@ The window does not start the server and does not bundle it. They meet on one Un `glass_window.t27`), the server from `$HOME` (`socket_path` in `pty_proto.t27`). With no server the Claude Code 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 +**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 +again. `/usr/bin/security` is signed by Apple and never changes, so a read of an item it created shows no +dialog across rebuilds. A key is never put on argv (`ps` shows it): an add writes one line to the stdin of +`security -i`, and refuses a key with white space, a quote or a backslash. An add never replaces a key +(the account must be free); a delete removes the newest key only. Items that an older SecItem build made +trust that build, not `security`, so a read of one may still ask once: remove it in Keychain Access and add +the key again in the KEYS tab. + ## 2. Prerequisites - Apple silicon, Xcode installed and selected (`xcode-select -p`): `ld`, the SDK and `codesign`. Command Line diff --git a/specs/t27os/glass_window.t27 b/specs/t27os/glass_window.t27 index 2f26aa3a22..606e1cf963 100644 --- a/specs/t27os/glass_window.t27 +++ b/specs/t27os/glass_window.t27 @@ -94,7 +94,6 @@ use t27os::keys::KE_CHAR; use t27os::keys::ST_STORED; use t27os::keys::ST_VALID; use t27os::keys::ST_MISSING; -use t27os::keys::STATUS_NAMES; use t27os::keys::ky_row_top; use t27os::keys::ky_btn_x; use t27os::keys::ky_hit; @@ -105,19 +104,27 @@ use t27os::keys::on_keys_link; use t27os::keys::CIRCUIT_FAILS; use t27os::keys::trim_key; use t27os::keys::mask_key; -use t27os::keys::key_fits; +use t27os::keys::sec_key_safe; +use t27os::keys::key_index; +use t27os::keys::slot_count; +use t27os::keys::next_free_key; +use t27os::keys::newest_key; +use t27os::keys::ky_set_word; +use t27os::keys::provider_status; +use t27os::keys::key_withheld; +use t27os::keys::key_status_text; +use t27os::keys::scan_goes_on; use t27os::keys::probe_has; -use t27os::keys::bad_mask; +use t27os::keys::bad_mask_keys; use t27os::keys::profile_why; use t27os::keys::PROFILE_PROVIDER; use t27os::keys::provider_name; use t27os::keys::status_after_probe; use t27os::keys::circuit_fails; use t27os::keys::status_shown; -use t27os::keys_host::kc_put; -use t27os::keys_host::kc_get; +use t27os::keys_host::kc_put_n; use t27os::keys_host::kc_get_n; -use t27os::keys_host::kc_del_all; +use t27os::keys_host::kc_del_n; use t27os::keys_host::kc_has; use t27os::keys_host::probe; use t27os::keys_host::push_keys; @@ -2333,9 +2340,10 @@ fn bytes_w(kit: [48]u64, s: []const u8, n: usize, size: f64, weight: f64, mono: return w; } -/// The Keychain is read here and nowhere else in the window: how many keys each provider holds (stored[p], -/// 0 to MAX_KEYS, every numbered account is read) and the masked text of its first key (16 bytes a -/// provider). The full key is wiped from the buffer before the next one is read. +/// The Keychain is read here and nowhere else in the window: which numbers each provider holds (stored[p] is a +/// bit mask, bit k set = key k+1 exists; keys.t27 slot_count counts it) and the masked text of its newest key, +/// the highest number (16 bytes a provider). A scan stops after two misses in a row (keys.t27 scan_goes_on). +/// The full key is wiped from the buffer before the next one is read. fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { var key : [256]u8 = undefined; var p : u32 = 0; @@ -2344,11 +2352,15 @@ fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { mlen[p] = 0; stored[p] = 0; var k : u32 = 0; - while (k < MAX_KEYS) { + var misses : u32 = 0; + while (scan_goes_on(k, misses)) { const n : usize = kc_get_n(p, k, key[0..256]); if (n > 0) { - if (stored[p] == 0) { mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); } - stored[p] = stored[p] + 1; + misses = 0; + mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); + stored[p] = stored[p] + (1 << k); + } else { + misses = misses + 1; } var i : usize = 0; while (i < 256) { @@ -2361,14 +2373,18 @@ fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { } } -/// Hand the server every stored key with its number (keys_host push_keys), then take back the first key of each -/// provider whose last check failed, as the check button does: a refresh must not undo that. +/// Hand the server every stored key with its number (keys_host push_keys), then take back each key whose last +/// check failed (keys.t27 key_withheld), as the check button does: a refresh must not undo that. kst holds one +/// status per key (keys.t27 key_index). fn keys_sync(fd: i32, kst: []const u32, out: []u8) -> void { push_keys(fd); - const bad : u32 = bad_mask(kst); var p : u32 = 0; while (p < PROVIDER_COUNT) { - if ((bad >> p) % 2 == 1) { client_set_key(fd, setkey_byte(p, 0), "", out); } + var k : u32 = 0; + while (k < MAX_KEYS) { + if (key_withheld(kst[key_index(p, k)])) { client_set_key(fd, setkey_byte(p, k), "", out); } + k = k + 1; + } p = p + 1; } } @@ -2435,19 +2451,19 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto while (p < PROVIDER_COUNT) { const y : f64 = ky_row_top(t, p); const has : bool = stored[p] > 0; - const st : u32 = status_shown(has, kst[p], kfail[p], kopen[p], now); + const st : u32 = status_shown(has, provider_status(kst, p, stored[p]), kfail[p], kopen[p], now); cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); n = provider_name(p, w[0..48]); const nw : f64 = draw_bytes(c, kit, w[0..48], n, x, y + 14.0, 11.0, WEIGHT_SEMIBOLD, false); - // how many keys the provider holds ("zai 7 keys"), right after its name - n = key_count_text(stored[p], w[0..48]); + // how many keys the provider holds ("zai 7 keys"), right after its name; the line below shows the newest key, masked + n = key_count_text(slot_count(stored[p]), w[0..48]); if (n > 0) { cg_fill_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); draw_bytes(c, kit, w[0..48], n, x + nw + 8.0, y + 14.0, 9.5, 0.0, false); } cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); draw_dot(c, status_dot(st), x + 112.0, y + 10.0, 1.0); - n = csv_field(STATUS_NAMES, st, w[0..48]); + n = key_status_text(st, kst, p, stored[p], w[0..48]); cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); draw_bytes(c, kit, w[0..48], n, x + 124.0, y + 14.0, 9.5, 0.0, false); // the key as typed (masked, with a caret) while it is being entered; else the stored key, masked @@ -2466,9 +2482,8 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto } const hit : u32 = ky_hit(mx, my, l, QP_W, QP_PAD, t); const by : f64 = y + KY_BTN_DY; - var setw : u32 = 1; - if (has) { setw = 2; } - draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_SET), by, setw, false, true, hit == p * 4 + B_SET); + const setw : u32 = ky_set_word(stored[p]); + draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_SET), by, setw, false, setw == 1, hit == p * 4 + B_SET); var delw : u32 = 3; if (karm == p) { delw = 4; } draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_DELETE), by, delw, karm == p, has, hit == p * 4 + B_DELETE); @@ -2487,7 +2502,7 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto pi = pi + 1; } var wy : [96]u8 = undefined; - n = profile_why(sm, bad_mask(kst[0..9]), wy[0..96]); + n = profile_why(sm, bad_mask_keys(kst, stored), wy[0..96]); cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); draw_bytes(c, kit, wy[0..96], n, x, sh - 14.0, 9.5, 0.0, false); // the OPEN IN strip: the editors found on this Mac (editors.t27), one chip each, below the rows @@ -3603,14 +3618,16 @@ pub fn main() -> void { var dacc : f64 = 0.0; var dgo : u32 = 0; var dnow : bool = true; - // the KEYS panel: open, which providers hold a key, their masked text, the last check's status, the - // failure count and when the circuit opened, the provider being typed (and the typed bytes, wiped after - // use), the DELETE that waits for its second click, the check in flight, and the editors found + // the KEYS panel: open, which numbers each provider holds (a bit mask: bit k = key k+1) and the masked text + // of its newest key, the last check's status of every key (keys.t27 key_index: provider * MAX_KEYS + key), + // the failure count and when the circuit opened (per provider), the provider being typed (and the typed + // bytes, wiped after use), the DELETE that waits for its second click, the check in flight (the provider + // and the next number it reads), and the editors found var kyp : bool = false; var kstored : [9]u32 = undefined; var kmask : [144]u8 = undefined; var kmlen : [9]u32 = undefined; - var kst : [9]u32 = undefined; + var kst : [72]u32 = undefined; var kfail : [9]u32 = undefined; var kopen : [9]u64 = undefined; var kbuf : [256]u8 = undefined; @@ -3636,16 +3653,21 @@ pub fn main() -> void { var esp : u32 = 0; var eesc : u64 = 0; var kpend : u32 = KY_NONE; + var kbk : u32 = 0; var kpre : u32 = 0; var ki : u32 = 0; while (ki < 9) { kstored[ki] = 0; kmlen[ki] = 0; - kst[ki] = ST_STORED; kfail[ki] = 0; kopen[ki] = 0; ki = ki + 1; } + var kz : u32 = 0; + while (kz < 72) { + kst[kz] = ST_STORED; + kz = kz + 1; + } var khome : [256]u8 = undefined; var kdir : [512]u8 = undefined; var ktmp : [256]u8 = undefined; @@ -3779,7 +3801,7 @@ pub fn main() -> void { ctl = C_DISMISS; } } else if (kyp and qp == false and dp == false and on_panel(mx, my, sw, WS_TOP)) { - // K. the KEYS panel: close, a row's add / replace / delete (asks twice) / check, an editor chip + // K. the KEYS panel: close, a row's add (the next free number) / delete (the newest key, asks twice) / check (every key), an editor chip const kl : f64 = qp_left(sw); if (on_panel_close(mx, my, sw, WS_TOP)) { kyp = false; @@ -3790,7 +3812,7 @@ pub fn main() -> void { const kh : u32 = ky_hit(mx, my, kl, QP_W, QP_PAD, WS_TOP); const kq : u32 = kh / 4; if (kh != KY_NONE) { - const kact : u32 = ky_click(kh % 4, kstored[kq] > 0, probe_has(kq), karm, kq); + const kact : u32 = ky_click(kh % 4, kstored[kq] > 0, next_free_key(kstored[kq]) >= MAX_KEYS, probe_has(kq), karm, kq); if (kact == KA_EDIT) { kedit = kq; klen = 0; @@ -3800,21 +3822,20 @@ pub fn main() -> void { kedit = KY_NONE; klen = 0; } else if (kact == KA_DELETE) { - // the row's DELETE removes every key of the provider, from the Keychain and the server - kc_del_all(kq); + // the row's DELETE removes the newest key only (keys.t27 newest_key), from the Keychain and the server + const kdn : u32 = newest_key(kstored[kq]); + if (kdn < MAX_KEYS) { + if (kc_del_n(kq, kdn)) { + kst[key_index(kq, kdn)] = ST_STORED; + if (tfd >= 0) { client_set_key(tfd, setkey_byte(kq, kdn), "", tout[0..4100]); } + } + } karm = KY_NONE; - kst[kq] = ST_STORED; kfail[kq] = 0; keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); - if (tfd >= 0) { - var kdk : u32 = 0; - while (kdk < MAX_KEYS) { - client_set_key(tfd, setkey_byte(kq, kdk), "", tout[0..4100]); - kdk = kdk + 1; - } - } } else if (kact == KA_CHECK) { kbusy = kq; + kbk = 0; kpend = 0; karm = KY_NONE; } else { @@ -3930,7 +3951,7 @@ pub fn main() -> void { dp = false; keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); // the refresh also hands the server every numbered key again (a server that restarted forgot them) - if (tfd >= 0) { keys_sync(tfd, kst[0..9], tout[0..4100]); } + if (tfd >= 0) { keys_sync(tfd, kst[0..72], tout[0..4100]); } const khn : usize = ns_bytes(home_dir(), khome[0..256]); kemask = installed_mask(khome[0..khn]); ktpick = term_pick(term_mask()); @@ -4045,11 +4066,15 @@ pub fn main() -> void { kbuf[@as(usize, klen)] = 0; } } else if (kk == KE_SAVE) { - if (key_fits(kbuf[0 .. @as(usize, klen)])) { - if (kc_put(kedit, kbuf[0 .. @as(usize, klen)])) { - kst[kedit] = ST_STORED; - kfail[kedit] = 0; - if (tfd >= 0) { client_set_key(tfd, kedit, kbuf[0 .. @as(usize, klen)], tout[0..4100]); } + if (sec_key_safe(kbuf[0 .. @as(usize, klen)])) { + // the key takes the lowest free number of its provider, never an existing one + const ksn : u32 = next_free_key(kstored[kedit]); + if (ksn < MAX_KEYS) { + if (kc_put_n(kedit, ksn, kbuf[0 .. @as(usize, klen)])) { + kst[key_index(kedit, ksn)] = ST_STORED; + kfail[kedit] = 0; + if (tfd >= 0) { client_set_key(tfd, setkey_byte(kedit, ksn), kbuf[0 .. @as(usize, klen)], tout[0..4100]); } + } } keys_wipe(kbuf[0..256]); keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); @@ -4141,30 +4166,37 @@ pub fn main() -> void { if (used == false) { msg1(app, s_send, ev); } ev = msg_event(app, s_next, 0xffffffffffffffff, 0, mode, true); } - // K. a check button: the frame after the click shows "checking", then the probe runs (it blocks up to - // PROBE_TIMEOUT_S) and its HTTP answer sets the status and feeds the circuit breaker (keys.t27) + // K. a check button: the frame after the click shows "checking", then one number of the provider is + // read and probed per frame (a probe blocks up to PROBE_TIMEOUT_S); its HTTP answer sets that key's status + // and feeds the circuit breaker (keys.t27). Every stored key of the provider is checked, none is skipped. if (kbusy != KY_NONE) { if (kpend == 0) { kpend = 1; + } else if (kbk >= MAX_KEYS) { + kbusy = KY_NONE; + kpend = KY_NONE; + kbk = 0; } else { - const kn2 : usize = kc_get(kbusy, ktmp[0..256]); - var http : u32 = 1; - if (kn2 > 0) { http = probe(kbusy, ktmp[0 .. kn2]); } - kfail[kbusy] = circuit_fails(kfail[kbusy], http); - if (kfail[kbusy] >= CIRCUIT_FAILS and kopen[kbusy] + 60 <= now) { kopen[kbusy] = now; } - kst[kbusy] = status_after_probe(kst[kbusy], http); - // M12: a key that failed its check is withheld from the server, so the next claude pane - // takes the next profile (keys.t27 pick_profile); a key that passed is handed over again - if (tfd >= 0 and kn2 > 0) { - if (bad_mask(kst[0..9]) / (1 << kbusy) % 2 == 1) { - client_set_key(tfd, kbusy, "", tout[0..4100]); - } else { - client_set_key(tfd, kbusy, ktmp[0 .. kn2], tout[0..4100]); + const kn2 : usize = kc_get_n(kbusy, kbk, ktmp[0..256]); + if (kn2 > 0) { + const http : u32 = probe(kbusy, ktmp[0 .. kn2]); + const kix : usize = key_index(kbusy, kbk); + kfail[kbusy] = circuit_fails(kfail[kbusy], http); + if (kfail[kbusy] >= CIRCUIT_FAILS and kopen[kbusy] + 60 <= now) { kopen[kbusy] = now; } + kst[kix] = status_after_probe(kst[kix], http); + // M12: a key that failed its check is withheld from the server, so rotation skips it and the + // next claude pane takes the next profile when none is left (keys.t27 pick_profile); a key + // that passed is handed over again + if (tfd >= 0) { + if (key_withheld(kst[kix])) { + client_set_key(tfd, setkey_byte(kbusy, kbk), "", tout[0..4100]); + } else { + client_set_key(tfd, setkey_byte(kbusy, kbk), ktmp[0 .. kn2], tout[0..4100]); + } } } keys_wipe(ktmp[0..256]); - kbusy = KY_NONE; - kpend = KY_NONE; + kbk = kbk + 1; } } // K. pre-flight (M12): once at start, check the stored key behind each claude profile, in the order @@ -4174,6 +4206,7 @@ pub fn main() -> void { kpre = kpre + 1; if (probe_has(kpp) and kc_has(kpp)) { kbusy = kpp; + kbk = 0; kpend = 0; } } @@ -4455,14 +4488,14 @@ pub fn main() -> void { fwait = fg_wait(fquiet, tlink == PANE_LIVE and topen != PANE_NONE, FRAME_S); msg_flag(tx_cls, s_quiet, true); if (fdraw and narrow) { - draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..9], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); + draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..72], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); const whole : u64 = cg_image(cc); const img : u64 = cg_crop(whole, 0.0, 0.0, sw * RETINA, sh * RETINA); msg1(layer, s_contents, img); cg_image_release(img); cg_image_release(whole); } else if (fdraw) { - draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..9], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); + draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..72], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); const whole : u64 = cg_image(c); const img : u64 = cg_crop(whole, 0.0, 0.0, sw * WIDE_RETINA, sh * WIDE_RETINA); msg1(layer, s_contents, img); diff --git a/specs/t27os/keys.t27 b/specs/t27os/keys.t27 index d3f4f6ed75..08109a1512 100644 --- a/specs/t27os/keys.t27 +++ b/specs/t27os/keys.t27 @@ -7,9 +7,11 @@ ; plus cooldown), ProviderCircuitBreaker, ProviderStatusService, KeychainSecrets. ; Storage is the macOS Keychain, service "ai.t27.os", one item per key (account = provider name for key 1, ; "#2" .. "#8" for the others, keychain_account_n). -; Never a plain file and never the repo. This spec holds no key and no syscall: the Keychain, the probe and -; the child environment are host calls (keys_host.t27, pty_server.t27); only the decisions live here, so -; they run their tests (a module with @extern imports cannot). Every test uses fake values. +; Never a plain file and never the repo. The Keychain is reached only by running /usr/bin/security (the +; section "the Keychain through /usr/bin/security" at the end), never through SecItem calls. This spec holds +; no key and no syscall: the Keychain, the probe and the child environment are host calls (keys_host.t27, +; pty_server.t27); only the decisions live here, so they run their tests (a module with @extern imports +; cannot). Every test uses fake values. ; Refs gHashTag/t27#10782. ; ---- MAP: the app.t27.ai account and its keys (read 2026-10-19, read-only; M12 follow-up) ----------------- ; R = 999-multibots-telegraf apps/vibee-editor/render; Q = BrowserOS trios/agent-server/apps/server/src/api @@ -34,6 +36,7 @@ module T27osKeys; use t27os::agents::AGENT_COUNT; +use t27os::editors::same_bytes; use t27os::session_store::csv_field; use t27os::session_store::put_bytes; use t27os::session_store::put_dec; @@ -467,7 +470,7 @@ pub const KEY_BACKSPACE : u32 = 51; pub const KEY_ESC : u32 = 53; pub const KEY_V : u32 = 9; -pub const KEYS_WORDS : str = "KEYS,add,replace,delete,confirm,check,checking,no key,paste or type a key,enter saves esc cancels,open in"; +pub const KEYS_WORDS : str = "KEYS,add,full,delete,confirm,check,checking,no key,paste or type a key,enter saves esc cancels,open in"; /// Top edge of provider row r in a panel whose top is t. pub fn ky_row_top(t: f64, r: u32) -> f64 { @@ -503,10 +506,14 @@ pub fn on_keys_link(x: f64, y: f64, side_w: f64, sh: f64, foot_h: f64, doc_w: f6 return x >= right - KY_LINK_W and x <= right and y >= sh - foot_h and y <= sh - 6.0; } -/// What a click on button b of a row means. `stored`: the row has a key; `probed`: its provider has a check; -/// `armed`: the row whose DELETE was clicked once (KY_NONE when none); `row`: the clicked row. -pub fn ky_click(b: u32, stored: bool, probed: bool, armed: u32, row: u32) -> u32 { - if (b == B_SET) { return KA_EDIT; } +/// What a click on button b of a row means. `stored`: the row has a key; `full`: it holds MAX_KEYS keys, so +/// "add" has no number left; `probed`: its provider has a check; `armed`: the row whose DELETE was clicked +/// once (KY_NONE when none); `row`: the clicked row. +pub fn ky_click(b: u32, stored: bool, full: bool, probed: bool, armed: u32, row: u32) -> u32 { + if (b == B_SET) { + if (full) { return KA_NONE; } + return KA_EDIT; + } if (stored == false) { return KA_NONE; } if (b == B_DELETE) { if (armed == row) { return KA_DELETE; } @@ -528,9 +535,11 @@ pub fn ky_key(code: u32, cmd: bool) -> u32 { return KE_CHAR; } -/// A typed character is taken when it is printable ASCII without a space and the buffer has room. +/// A typed character is taken when it is printable ASCII without a space, a quote or a backslash (the three +/// that `security -i` reads as syntax, sec_word_ok) and the buffer has room. pub fn ky_accepts(len: u32, ch: u32) -> bool { if (len >= MAX_KEY_CHARS) { return false; } + if (ch == 34 or ch == 39 or ch == 92) { return false; } return ch > 32 and ch < 127; } @@ -558,18 +567,21 @@ test a_click_finds_its_row_and_button { } test delete_asks_twice { - assert ky_click(B_DELETE, true, true, KY_NONE, 3) == KA_ARM; - assert ky_click(B_DELETE, true, true, 3, 3) == KA_DELETE; - assert ky_click(B_DELETE, true, true, 2, 3) == KA_ARM; - assert ky_click(B_DELETE, false, true, KY_NONE, 3) == KA_NONE; + assert ky_click(B_DELETE, true, false, true, KY_NONE, 3) == KA_ARM; + assert ky_click(B_DELETE, true, false, true, 3, 3) == KA_DELETE; + assert ky_click(B_DELETE, true, false, true, 2, 3) == KA_ARM; + assert ky_click(B_DELETE, false, false, true, KY_NONE, 3) == KA_NONE; + assert ky_click(B_DELETE, true, true, true, 3, 3) == KA_DELETE; } -test add_always_edits_and_check_needs_a_key_and_a_probe { - assert ky_click(B_SET, false, false, KY_NONE, 0) == KA_EDIT; - assert ky_click(B_SET, true, true, KY_NONE, 0) == KA_EDIT; - assert ky_click(B_CHECK, true, true, KY_NONE, 0) == KA_CHECK; - assert ky_click(B_CHECK, false, true, KY_NONE, 0) == KA_NONE; - assert ky_click(B_CHECK, true, false, KY_NONE, 7) == KA_NONE; +test add_edits_until_the_provider_is_full_and_check_needs_a_key_and_a_probe { + assert ky_click(B_SET, false, false, false, KY_NONE, 0) == KA_EDIT; + assert ky_click(B_SET, true, false, true, KY_NONE, 0) == KA_EDIT; + assert ky_click(B_SET, true, true, true, KY_NONE, 0) == KA_NONE; + assert ky_click(B_CHECK, true, false, true, KY_NONE, 0) == KA_CHECK; + assert ky_click(B_CHECK, true, true, true, KY_NONE, 0) == KA_CHECK; + assert ky_click(B_CHECK, false, false, true, KY_NONE, 0) == KA_NONE; + assert ky_click(B_CHECK, true, false, false, KY_NONE, 7) == KA_NONE; } test keys_while_typing { @@ -583,6 +595,10 @@ test keys_while_typing { assert ky_accepts(0, 32) == false; assert ky_accepts(0, 10) == false; assert ky_accepts(MAX_KEY_CHARS, 65) == false; + assert ky_accepts(0, 34) == false; + assert ky_accepts(0, 39) == false; + assert ky_accepts(0, 92) == false; + assert ky_accepts(0, 45) and ky_accepts(0, 95) and ky_accepts(0, 35); } test a_pasted_text_loses_its_white_space { @@ -1758,3 +1774,709 @@ test the_zai_check_is_one_message_to_the_flash_model { assert b[0] == 123 and b[n - 1] == 125; assert probe_body(P_OPENAI, b[0..160]) == 0; } + +// ---- the Keychain through /usr/bin/security (gHashTag/t27#11241) -------------------------------------------- +// WHY. T27 OS is signed ad hoc, so every rebuild is a new code identity for macOS: a SecItemCopyMatching read +// asks for the login password again, once per item, and "Always Allow" does not survive the next build +// (measured 2026-10-20: 7 items, 7 dialogs, every load). The items were made with /usr/bin/security, so that +// tool is the application they trust; it is signed by Apple and does not change when T27 OS is rebuilt. +// keys_host.t27 therefore reaches the Keychain only by running it. Everything it needs decided is here: the +// argument list, the one line that goes to `security -i` (a key never travels on argv, where ps shows it), +// which bytes may go in that line, what an exit status means, how an output becomes a key. +// Measured on macOS 26 (2026-10-20), with commands that touch no item: +// - in `-i` mode a word ends at white space, a backslash escapes the next byte and a word that starts with a +// quote is quoted; `#`, `;`, `$`, `` ` ``, `(` and the rest are plain bytes. So a key with white space, a +// control byte, a quote or a backslash is refused (sec_word_ok); `#` stays, the account "zai#2" needs it. +// - `quit` is not a command there (it prints "unknown command" and sets the exit status to 1), so the line +// is followed by end of input, and the exit status of `security -i` is the status of its LAST command. +// - an exit status is the low byte of the OSStatus: -25300 (item not found) is 44, -25299 (duplicate) 45, +// -25293 (authorisation failed) 51, -25308 (interaction not allowed) 36, -128 (user cancelled) 128. +// - a line of 1500 bytes is read whole. +// An add never carries -U: an account that exists answers 45, so "add" cannot replace a key. + +pub const SEC_TOOL : str = "/usr/bin/security"; +pub const SEC_OP_READ : u32 = 0; +pub const SEC_OP_WRITE : u32 = 1; +pub const SEC_OP_DELETE : u32 = 2; +/// The most words an argument list holds (the tool, the command, -s, service, -a, account, -w). +pub const SEC_MAX_WORDS : u32 = 8; +/// Bytes of standard output the host reads: a key (MAX_KEY_CHARS), its newline and one more. A full buffer is +/// an overflow, never a key. +pub const SEC_OUT_CAP : u32 = 256; +/// How long the host waits for the tool before it kills it (a dialog, a stuck securityd). +pub const SEC_TIMEOUT_MS : u32 = 8000; +/// The exit code sec_exit_code gives a process that a signal ended. +pub const SEC_SIGNALED : u32 = 256; + +/// The four things a run of the tool can come to. +pub const SEC_OK : u32 = 0; +pub const SEC_NOT_FOUND : u32 = 1; +pub const SEC_DENIED : u32 = 2; +pub const SEC_FAILED : u32 = 3; + +/// The exit code a waitpid status holds: the second byte of a normal exit, SEC_SIGNALED when a signal ended +/// the process (or stopped it: the low seven bits are not zero). +pub fn sec_exit_code(ws: u32) -> u32 { + if (ws % 128 != 0) { return SEC_SIGNALED; } + return (ws / 256) % 256; +} + +/// What an exit code means: 0 done, 44 no such item, 36 / 51 / 128 the Keychain wanted the user and did not +/// get a yes (the item does not trust this tool), anything else a failure. Never a key. +pub fn sec_state(code: u32) -> u32 { + if (code == 0) { return SEC_OK; } + if (code == 44) { return SEC_NOT_FOUND; } + if (code == 36 or code == 51 or code == 128) { return SEC_DENIED; } + return SEC_FAILED; +} + +/// The state of a run: a run the host cut short (timeout, overflow) failed whatever status it left. +pub fn sec_verdict(code: u32, cut: bool) -> u32 { + if (cut) { return SEC_FAILED; } + return sec_state(code); +} + +/// A delete is done when the item is gone: deleted now, or not there to begin with. +pub fn sec_gone(state: u32) -> bool { + return state == SEC_OK or state == SEC_NOT_FOUND; +} + +/// A word that can go in the line `security -i` reads: not empty, printable ASCII without a space (white +/// space ends a word), a quote or a backslash (syntax). +pub fn sec_word_ok(w: []const u8) -> bool { + if (w.len == 0) { return false; } + var i : usize = 0; + while (i < w.len) { + if (w[i] <= 32 or w[i] >= 127) { return false; } + if (w[i] == 34 or w[i] == 39 or w[i] == 92) { return false; } + i = i + 1; + } + return true; +} + +/// A key that may be stored: key_fits (non-empty, within MAX_KEY_CHARS, printable) and one clean word. +pub fn sec_key_safe(key: []const u8) -> bool { + return key_fits(key) and sec_word_ok(key); +} + +/// Append `text` and a NUL to the packed argument words at `at`, remember where it starts as word k; the next +/// free offset, 0 when it does not fit. +fn sec_push(words: []u8, offs: []u32, k: u32, at: usize, text: []const u8) -> usize { + if (at + text.len + 1 > words.len) { return 0; } + offs[k] = @as(u32, @intCast(at)); + const n : usize = put_bytes(words, at, text); + words[n] = 0; + return n + 1; +} + +/// The argument list of one run, as NUL-ended words packed into `words`; offs[i] is where word i starts and +/// the count of words comes back (0 when the op is unknown, a name is empty or the words do not fit). +/// READ /usr/bin/security find-generic-password -s -a -w +/// DELETE /usr/bin/security delete-generic-password -s -a +/// WRITE /usr/bin/security -i (the command goes to standard input, sec_add_line) +/// The host points argv[i] at words + offs[i]; offs needs SEC_MAX_WORDS entries. +pub fn sec_argv(op: u32, service: []const u8, account: []const u8, words: []u8, offs: []u32) -> u32 { + if (offs.len < SEC_MAX_WORDS) { return 0; } + var at : usize = sec_push(words, offs, 0, 0, SEC_TOOL); + if (at == 0) { return 0; } + if (op == SEC_OP_WRITE) { + at = sec_push(words, offs, 1, at, "-i"); + if (at == 0) { return 0; } + return 2; + } + if (op != SEC_OP_READ and op != SEC_OP_DELETE) { return 0; } + if (service.len == 0 or account.len == 0) { return 0; } + if (op == SEC_OP_READ) { + at = sec_push(words, offs, 1, at, "find-generic-password"); + } else { + at = sec_push(words, offs, 1, at, "delete-generic-password"); + } + if (at == 0) { return 0; } + at = sec_push(words, offs, 2, at, "-s"); + if (at == 0) { return 0; } + at = sec_push(words, offs, 3, at, service); + if (at == 0) { return 0; } + at = sec_push(words, offs, 4, at, "-a"); + if (at == 0) { return 0; } + at = sec_push(words, offs, 5, at, account); + if (at == 0) { return 0; } + if (op == SEC_OP_DELETE) { return 6; } + at = sec_push(words, offs, 6, at, "-w"); + if (at == 0) { return 0; } + return 7; +} + +/// The one line the host writes to `security -i`, newline included, that adds `key` under service and account +/// (label "/", the label keychain_label_n gives). 0 when the key is not sec_key_safe, a name +/// is not a clean word or the line does not fit. No -U: an existing account makes the add fail (45). +pub fn sec_add_line(service: []const u8, account: []const u8, key: []const u8, out: []u8) -> usize { + if (sec_word_ok(service) == false or sec_word_ok(account) == false) { return 0; } + if (sec_key_safe(key) == false) { return 0; } + if (38 + 2 * service.len + 2 * account.len + key.len > out.len) { return 0; } + var n : usize = put_bytes(out, 0, "add-generic-password -s "); + n = put_bytes(out, n, service); + n = put_bytes(out, n, " -a "); + n = put_bytes(out, n, account); + n = put_bytes(out, n, " -l "); + n = put_bytes(out, n, service); + n = put_bytes(out, n, "/"); + n = put_bytes(out, n, account); + n = put_bytes(out, n, " -w "); + n = put_bytes(out, n, key); + out[n] = 10; + return n + 1; +} + +/// The key in what `find-generic-password -w` printed: one trailing newline off, then it must pass key_fits +/// and fit `out` (a key is never cut to fit). Its length, 0 when there is none. +pub fn sec_key_from_output(raw: []const u8, out: []u8) -> usize { + var n : usize = raw.len; + if (n > 0 and raw[n - 1] == 10) { n = n - 1; } + if (n == 0 or n > out.len) { return 0; } + if (key_fits(raw[0..n]) == false) { return 0; } + var i : usize = 0; + while (i < n) { + out[i] = raw[i]; + i = i + 1; + } + return n; +} + +/// Is word `at` of the packed words `text` followed by its NUL? +fn sec_word_is(w: []const u8, at: u32, text: []const u8) -> bool { + const s : usize = @as(usize, at); + if (s + text.len >= w.len) { return false; } + if (same_bytes(w[s .. s + text.len], text) == false) { return false; } + return w[s + text.len] == 0; +} + +test a_read_runs_find_generic_password_for_one_account { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 255; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + var a : [32]u8 = undefined; + const an : usize = keychain_account_n(P_ZAI, 1, a[0..32]); + assert an == 5; + const n : u32 = sec_argv(SEC_OP_READ, KEYCHAIN_SERVICE, a[0..an], w[0..160], o[0..8]); + assert n == 7; + assert o[0] == 0; + assert sec_word_is(w[0..160], o[0], "/usr/bin/security"); + assert sec_word_is(w[0..160], o[1], "find-generic-password"); + assert sec_word_is(w[0..160], o[2], "-s"); + assert sec_word_is(w[0..160], o[3], "ai.t27.os"); + assert sec_word_is(w[0..160], o[4], "-a"); + assert sec_word_is(w[0..160], o[5], "zai#2"); + assert sec_word_is(w[0..160], o[6], "-w"); + assert o[1] == 18; +} + +test a_delete_names_the_account_and_never_asks_for_the_secret { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 0; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + const n : u32 = sec_argv(SEC_OP_DELETE, "ai.t27.os", "zai", w[0..160], o[0..8]); + assert n == 6; + assert sec_word_is(w[0..160], o[1], "delete-generic-password"); + assert sec_word_is(w[0..160], o[3], "ai.t27.os"); + assert sec_word_is(w[0..160], o[5], "zai"); +} + +test a_write_runs_the_interactive_tool_and_puts_nothing_secret_on_argv { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 0; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + const n : u32 = sec_argv(SEC_OP_WRITE, "", "", w[0..160], o[0..8]); + assert n == 2; + assert sec_word_is(w[0..160], o[0], "/usr/bin/security"); + assert sec_word_is(w[0..160], o[1], "-i"); +} + +test an_argument_list_that_cannot_be_built_comes_back_empty { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 0; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + assert sec_argv(SEC_OP_READ, "ai.t27.os", "", w[0..160], o[0..8]) == 0; + assert sec_argv(SEC_OP_DELETE, "", "zai", w[0..160], o[0..8]) == 0; + assert sec_argv(9, "ai.t27.os", "zai", w[0..160], o[0..8]) == 0; + assert sec_argv(SEC_OP_READ, "ai.t27.os", "zai", w[0..40], o[0..8]) == 0; + assert sec_argv(SEC_OP_READ, "ai.t27.os", "zai", w[0..160], o[0..4]) == 0; + assert sec_argv(SEC_OP_READ, "ai.t27.os", "zai", w[0..160], o[0..8]) == 7; +} + +test the_add_line_carries_the_key_to_standard_input_with_the_label_of_the_item { + var line : [320]u8 = undefined; + var z : usize = 0; + while (z < 320) { + line[z] = 0; + z = z + 1; + } + const n : usize = sec_add_line("ai.t27.os", "zai#2", "sk-fake-0000.abc_1", line[0..320]); + assert n == 84; + assert same_bytes(line[0..n], "add-generic-password -s ai.t27.os -a zai#2 -l ai.t27.os/zai#2 -w sk-fake-0000.abc_1\n"); + assert line[n - 1] == 10; + var lab : [48]u8 = undefined; + const ln : usize = keychain_label_n(P_ZAI, 1, lab[0..48]); + assert ln == 15; + assert same_bytes(line[46 .. 46 + ln], lab[0..ln]); +} + +test a_key_the_line_cannot_carry_is_refused { + var line : [320]u8 = undefined; + var z : usize = 0; + while (z < 320) { + line[z] = 0; + z = z + 1; + } + var k : [8]u8 = undefined; + k[0] = 115; + k[1] = 107; + k[2] = 45; + k[3] = 97; + k[4] = 98; + k[5] = 99; + k[6] = 100; + k[7] = 0; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 69; + k[3] = 32; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 34; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 39; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 92; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 10; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 127; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 200; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 35; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) > 0; + assert sec_add_line("ai.t27.os", "zai", k[0..0], line[0..320]) == 0; + assert sec_add_line("ai.t27.os", "", k[0..7], line[0..320]) == 0; + assert sec_add_line("", "zai", k[0..7], line[0..320]) == 0; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..40]) == 0; +} + +test a_key_is_safe_when_it_fits_and_is_one_clean_word { + assert sec_key_safe("sk-ant-api03-AbC_dEf.123"); + assert sec_key_safe("sk-or-v1-0000+/="); + assert sec_key_safe("a#b"); + assert sec_key_safe("") == false; + assert sec_key_safe("sk fake") == false; + assert sec_key_safe("sk\tfake") == false; + assert sec_word_ok("zai#2"); + assert sec_word_ok("ai.t27.os"); + assert sec_word_ok("") == false; + var big : [255]u8 = undefined; + var i : usize = 0; + while (i < 255) { + big[i] = 97; + i = i + 1; + } + assert sec_key_safe(big[0..254]); + assert sec_key_safe(big[0..255]) == false; +} + +test an_exit_status_says_what_the_tool_did { + assert sec_exit_code(0) == 0; + assert sec_exit_code(44 * 256) == 44; + assert sec_exit_code(1 * 256) == 1; + assert sec_exit_code(9) == SEC_SIGNALED; + assert sec_exit_code(127 + 19 * 256) == SEC_SIGNALED; + assert sec_state(0) == SEC_OK; + assert sec_state(44) == SEC_NOT_FOUND; + assert sec_state(36) == SEC_DENIED; + assert sec_state(51) == SEC_DENIED; + assert sec_state(128) == SEC_DENIED; + assert sec_state(1) == SEC_FAILED; + assert sec_state(45) == SEC_FAILED; + assert sec_state(161) == SEC_FAILED; + assert sec_state(SEC_SIGNALED) == SEC_FAILED; + assert sec_verdict(0, false) == SEC_OK; + assert sec_verdict(0, true) == SEC_FAILED; + assert sec_verdict(44, true) == SEC_FAILED; + assert sec_verdict(44, false) == SEC_NOT_FOUND; + assert sec_gone(SEC_OK) and sec_gone(SEC_NOT_FOUND); + assert sec_gone(SEC_DENIED) == false; + assert sec_gone(SEC_FAILED) == false; +} + +test what_the_tool_printed_becomes_a_key_only_when_it_is_one { + var out : [256]u8 = undefined; + var z : usize = 0; + while (z < 256) { + out[z] = 0; + z = z + 1; + } + assert sec_key_from_output("sk-fake-1\n", out[0..256]) == 9; + assert out[0] == 115 and out[8] == 49; + assert sec_key_from_output("sk-fake-2", out[0..256]) == 9; + assert sec_key_from_output("", out[0..256]) == 0; + assert sec_key_from_output("\n", out[0..256]) == 0; + assert sec_key_from_output("sk-a\n\n", out[0..256]) == 0; + assert sec_key_from_output("sk a\n", out[0..256]) == 0; + assert sec_key_from_output("sk-fake-1\n", out[0..4]) == 0; + assert sec_key_from_output("sk-fake-1\n", out[0..9]) == 9; + var big : [256]u8 = undefined; + var i : usize = 0; + while (i < 256) { + big[i] = 98; + i = i + 1; + } + big[254] = 10; + assert sec_key_from_output(big[0..255], out[0..256]) == 254; + big[254] = 98; + big[255] = 10; + assert sec_key_from_output(big[0..256], out[0..256]) == 0; + assert SEC_OUT_CAP == MAX_KEY_CHARS + 2; +} + +// ---- several keys per provider (owner, 2026-10-20: add several tokens to a provider so they rotate) --------- +// A provider row owns up to MAX_KEYS numbered Keychain accounts. The window keeps which numbers are stored as +// a bit mask (bit k set = key k+1 exists) and every decision below works from that mask: which number "add" +// takes (the lowest free one, never an existing one), which key "delete" removes (the newest, the highest +// number), whether "add" reads "full", what a row says after a check, which keys the server is not given. +// A check keeps one status per key, in an array indexed p * MAX_KEYS + k (key_index), so a key that failed +// is withheld alone and the others keep rotating. + +/// How many numbers the mask holds. +pub fn slot_count(mask: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if ((mask >> k) % 2 == 1) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// Is key k (0-based) in the mask? +pub fn slot_taken(mask: u32, k: u32) -> bool { + return k < MAX_KEYS and (mask >> k) % 2 == 1; +} + +/// The number "add" stores under: the lowest free one; MAX_KEYS when all are taken ("full"). +pub fn next_free_key(mask: u32) -> u32 { + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) == false) { return k; } + k = k + 1; + } + return MAX_KEYS; +} + +/// The newest key, the one "delete" removes: the highest stored number; MAX_KEYS when none is stored. +pub fn newest_key(mask: u32) -> u32 { + var k : u32 = MAX_KEYS; + while (k > 0) { + k = k - 1; + if (slot_taken(mask, k)) { return k; } + } + return MAX_KEYS; +} + +/// The word of the "add" button (KEYS_WORDS): "add" while a number is free, "full" when none is. +pub fn ky_set_word(mask: u32) -> u32 { + if (next_free_key(mask) >= MAX_KEYS) { return 2; } + return 1; +} + +/// A scan of one provider's numbers ends after this many misses in a row (a gap of one is stepped over). Each +/// read is a run of /usr/bin/security (about 23 ms measured), so reading all 8 numbers of all 9 providers +/// costs about 1.7 s; this keeps a scan to the numbers that exist and the first miss behind them. Set it to +/// MAX_KEYS to read every number again. +pub const SCAN_GAP_STOP : u32 = 2; + +/// May a scan read number k after `misses` reads in a row that found nothing? +pub fn scan_goes_on(k: u32, misses: u32) -> bool { + return k < MAX_KEYS and misses < SCAN_GAP_STOP; +} + +/// Where the status of key k of provider p sits in the per-key arrays. +pub fn key_index(p: u32, k: u32) -> usize { + return @as(usize, p) * @as(usize, MAX_KEYS) + @as(usize, k); +} + +/// A status that came from a check: valid, invalid, rate limited (or circuit open), not stored / missing. +fn status_checked(s: u32) -> bool { + return s >= ST_VALID; +} + +/// How many of the keys in `mask` of provider p passed their check. +pub fn keys_valid(st: []const u32, p: u32, mask: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) and st[key_index(p, k)] == ST_VALID) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// How many of the keys in `mask` of provider p have been checked. +pub fn keys_checked(st: []const u32, p: u32, mask: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) and status_checked(st[key_index(p, k)])) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// The status of a provider from its keys: valid when one key is (it can serve), stored while a key is +/// unchecked, then rate limited when one is, else invalid; missing with no key. +pub fn provider_status(st: []const u32, p: u32, mask: u32) -> u32 { + if (mask == 0) { return ST_MISSING; } + if (keys_valid(st, p, mask) > 0) { return ST_VALID; } + if (keys_checked(st, p, mask) < slot_count(mask)) { return ST_STORED; } + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) and st[key_index(p, k)] == ST_RATE_LIMITED) { return ST_RATE_LIMITED; } + k = k + 1; + } + return ST_INVALID; +} + +/// The bad mask (bad_mask) from the keys: a provider is bad when it holds keys and none can serve. `stored[p]` +/// is the mask of provider p's numbers. +pub fn bad_mask_keys(st: []const u32, stored: []const u32) -> u32 { + var m : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT and p < stored.len) { + if (stored[p] > 0 and provider_status(st, p, stored[p]) == ST_INVALID) { m = m + (1 << p); } + p = p + 1; + } + return m; +} + +/// Is a key with this status kept from the server (so rotation skips it)? A key that answered 401 or 403. +pub fn key_withheld(s: u32) -> bool { + return s == ST_INVALID; +} + +/// What a row says next to its dot: "6/7 valid" once two or more keys were checked, else the status word. +/// `shown` is the status the row shows (status_shown: missing, circuit-open and the rest win over the count). +pub fn key_status_text(shown: u32, st: []const u32, p: u32, mask: u32, out: []u8) -> usize { + const checked : u32 = keys_checked(st, p, mask); + if (checked >= 2 and shown != ST_CIRCUIT_OPEN and shown != ST_MISSING) { + var n : usize = put_dec(out, 0, @as(u64, keys_valid(st, p, mask))); + n = put_bytes(out, n, "/"); + n = put_dec(out, n, @as(u64, checked)); + return put_bytes(out, n, " valid"); + } + return csv_field(STATUS_NAMES, shown, out); +} + +test add_takes_the_lowest_free_number_and_stops_when_full { + assert next_free_key(0) == 0; + assert next_free_key(1) == 1; + assert next_free_key(3) == 2; + assert next_free_key(5) == 1; + assert next_free_key(127) == 7; + assert next_free_key(255) == MAX_KEYS; + assert ky_set_word(0) == 1; + assert ky_set_word(127) == 1; + assert ky_set_word(255) == 2; + var w : [24]u8 = undefined; + assert csv_field(KEYS_WORDS, 1, w[0..24]) == 3; + assert w[0] == 97; + assert csv_field(KEYS_WORDS, 2, w[0..24]) == 4; + assert w[0] == 102 and w[3] == 108; +} + +test delete_removes_the_newest_key_only { + assert newest_key(0) == MAX_KEYS; + assert newest_key(1) == 0; + assert newest_key(127) == 6; + assert newest_key(255) == 7; + assert newest_key(5) == 2; + assert newest_key(2) == 1; + var mask : u32 = 127; + mask = mask - (1 << newest_key(mask)); + assert mask == 63; + assert newest_key(mask) == 5; +} + +test the_numbers_in_a_mask_are_counted { + assert slot_count(0) == 0; + assert slot_count(127) == 7; + assert slot_count(255) == 8; + assert slot_count(5) == 2; + assert slot_taken(5, 0) and slot_taken(5, 2); + assert slot_taken(5, 1) == false; + assert slot_taken(5, MAX_KEYS) == false; + assert slot_taken(65535, 9) == false; +} + +test a_scan_steps_over_one_gap_and_stops_at_two { + assert scan_goes_on(0, 0); + assert scan_goes_on(3, 1); + assert scan_goes_on(3, 2) == false; + assert scan_goes_on(MAX_KEYS, 0) == false; + assert SCAN_GAP_STOP >= 1; +} + +test a_key_has_its_own_place_among_the_statuses { + assert key_index(0, 0) == 0; + assert key_index(0, 7) == 7; + assert key_index(4, 0) == 32; + assert key_index(4, 6) == 38; + assert key_index(8, 7) == 71; +} + +test a_provider_is_valid_while_one_key_is_and_bad_when_none_can_serve { + var st : [72]u32 = undefined; + var i : usize = 0; + while (i < 72) { + st[i] = ST_STORED; + i = i + 1; + } + assert provider_status(st[0..72], P_ZAI, 0) == ST_MISSING; + assert provider_status(st[0..72], P_ZAI, 127) == ST_STORED; + st[key_index(P_ZAI, 0)] = ST_INVALID; + assert provider_status(st[0..72], P_ZAI, 127) == ST_STORED; + st[key_index(P_ZAI, 1)] = ST_VALID; + assert provider_status(st[0..72], P_ZAI, 127) == ST_VALID; + assert provider_status(st[0..72], P_ZAI, 1) == ST_INVALID; + assert provider_status(st[0..72], P_ZAI, 3) == ST_VALID; + var k : u32 = 0; + while (k < 7) { + st[key_index(P_ZAI, k)] = ST_INVALID; + k = k + 1; + } + assert provider_status(st[0..72], P_ZAI, 127) == ST_INVALID; + st[key_index(P_ZAI, 4)] = ST_RATE_LIMITED; + assert provider_status(st[0..72], P_ZAI, 127) == ST_RATE_LIMITED; + var stored : [9]u32 = undefined; + var s : usize = 0; + while (s < 9) { + stored[s] = 0; + s = s + 1; + } + stored[P_ZAI] = 127; + assert bad_mask_keys(st[0..72], stored[0..9]) == 0; + st[key_index(P_ZAI, 4)] = ST_INVALID; + assert bad_mask_keys(st[0..72], stored[0..9]) == 16; + stored[P_ANTHROPIC] = 1; + assert bad_mask_keys(st[0..72], stored[0..9]) == 16; + st[key_index(P_ANTHROPIC, 0)] = ST_INVALID; + assert bad_mask_keys(st[0..72], stored[0..9]) == 17; + st[key_index(P_ZAI, 6)] = ST_VALID; + assert bad_mask_keys(st[0..72], stored[0..9]) == 1; +} + +test only_a_key_that_failed_is_withheld_from_the_server { + assert key_withheld(ST_INVALID); + assert key_withheld(ST_VALID) == false; + assert key_withheld(ST_STORED) == false; + assert key_withheld(ST_RATE_LIMITED) == false; + assert key_withheld(ST_MISSING) == false; +} + +test a_row_after_a_check_says_how_many_keys_are_valid { + var st : [72]u32 = undefined; + var i : usize = 0; + while (i < 72) { + st[i] = ST_STORED; + i = i + 1; + } + var out : [32]u8 = undefined; + var z : usize = 0; + while (z < 32) { + out[z] = 0; + z = z + 1; + } + var k : u32 = 0; + while (k < 7) { + st[key_index(P_ZAI, k)] = ST_VALID; + k = k + 1; + } + st[key_index(P_ZAI, 3)] = ST_INVALID; + const n : usize = key_status_text(ST_VALID, st[0..72], P_ZAI, 127, out[0..32]); + assert n == 9; + assert out[0] == 54 and out[1] == 47 and out[2] == 55 and out[3] == 32; + assert out[4] == 118 and out[8] == 100; + assert key_status_text(ST_CIRCUIT_OPEN, st[0..72], P_ZAI, 127, out[0..32]) == 12; + assert out[0] == 99; + assert key_status_text(ST_MISSING, st[0..72], P_ZAI, 0, out[0..32]) == 7; + assert key_status_text(ST_STORED, st[0..72], P_ANTHROPIC, 1, out[0..32]) == 6; + assert out[0] == 115; + st[key_index(P_OPENAI, 0)] = ST_VALID; + assert key_status_text(ST_VALID, st[0..72], P_OPENAI, 1, out[0..32]) == 5; + assert out[0] == 118; + st[key_index(P_OPENAI, 1)] = ST_INVALID; + assert key_status_text(ST_VALID, st[0..72], P_OPENAI, 3, out[0..32]) == 9; + assert out[0] == 49 and out[2] == 50; +} + +test the_new_provider_status_agrees_with_the_old_one_for_a_single_key { + var st : [72]u32 = undefined; + var i : usize = 0; + while (i < 72) { + st[i] = ST_STORED; + i = i + 1; + } + var one : [9]u32 = undefined; + var j : usize = 0; + while (j < 9) { + one[j] = ST_STORED; + j = j + 1; + } + var s : u32 = ST_STORED; + while (s <= ST_RATE_LIMITED) { + st[key_index(P_GEMINI, 0)] = s; + one[P_GEMINI] = s; + assert provider_status(st[0..72], P_GEMINI, 1) == s; + var sm : [9]u32 = undefined; + var q : usize = 0; + while (q < 9) { + sm[q] = 0; + q = q + 1; + } + sm[P_GEMINI] = 1; + var bad : u32 = 0; + if (s == ST_INVALID) { bad = 4; } + assert bad_mask_keys(st[0..72], sm[0..9]) == bad; + assert bad_mask(one[0..9]) == bad; + s = s + 1; + } +} diff --git a/specs/t27os/keys_host.t27 b/specs/t27os/keys_host.t27 index 0f90dec80f..96b8bb36aa 100644 --- a/specs/t27os/keys_host.t27 +++ b/specs/t27os/keys_host.t27 @@ -1,26 +1,48 @@ // SPDX-License-Identifier: Apache-2.0 // T27 OS keys_host: where the key manager (keys.t27) touches the machine. Three jobs, no decisions: -// - the macOS Keychain: kc_put_n / kc_get_n / kc_del_n, one generic-password item per key (up to MAX_KEYS a -// provider; account = provider name, then "#2" .. "#8") under the service "ai.t27.os" -// (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete); kc_put / kc_get / kc_del are key 1. -// A key is never written to a file and never reaches the repo. +// - 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 +// through SecItem calls. The window is signed ad hoc, so each rebuild is a new code identity and every +// 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. // -// The kSec* constants are global CFStringRef variables, not functions, so an @extern call cannot read them: -// dlsym finds the variable's address and sys_peek copies the pointer out. Specs holding @extern cannot run -// tests under t27c; every rule these fns follow is a tested fn in keys.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. // -// Refs gHashTag/t27#10782. +// Signals around a run: editors_host.t27 sets SIGCHLD to ignore (its children are never waited for), and with +// that set waitpid cannot return a child's status, so a run sets the default for its own length and puts back +// what it found; SIGPIPE is ignored while the line is written (a dead tool must not end the window). +// +// Refs gHashTag/t27#10782, gHashTag/t27#11241. 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; +use t27os::keys::SEC_MAX_WORDS; +use t27os::keys::SEC_TIMEOUT_MS; +use t27os::keys::SEC_SIGNALED; +use t27os::keys::SEC_OK; +use t27os::keys::SEC_FAILED; +use t27os::keys::sec_argv; +use t27os::keys::sec_add_line; +use t27os::keys::sec_exit_code; +use t27os::keys::sec_verdict; +use t27os::keys::sec_gone; +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; @@ -32,28 +54,29 @@ 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; use t27os::pty_sys::sys_peek; +use t27os::pty_sys::sys_environ; +use t27os::pty_sys::sys_signal; +use t27os::pty_sys::sys_read; +use t27os::pty_sys::sys_write; +use t27os::pty_sys::sys_close; +use t27os::pty_sys::sys_poll; +use t27os::pty_sys::sys_kill; +use t27os::pty_sys::sys_waitpid; use t27os::pty_client::client_set_key; -use t27os::session_store::put_bytes; pub const UTF8_ENC : u32 = 134217984; -pub const SEC_DUPLICATE : i32 = -25299; -pub const RTLD_LAZY : i32 = 1; +pub const SIGNAL_PIPE : i32 = 13; +pub const SIGNAL_CHLD : i32 = 20; +pub const HANDLER_DFL : u64 = 0; +pub const HANDLER_IGN : u64 = 1; +pub const SIGKILL_NUMBER : i32 = 9; -fn dl_open(path: u64, mode: i32) -> u64 { return @extern("dlopen"); } -fn dl_sym(h: u64, name: []const u8) -> u64 { return @extern("dlsym"); } 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_data_len(d: u64) -> u64 { return @extern("CFDataGetLength"); } -fn cf_data_ptr(d: u64) -> u64 { return @extern("CFDataGetBytePtr"); } -fn cf_dict_new(alloc: u64, cap: u64, kcb: u64, vcb: u64) -> u64 { return @extern("CFDictionaryCreateMutable"); } -fn cf_dict_set(d: u64, k: u64, v: u64) -> void { @extern("CFDictionarySetValue"); } fn cf_release(o: u64) -> void { @extern("CFRelease"); } -fn sec_add(attrs: u64, result: u64) -> i32 { return @extern("SecItemAdd"); } -fn sec_copy(query: u64, result: []u8) -> i32 { return @extern("SecItemCopyMatching"); } -fn sec_update(query: u64, attrs: u64) -> i32 { return @extern("SecItemUpdate"); } -fn sec_delete(query: u64) -> i32 { return @extern("SecItemDelete"); } 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"); } @@ -62,6 +85,16 @@ 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"); } +/// posix_spawn(3): the file actions are the address of the 8-byte posix_spawn_file_actions_t. +fn sec_spawn(pid: [2]u32, path: []const u8, actions: []u8, attr: u64, argv: [9]u64, envp: u64) -> i32 { return @extern("posix_spawn"); } +fn fa_init(fa: []u8) -> i32 { return @extern("posix_spawn_file_actions_init"); } +fn fa_destroy(fa: []u8) -> i32 { return @extern("posix_spawn_file_actions_destroy"); } +fn fa_dup2(fa: []u8, fd: i32, to: i32) -> i32 { return @extern("posix_spawn_file_actions_adddup2"); } +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); } @@ -80,157 +113,262 @@ fn sel(s: []const u8) -> u64 { return c; } -/// The value of a global CFStringRef (or CFBooleanRef) variable of the loaded frameworks. -fn global_ref(name: []const u8) -> u64 { - var z : [40]u8 = undefined; - const n : usize = put_bytes(z[0..40], 0, name); - z[n] = 0; - const h : u64 = dl_open(0, RTLD_LAZY); - const at : u64 = dl_sym(h, z[0..40]); - if (at == 0) { return 0; } - var b : [8]u8 = undefined; - sys_peek(b[0..8], at, 8); - return get_u64(b[0..8], 0); +fn close_fd(fd: u32) -> void { + sys_close(@as(i32, @intCast(fd))); } -/// The address of a global struct (the dictionary callbacks): dlsym's answer itself. -fn global_addr(name: []const u8) -> u64 { - var z : [40]u8 = undefined; - const n : usize = put_bytes(z[0..40], 0, name); - z[n] = 0; - return dl_sym(dl_open(0, RTLD_LAZY), z[0..40]); +fn wipe(b: []u8) -> void { + var i : usize = 0; + while (i < b.len) { + b[i] = 0; + i = i + 1; + } } -fn new_dict() -> u64 { - return cf_dict_new(0, 0, global_addr("kCFTypeDictionaryKeyCallBacks"), global_addr("kCFTypeDictionaryValueCallBacks")); +/// The body of a run of /usr/bin/security (sec_run): the argument words (keys.t27 sec_argv), `feed` to its +/// standard input (nothing when feed is empty, then /dev/null), its standard output into `out`, its standard +/// error to /dev/null. res[0] = the bytes read, res[1] = the exit code (SEC_SIGNALED when a signal ended it). +/// A tool that stays silent for `timeout` ms, or fills `out`, is killed. Returns SEC_OK / SEC_NOT_FOUND / +/// SEC_DENIED / SEC_FAILED (keys.t27 sec_verdict); a failure to start is SEC_FAILED, never a key. +fn sec_run_inner(words: []u8, offs: []const u32, argc: u32, feed: []const u8, out: []u8, res: []u32, timeout: i32) -> u32 { + res[0] = 0; + res[1] = SEC_SIGNALED; + if (argc == 0 or argc > SEC_MAX_WORDS) { return SEC_FAILED; } + var argv : [9]u64 = undefined; + var a : usize = 0; + while (a < 9) { + argv[a] = 0; + a = a + 1; + } + const base : u64 = addr_of(words, words, 0); + var w : u32 = 0; + while (w < argc) { + argv[w] = base + @as(u64, offs[w]); + w = w + 1; + } + var ev : [8]u8 = undefined; + wipe(ev[0..8]); + sys_peek(ev[0..8], sys_environ(), 8); + const envp : u64 = get_u64(ev[0..8], 0); + var po : [2]u32 = undefined; + po[0] = 0; + po[1] = 0; + var pi : [2]u32 = undefined; + pi[0] = 0; + pi[1] = 0; + const has_in : bool = feed.len > 0; + if (sys_pipe(po) != 0) { return SEC_FAILED; } + if (has_in) { + if (sys_pipe(pi) != 0) { + close_fd(po[0]); + close_fd(po[1]); + return SEC_FAILED; + } + } + // the child's descriptors: 0 = the line (or /dev/null), 1 = the output pipe, 2 = /dev/null; it closes every pipe end + var dn : [16]u8 = undefined; + wipe(dn[0..16]); + dn[0] = 47; + dn[1] = 100; + dn[2] = 101; + dn[3] = 118; + dn[4] = 47; + dn[5] = 110; + dn[6] = 117; + dn[7] = 108; + dn[8] = 108; + var fa : [8]u8 = undefined; + wipe(fa[0..8]); + var bad : bool = fa_init(fa[0..8]) != 0; + if (bad) { + close_fd(po[0]); + close_fd(po[1]); + if (has_in) { + close_fd(pi[0]); + close_fd(pi[1]); + } + return SEC_FAILED; + } + if (has_in) { + if (fa_dup2(fa[0..8], @as(i32, @intCast(pi[0])), 0) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(pi[0]))) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(pi[1]))) != 0) { bad = true; } + } else { + if (fa_open(fa[0..8], 0, dn[0..16], 0, 0) != 0) { bad = true; } + } + if (fa_dup2(fa[0..8], @as(i32, @intCast(po[1])), 1) != 0) { bad = true; } + if (fa_open(fa[0..8], 2, dn[0..16], 1, 0) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(po[0]))) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(po[1]))) != 0) { bad = true; } + var pid : [2]u32 = undefined; + pid[0] = 0; + pid[1] = 0; + var rc : i32 = 1; + if (bad == false) { + rc = sec_spawn(pid, words[@as(usize, offs[0]) .. words.len], fa[0..8], 0, argv, envp); + } + fa_destroy(fa[0..8]); + close_fd(po[1]); + if (has_in) { close_fd(pi[0]); } + if (rc != 0) { + close_fd(po[0]); + if (has_in) { close_fd(pi[1]); } + return SEC_FAILED; + } + const pidv : i32 = @as(i32, @intCast(pid[0])); + // the line, in one write (it is shorter than PIPE_BUF, so it arrives whole or not at all) + var cut : bool = false; + if (has_in) { + const prev : u64 = sys_signal(SIGNAL_PIPE, HANDLER_IGN); + var off : usize = 0; + var live : bool = true; + while (live and off < feed.len) { + const wn : i64 = sys_write(@as(i32, @intCast(pi[1])), feed[off .. feed.len], @as(u64, feed.len - off)); + if (wn <= 0) { + live = false; + } else { + off = off + @as(usize, @intCast(wn)); + } + } + sys_signal(SIGNAL_PIPE, prev); + close_fd(pi[1]); + if (off < feed.len) { cut = true; } + } + // the output, until the tool closes it; a silent tool or a full buffer is cut short + var pf : [8]u8 = undefined; + wipe(pf[0..8]); + var got : usize = 0; + var spins : u32 = 0; + var more : bool = cut == false; + while (more) { + if (got >= out.len) { + cut = true; + more = false; + } else { + put_pollfd(pf[0..8], 0, po[0]); + const r : i32 = sys_poll(pf[0..8], 1, timeout); + if (r == 0) { + cut = true; + more = false; + } else if (r < 0) { + spins = spins + 1; + if (spins > 4) { + cut = true; + more = false; + } + } else { + const rn : i64 = sys_read(@as(i32, @intCast(po[0])), out[got .. out.len], @as(u64, out.len - got)); + if (rn <= 0) { + more = false; + } else { + got = got + @as(usize, @intCast(rn)); + } + } + } + } + close_fd(po[0]); + if (cut) { sys_kill(pidv, SIGKILL_NUMBER); } + var ws : [2]u32 = undefined; + ws[0] = 0; + ws[1] = 0; + var wp : i32 = sys_waitpid(pidv, ws, 0); + var tries : u32 = 0; + while (wp != pidv and tries < 4) { + wp = sys_waitpid(pidv, ws, 0); + tries = tries + 1; + } + res[0] = @as(u32, @intCast(got)); + if (wp != pidv) { return SEC_FAILED; } + res[1] = sec_exit_code(ws[0]); + return sec_verdict(res[1], cut); } -fn set_text(d: u64, key_name: []const u8, text: []const u8) -> void { - const s : u64 = cf(text); - cf_dict_set(d, global_ref(key_name), s); - cf_release(s); +/// One run of /usr/bin/security (see sec_run_inner), with SIGCHLD at its default for the run's length. +pub fn sec_run(words: []u8, offs: []const u32, argc: u32, feed: []const u8, out: []u8, res: []u32, timeout: i32) -> u32 { + const prev : u64 = sys_signal(SIGNAL_CHLD, HANDLER_DFL); + const st : u32 = sec_run_inner(words, offs, argc, feed, out, res, timeout); + sys_signal(SIGNAL_CHLD, prev); + return st; } -/// The query that names key k of provider p (k = 0 is key 1): generic password, service "ai.t27.os", account -/// = the provider name, then "#2" .. "#8" for the others (keys.t27 keychain_account_n). -fn item_query(p: u32, k: u32) -> u64 { - const d : u64 = new_dict(); - cf_dict_set(d, global_ref("kSecClass"), global_ref("kSecClassGenericPassword")); - set_text(d, "kSecAttrService", KEYCHAIN_SERVICE); +/// One Keychain operation on key k of provider p: build the argument list (keys.t27 sec_argv), run the tool +/// with `feed` on its standard input, its output in `raw`. The state of the run (SEC_*); res as in sec_run. +fn sec_op(op: u32, p: u32, k: u32, feed: []const u8, raw: []u8, res: []u32) -> u32 { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return SEC_FAILED; } var nm : [32]u8 = undefined; - const n : usize = keychain_account_n(p, k, nm[0..32]); - set_text(d, "kSecAttrAccount", nm[0..n]); - return d; -} - -fn set_data(d: u64, key: []const u8) -> void { - const data : u64 = cf_data(0, key, @as(u64, key.len)); - cf_dict_set(d, global_ref("kSecValueData"), data); - cf_release(data); + wipe(nm[0..32]); + const nn : usize = keychain_account_n(p, k, nm[0..32]); + var words : [160]u8 = undefined; + var offs : [8]u32 = undefined; + wipe(words[0..160]); + var z : usize = 0; + while (z < 8) { + offs[z] = 0; + z = z + 1; + } + const argc : u32 = sec_argv(op, KEYCHAIN_SERVICE, nm[0..nn], words[0..160], offs[0..8]); + if (argc == 0) { return SEC_FAILED; } + return sec_run(words[0..160], offs[0..8], argc, feed, raw, res, @as(i32, @intCast(SEC_TIMEOUT_MS))); } -/// Store key k (0-based) of provider p, replacing an earlier one. True when the Keychain accepted it. +/// Store `key` as key k (0-based) of provider p, never replacing one. True when the Keychain accepted it; +/// false when the number is taken (the tool answers 45), the key is not fit for the `security -i` line +/// (keys.t27 sec_key_safe) or the tool did not answer. The key goes in on standard input, never on argv. pub fn kc_put_n(p: u32, k: u32, key: []const u8) -> bool { if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return false; } - if (key.len == 0) { return false; } - const a : u64 = item_query(p, k); - set_data(a, key); - var st : i32 = sec_add(a, 0); - cf_release(a); - if (st == SEC_DUPLICATE) { - const q : u64 = item_query(p, k); - const u : u64 = new_dict(); - set_data(u, key); - st = sec_update(q, u); - cf_release(q); - cf_release(u); - } - return st == 0; -} - -/// Store provider p's first key. -pub fn kc_put(p: u32, key: []const u8) -> bool { - return kc_put_n(p, 0, key); + var nm : [32]u8 = undefined; + wipe(nm[0..32]); + const nn : usize = keychain_account_n(p, k, nm[0..32]); + var line : [400]u8 = undefined; + wipe(line[0..400]); + const ln : usize = sec_add_line(KEYCHAIN_SERVICE, nm[0..nn], key, line[0..400]); + if (ln == 0) { return false; } + var raw : [256]u8 = undefined; + wipe(raw[0..256]); + var res : [2]u32 = undefined; + res[0] = 0; + res[1] = 0; + const st : u32 = sec_op(SEC_OP_WRITE, p, k, line[0..ln], raw[0..256], res[0..2]); + wipe(line[0..400]); + wipe(raw[0..256]); + return st == SEC_OK; } -/// Read key k (0-based) of provider p into out; its length, 0 when none is stored. +/// Read key k (0-based) of provider p into out; its length, 0 when none is stored, the tool did not answer +/// with exit status 0, or what it printed is not a key that fits out. pub fn kc_get_n(p: u32, k: u32, out: []u8) -> usize { - if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return 0; } - const q : u64 = item_query(p, k); - cf_dict_set(q, global_ref("kSecReturnData"), global_ref("kCFBooleanTrue")); - cf_dict_set(q, global_ref("kSecMatchLimit"), global_ref("kSecMatchLimitOne")); - var res : [8]u8 = undefined; - const st : i32 = sec_copy(q, res[0..8]); - cf_release(q); - if (st != 0) { return 0; } - const d : u64 = get_u64(res[0..8], 0); - if (d == 0) { return 0; } - var n : u64 = cf_data_len(d); - if (n > @as(u64, out.len)) { n = @as(u64, out.len); } - sys_peek(out, cf_data_ptr(d), n); - cf_release(d); - return @as(usize, n); -} - -/// Read provider p's first key into out. -pub fn kc_get(p: u32, out: []u8) -> usize { - return kc_get_n(p, 0, out); + var raw : [256]u8 = undefined; + wipe(raw[0..256]); + var res : [2]u32 = undefined; + res[0] = 0; + res[1] = 0; + const st : u32 = sec_op(SEC_OP_READ, p, k, "", raw[0..256], res[0..2]); + var n : usize = 0; + if (st == SEC_OK) { n = sec_key_from_output(raw[0 .. @as(usize, res[0])], out); } + wipe(raw[0..256]); + return n; } /// Delete key k (0-based) of provider p. True when it is gone (also when none was there). pub fn kc_del_n(p: u32, k: u32) -> bool { - if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return false; } - const q : u64 = item_query(p, k); - const st : i32 = sec_delete(q); - cf_release(q); - return st == 0 or st == -25300; -} - -/// Delete provider p's first key. -pub fn kc_del(p: u32) -> bool { - return kc_del_n(p, 0); -} - -/// Delete every key of provider p (a KEYS row's DELETE removes the provider's whole set). True when all are gone. -pub fn kc_del_all(p: u32) -> bool { - var ok : bool = true; - var k : u32 = 0; - while (k < MAX_KEYS) { - if (kc_del_n(p, k) == false) { ok = false; } - k = k + 1; - } - return ok; + var raw : [256]u8 = undefined; + wipe(raw[0..256]); + var res : [2]u32 = undefined; + res[0] = 0; + res[1] = 0; + const st : u32 = sec_op(SEC_OP_DELETE, p, k, "", raw[0..256], res[0..2]); + wipe(raw[0..256]); + return sec_gone(st); } /// True when the Keychain holds a first key for provider p (reads it, then drops the copy). pub fn kc_has(p: u32) -> bool { var b : [256]u8 = undefined; - const n : usize = kc_get(p, b[0..256]); - var i : usize = 0; - while (i < 256) { - b[i] = 0; - i = i + 1; - } + const n : usize = kc_get_n(p, 0, b[0..256]); + wipe(b[0..256]); return n > 0; } -/// How many of the provider's MAX_KEYS accounts hold a key (a gap in the numbers does not stop the count). -pub fn kc_count(p: u32) -> u32 { - var b : [256]u8 = undefined; - var c : u32 = 0; - var k : u32 = 0; - while (k < MAX_KEYS) { - if (kc_get_n(p, k, b[0..256]) > 0) { c = c + 1; } - k = k + 1; - } - var i : usize = 0; - while (i < 256) { - b[i] = 0; - i = i + 1; - } - return c; -} - fn add_header(req: u64, name: []const u8, value: []const u8) -> void { const n : u64 = cf(name); const v : u64 = cf(value); @@ -284,11 +422,11 @@ pub fn probe(p: u32, key: []const u8) -> u32 { 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. Every numbered -/// account of every provider is read, up to MAX_KEYS, and the number rides in the OP_SETKEY pane byte -/// (keys.t27 setkey_byte), so the server holds all of them and rotates over them. Called on connect, when -/// the KEYS tab opens and before a spawn, because a server that restarted has forgotten them. The copy of -/// each key in this stack frame is wiped when the call ends. +/// 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 +/// rotates over them. Called on connect, when the KEYS tab opens and before a spawn, because a server that +/// restarted has forgotten them. The copy of each key in this stack frame is wiped when the call ends. pub fn push_keys(fd: i32) -> u32 { var key : [256]u8 = undefined; var reply : [MAX_FRAME]u8 = undefined; @@ -296,20 +434,20 @@ pub fn push_keys(fd: i32) -> u32 { var p : u32 = 0; while (p < PROVIDER_COUNT) { var k : u32 = 0; - while (k < MAX_KEYS) { + var misses : u32 = 0; + while (scan_goes_on(k, misses)) { const n : usize = kc_get_n(p, k, key[0..256]); if (n > 0) { + misses = 0; if (client_set_key(fd, setkey_byte(p, k), key[0..n], reply[0..MAX_FRAME]) > 0) { pushed = pushed + 1; } + } else { + misses = misses + 1; } k = k + 1; } p = p + 1; } - var i : usize = 0; - while (i < 256) { - key[i] = 0; - i = i + 1; - } + wipe(key[0..256]); return pushed; } From 808d556262184c54d1d702777c87052c27286635 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:43:53 +0700 Subject: [PATCH 4/4] T27 OS keys: pty_logic uses keys.t27 key_index instead of its own kidx; keys-orchestration.md says the host reads through security pty_logic.t27 already imports from keys.t27 and keys.t27 does not import pty_logic.t27, so the one definition of "where key k of provider p sits" (p * MAX_KEYS + k) is keys.t27 key_index and pty_logic's private kidx is gone. dupe_scan --like is silent for both specs. keys-orchestration.md: two rows now say the host reads every numbered key, up to 8 per provider, through /usr/bin/security. Refs #11265 Co-Authored-By: Claude Sonnet 5.5 --- docs/t27os/keys-orchestration.md | 4 ++-- specs/t27os/pty_logic.t27 | 19 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/t27os/keys-orchestration.md b/docs/t27os/keys-orchestration.md index 7757e6090a..10b895d3d7 100644 --- a/docs/t27os/keys-orchestration.md +++ b/docs/t27os/keys-orchestration.md @@ -49,13 +49,13 @@ 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 and 3 add `#2`, `#3`; least-recently-used rotation with a rest time | `keychain_label_n`, `MAX_KEYS`, `rotate_pick`, `rotate_pick_except` | The KEYS tab still shows one row per provider and the host reads only key 1; storing and listing keys 2 and 3 is host work | +| 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`) | | 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 | | Reasoning effort per route | No | none | Out of scope; not set by any profile | | Bearer token for the local proxy | Not needed, there is no proxy | none | Needed with the router (it binds 127.0.0.1, `ROUTER_BIND`) | -| Admin page for keys | Yes: the KEYS tab, Keychain storage, masked display, a check button, a circuit breaker | `ky_*`, `mask_key`, `status_after_probe`, `circuit_fails`, `bad_mask` | The signed-in account keys on app.t27.ai cannot be spent from a pane: no proxy route exists server-side (see the MAP block in `keys.t27`) | +| Admin page for keys | Yes: the KEYS tab, Keychain storage through `/usr/bin/security`, masked display of the newest key, a check button, a circuit breaker | `ky_*`, `mask_key`, `status_after_probe`, `circuit_fails`, `bad_mask` | The signed-in account keys on app.t27.ai cannot be spent from a pane: no proxy route exists server-side (see the MAP block in `keys.t27`) | | Local models (LM Studio, llama.cpp, Ollama) | Ollama cloud only | profile `ollama-cloud` | Local Ollama needs no key, so the stored-keys mask cannot say it is available; a host fact (is the server up) must join the mask first | | OAuth sign-in for some providers | No | none | Out of scope | diff --git a/specs/t27os/pty_logic.t27 b/specs/t27os/pty_logic.t27 index 8e8ca12b42..7d132bf151 100644 --- a/specs/t27os/pty_logic.t27 +++ b/specs/t27os/pty_logic.t27 @@ -90,6 +90,7 @@ use t27os::pty_proto::spawn_ok; use t27os::pty_proto::socket_path; use t27os::keys::PROVIDER_COUNT; use t27os::keys::MAX_KEYS; +use t27os::keys::key_index; use t27os::keys::KEY_NEVER; use t27os::keys::PROFILE_COUNT; use t27os::keys::PROFILE_PROVIDER; @@ -446,10 +447,6 @@ pub fn c_path(out: []u8, src: []const u8) -> usize { // When the pane's screen shows a failure the key rests and the pane is respawned by keys.t27 fo_action, on the // next key of the same provider (rotate_pick_except, the failed key skipped) or on the next profile. -fn kidx(p: u32, k: u32) -> usize { - return @as(usize, p) * @as(usize, MAX_KEYS) + @as(usize, k); -} - fn pow16(n: u32) -> u64 { var m : u64 = 1; var j : u32 = 0; @@ -462,7 +459,7 @@ fn pow16(n: u32) -> u64 { /// The offset in the arena of key slot (p, k): a length byte, the key, a NUL (OP_SETKEY fills it). pub fn key_slot(p: u32, k: u32) -> usize { - return O_KEYS + kidx(p, k) * KEY_CAP; + return O_KEYS + key_index(p, k) * KEY_CAP; } /// A pane that has chosen nothing: no keys, no profile, no failures, nothing to skip, nothing waiting. @@ -493,7 +490,7 @@ pub fn keys_init(st: []u64) -> void { } pub fn key_present(st: []const u64, p: u32, k: u32) -> bool { - return st[G_KREST + kidx(p, k)] < KEY_NEVER; + return st[G_KREST + key_index(p, k)] < KEY_NEVER; } /// How many slots of provider p hold a key. @@ -521,7 +518,7 @@ pub fn keys_stored(st: []const u64) -> u32 { /// OP_SETKEY filled (present) or emptied slot (p, k). `same`: the bytes are those already held, so the key keeps /// its last-use and rest times (a window that sends every key again must not end a cooldown). pub fn key_put(st: []u64, p: u32, k: u32, present: bool, same: bool) -> void { - const ix : usize = kidx(p, k); + const ix : usize = key_index(p, k); if (present == false) { st[G_KLAST + ix] = 0; st[G_KREST + ix] = KEY_NEVER; @@ -534,23 +531,23 @@ pub fn key_put(st: []u64, p: u32, k: u32, present: bool, same: bool) -> void { /// The key of provider p a new pane takes (MAX_KEYS when it holds none); `skip` is a key not to take. pub fn key_pick(st: []const u64, p: u32, skip: u32, now_ms: u64) -> u32 { - const at : usize = kidx(p, 0); + const at : usize = key_index(p, 0); return rotate_pick_soft(MAX_KEYS, skip, st[G_KLAST + at .. G_KLAST + at + 8], st[G_KREST + at .. G_KREST + at + 8], now_ms / 1000); } /// A key of provider p that is usable right now and is not `skip` (MAX_KEYS when none): the test for a swap. pub fn key_pick_free(st: []const u64, p: u32, skip: u32, now_ms: u64) -> u32 { - const at : usize = kidx(p, 0); + const at : usize = key_index(p, 0); return rotate_pick_except(MAX_KEYS, skip, st[G_KLAST + at .. G_KLAST + at + 8], st[G_KREST + at .. G_KREST + at + 8], now_ms / 1000); } pub fn key_used(st: []u64, p: u32, k: u32, now_ms: u64) -> void { - st[G_KLAST + kidx(p, k)] = now_ms; + st[G_KLAST + key_index(p, k)] = now_ms; } /// Key (p, k) rests until `until_s`; a longer rest it already has stays, and a slot that holds no key stays empty. pub fn key_rest(st: []u64, p: u32, k: u32, until_s: u64) -> void { - const ix : usize = G_KREST + kidx(p, k); + const ix : usize = G_KREST + key_index(p, k); if (st[ix] >= KEY_NEVER) { return; } if (until_s > st[ix]) { st[ix] = until_s; } }