flipstream 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +168 -12
  2. package/dist/commands/auth/clear-all.d.ts +1 -0
  3. package/dist/commands/auth/clear-all.js +91 -24
  4. package/dist/commands/auth/login.js +8 -2
  5. package/dist/commands/auth/logout.js +2 -2
  6. package/dist/commands/auth/status.js +35 -8
  7. package/dist/commands/catalog.d.ts +15 -0
  8. package/dist/commands/catalog.js +110 -0
  9. package/dist/commands/connections/list.d.ts +1 -0
  10. package/dist/commands/connections/list.js +28 -2
  11. package/dist/commands/contract.d.ts +11 -0
  12. package/dist/commands/contract.js +35 -0
  13. package/dist/commands/health.d.ts +10 -0
  14. package/dist/commands/health.js +31 -0
  15. package/dist/commands/log/add.js +7 -3
  16. package/dist/commands/log/list.js +1 -1
  17. package/dist/commands/query.d.ts +15 -2
  18. package/dist/commands/query.js +256 -43
  19. package/dist/commands/skills/install.d.ts +16 -0
  20. package/dist/commands/skills/install.js +55 -0
  21. package/dist/commands/workspaces/connections.js +4 -2
  22. package/dist/commands/workspaces/get.js +5 -3
  23. package/dist/commands/workspaces/list.js +4 -1
  24. package/dist/lib/api/errors.d.ts +1 -0
  25. package/dist/lib/api/errors.js +13 -2
  26. package/dist/lib/api/http.d.ts +2 -0
  27. package/dist/lib/api/http.js +40 -4
  28. package/dist/lib/api/ids.d.ts +1 -0
  29. package/dist/lib/api/ids.js +5 -0
  30. package/dist/lib/api/retry.d.ts +1 -1
  31. package/dist/lib/api/retry.js +1 -1
  32. package/dist/lib/api/short-uuid.d.ts +1 -0
  33. package/dist/lib/api/short-uuid.js +30 -0
  34. package/dist/lib/auth/claims.d.ts +1 -1
  35. package/dist/lib/auth/claims.js +2 -2
  36. package/dist/lib/auth/flow.js +10 -2
  37. package/dist/lib/auth/headless.js +14 -10
  38. package/dist/lib/auth/refresh.js +33 -3
  39. package/dist/lib/auth/session.js +1 -1
  40. package/dist/lib/command/base.d.ts +4 -0
  41. package/dist/lib/command/base.js +102 -3
  42. package/dist/lib/command/flags.d.ts +4 -0
  43. package/dist/lib/command/flags.js +11 -0
  44. package/dist/lib/command/planner.d.ts +9 -0
  45. package/dist/lib/command/planner.js +14 -0
  46. package/dist/lib/config/constants.d.ts +3 -1
  47. package/dist/lib/config/constants.js +14 -1
  48. package/dist/lib/config/xdg.d.ts +4 -0
  49. package/dist/lib/config/xdg.js +56 -1
  50. package/dist/lib/errors.d.ts +20 -1
  51. package/dist/lib/errors.js +125 -17
  52. package/dist/lib/output/dialogs.d.ts +27 -0
  53. package/dist/lib/output/dialogs.js +94 -0
  54. package/dist/lib/output/interactivity.d.ts +11 -0
  55. package/dist/lib/output/interactivity.js +54 -0
  56. package/dist/lib/output/machine-mode.d.ts +2 -0
  57. package/dist/lib/output/machine-mode.js +25 -0
  58. package/dist/lib/output/redact.d.ts +1 -0
  59. package/dist/lib/output/redact.js +12 -0
  60. package/dist/lib/output/runlog.d.ts +3 -0
  61. package/dist/lib/output/runlog.js +72 -0
  62. package/dist/lib/output/sanitize.d.ts +2 -0
  63. package/dist/lib/output/sanitize.js +57 -0
  64. package/dist/lib/output/sidecar.d.ts +30 -0
  65. package/dist/lib/output/sidecar.js +58 -0
  66. package/dist/lib/output/table.js +5 -1
  67. package/dist/lib/output/trace.d.ts +11 -0
  68. package/dist/lib/output/trace.js +89 -0
  69. package/dist/lib/planner/catalog.d.ts +26 -0
  70. package/dist/lib/planner/catalog.js +60 -0
  71. package/dist/lib/planner/client.d.ts +14 -0
  72. package/dist/lib/planner/client.js +47 -0
  73. package/dist/lib/planner/connection.d.ts +14 -0
  74. package/dist/lib/planner/connection.js +139 -0
  75. package/dist/lib/planner/diagnose.d.ts +8 -0
  76. package/dist/lib/planner/diagnose.js +50 -0
  77. package/dist/lib/planner/errors.d.ts +14 -0
  78. package/dist/lib/planner/errors.js +129 -0
  79. package/dist/lib/planner/filters.d.ts +8 -0
  80. package/dist/lib/planner/filters.js +74 -0
  81. package/dist/lib/planner/request.d.ts +24 -0
  82. package/dist/lib/planner/request.js +51 -0
  83. package/dist/lib/planner/suggest.d.ts +2 -0
  84. package/dist/lib/planner/suggest.js +45 -0
  85. package/dist/lib/planner/vocabulary.d.ts +9 -0
  86. package/dist/lib/planner/vocabulary.js +95 -0
  87. package/dist/lib/skills/install.d.ts +24 -0
  88. package/dist/lib/skills/install.js +69 -0
  89. package/dist/lib/store/keychain-child.d.ts +11 -0
  90. package/dist/lib/store/keychain-child.js +135 -0
  91. package/dist/lib/store/keyring.d.ts +27 -10
  92. package/dist/lib/store/keyring.js +288 -17
  93. package/dist/lib/store/memory-store.d.ts +6 -5
  94. package/dist/lib/store/memory-store.js +14 -6
  95. package/docs/AGENT-CONTRACT.md +238 -0
  96. package/oclif.manifest.json +392 -8
  97. package/package.json +7 -3
  98. package/skill/SKILL.md +55 -0
@@ -0,0 +1,135 @@
1
+ // ONE keychain operation, in a process of its own, printed as JSON on stdout.
2
+ //
3
+ // This exists because a macOS keychain call can park in mach_msg forever (#119):
4
+ // when securityd wants an authorization prompt that no GUI session can show (an
5
+ // SSH shell, a locked console), the call never returns. Nothing inside a Node
6
+ // process can recover from that — an AbortSignal does not interrupt a syscall
7
+ // already in flight, worker.terminate() cannot unwind a thread blocked in the
8
+ // kernel, and even process.exit() and process.reallyExit() hang, because Node's
9
+ // shutdown joins its libuv workers and the wedged one never finishes. All three
10
+ // were measured doing exactly that.
11
+ //
12
+ // A separate PROCESS is the one thing that can be killed. So the parent spawns
13
+ // this, waits with a deadline, and SIGKILLs it if the deadline wins — the wedged
14
+ // thread dies with the child, and the CLI stays free to report the failure and
15
+ // exit with its own code.
16
+ //
17
+ // STDIN IS ALSO THE PARENT'S HEARTBEAT. The request arrives as one line and the
18
+ // parent then holds the pipe open rather than closing it, so losing that pipe
19
+ // means the parent is gone. A wedged helper whose CLI was killed would otherwise
20
+ // be reparented and block forever, and cancelling a slow command repeatedly
21
+ // would quietly accumulate immortal processes — the exact containment failure
22
+ // the subprocess was introduced to prevent. Self-terminating on disconnect
23
+ // covers every way a parent can die, including SIGKILL, which no parent-side
24
+ // signal handler could catch.
25
+ //
26
+ // Nothing is passed as a command-line argument: those are readable by any `ps`
27
+ // on the machine, which would expose the secret on a write and the account name
28
+ // on every read.
29
+ import { AsyncEntry } from '@napi-rs/keyring';
30
+ import { writeSync } from 'node:fs';
31
+ // fs.writeSync rather than process.stdout.write: this is the one place a store
32
+ // module writes to a pipe, and a synchronous write cannot be truncated by the
33
+ // process exiting underneath it.
34
+ function emit(result) {
35
+ writeSync(1, `${JSON.stringify(result)}\n`);
36
+ }
37
+ // SIGKILL on ourselves rather than process.exit(): if a keychain call is already
38
+ // wedged in the kernel, Node's own shutdown would join its libuv worker and hang
39
+ // exactly like the parent did. Only the signal is certain.
40
+ function dieWithParent() {
41
+ process.kill(process.pid, 'SIGKILL');
42
+ }
43
+ // ONE liveness handler, installed before anything else and never conditional.
44
+ //
45
+ // An earlier version had two sets — one that ignored EOF while reading the
46
+ // request, and an unconditional pair installed after the request resolved. The
47
+ // gap between them was a real hole: a parent that died immediately after writing
48
+ // the request could deliver EOF into the handoff, have it ignored by the first
49
+ // set and missed by the second, and leave a wedged helper with nothing left to
50
+ // kill it. That is precisely the orphan this design exists to prevent, so the
51
+ // handler is now unconditional for the whole life of the process.
52
+ process.stdin.on('end', dieWithParent);
53
+ process.stdin.on('close', dieWithParent);
54
+ // The first line on stdin. The pipe stays OPEN afterwards, still serving as the
55
+ // heartbeat above.
56
+ function readRequestLine() {
57
+ return new Promise((resolve, reject) => {
58
+ // Already gone before we got started — nothing will ever arrive.
59
+ if (process.stdin.readableEnded) {
60
+ dieWithParent();
61
+ return;
62
+ }
63
+ let buffered = '';
64
+ process.stdin.setEncoding('utf8');
65
+ process.stdin.on('data', (chunk) => {
66
+ buffered += chunk;
67
+ const newline = buffered.indexOf('\n');
68
+ if (newline !== -1)
69
+ resolve(buffered.slice(0, newline));
70
+ });
71
+ process.stdin.on('error', reject);
72
+ });
73
+ }
74
+ // Narrow the request at the boundary — the repo's rule for anything parsed, even
75
+ // when the only writer is our own parent.
76
+ function parseRequest(raw) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ if (typeof parsed !== 'object' || parsed === null)
85
+ return undefined;
86
+ const record = parsed;
87
+ const { account, op, secret, service } = record;
88
+ if (op !== 'delete' && op !== 'get' && op !== 'set')
89
+ return undefined;
90
+ if (typeof service !== 'string' || typeof account !== 'string')
91
+ return undefined;
92
+ if (secret !== undefined && typeof secret !== 'string')
93
+ return undefined;
94
+ return { account, op, secret, service };
95
+ }
96
+ async function main() {
97
+ const request = parseRequest(await readRequestLine());
98
+ if (request === undefined) {
99
+ emit({ error: 'the keychain helper got no readable request', ok: false });
100
+ return;
101
+ }
102
+ const entry = new AsyncEntry(request.service, request.account);
103
+ try {
104
+ switch (request.op) {
105
+ case 'delete': {
106
+ await entry.deletePassword();
107
+ emit({ ok: true, value: null });
108
+ return;
109
+ }
110
+ case 'get': {
111
+ const value = await entry.getPassword();
112
+ emit({ ok: true, value: value ?? null });
113
+ return;
114
+ }
115
+ case 'set': {
116
+ await entry.setPassword(request.secret ?? '');
117
+ emit({ ok: true, value: null });
118
+ }
119
+ }
120
+ }
121
+ catch (error) {
122
+ // The message is the keychain's own ("No matching entry found…"), which the
123
+ // parent needs verbatim to tell "empty" apart from "unusable". It never
124
+ // contains the secret — that is the value we did not get.
125
+ emit({ error: error instanceof Error ? error.message : String(error), ok: false });
126
+ }
127
+ }
128
+ await main();
129
+ // Explicit, because stdin is deliberately still open as the parent's heartbeat
130
+ // and its listeners would otherwise keep this process alive forever — the answer
131
+ // would be written and the parent would still wait for a close that never came.
132
+ // Safe to exit here: the result left through a synchronous write, and the
133
+ // keychain call is already finished, so there is no libuv worker left to join.
134
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- the heartbeat pipe keeps the loop alive
135
+ process.exit(0);
@@ -1,16 +1,33 @@
1
+ import { TimeoutError } from '../errors.js';
1
2
  import { type Credentials } from './credentials.js';
3
+ import { type KeychainChildRequest } from './keychain-child.js';
2
4
  export declare const KEYRING_SERVICE = "io.flipstream.cli";
5
+ export declare const KEYCHAIN_TIMEOUT_MS = 5000;
6
+ export declare const KEYCHAIN_DEFAULT_TIMEOUT_MS = 30000;
7
+ export declare const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 120000;
8
+ export declare const KEYCHAIN_NOTICE_AFTER_MS = 10000;
9
+ export declare const KEYCHAIN_TIMEOUT_ENV = "FLIPSTREAM_KEYCHAIN_TIMEOUT_MS";
10
+ export declare const KEYCHAIN_INTERACTIVE_ENV = "FLIPSTREAM_KEYCHAIN_INTERACTIVE";
11
+ export declare const KEYCHAIN_MAX_TIMEOUT_MS = 2147483647;
3
12
  export interface TokenStore {
4
- accessTokenIfFresh(host: string): null | string;
5
- available(): boolean;
6
- clear(host: string): void;
7
- load(host: string): Credentials | null;
8
- save(host: string, creds: Credentials): void;
13
+ accessTokenIfFresh(host: string): Promise<null | string>;
14
+ available(): Promise<boolean>;
15
+ clear(host: string): Promise<void>;
16
+ load(host: string): Promise<Credentials | null>;
17
+ reload(host: string): Promise<Credentials | null>;
18
+ save(host: string, creds: Credentials): Promise<void>;
9
19
  }
20
+ export declare function keychainDeadlineMs(env?: NodeJS.ProcessEnv): number;
21
+ export declare function keychainTimeout(operation: string, timeoutMs?: number): TimeoutError;
22
+ export declare function withDeadline<T>(operation: string, run: (signal: AbortSignal) => Promise<T>, timeoutMs?: number): Promise<T>;
23
+ export declare function spawnKeychain(request: KeychainChildRequest, signal: AbortSignal, scriptPath?: string): Promise<null | string>;
10
24
  export declare class KeyringStore implements TokenStore {
11
- accessTokenIfFresh(host: string): null | string;
12
- available(): boolean;
13
- clear(host: string): void;
14
- load(host: string): Credentials | null;
15
- save(host: string, creds: Credentials): void;
25
+ #private;
26
+ constructor(scriptPath?: string);
27
+ accessTokenIfFresh(host: string): Promise<null | string>;
28
+ available(): Promise<boolean>;
29
+ clear(host: string): Promise<void>;
30
+ load(host: string): Promise<Credentials | null>;
31
+ reload(host: string): Promise<Credentials | null>;
32
+ save(host: string, creds: Credentials): Promise<void>;
16
33
  }
@@ -1,45 +1,316 @@
1
- import { Entry } from '@napi-rs/keyring';
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { TimeoutError } from '../errors.js';
4
+ import { isCI } from '../output/interactivity.js';
5
+ import { isMachineMode } from '../output/machine-mode.js';
2
6
  import { freshAccessToken, keyOf, parseCredentials } from './credentials.js';
3
7
  // FROZEN. Tokens are stored in the OS keychain under this service id. Renaming
4
8
  // it strands users' tokens (silent logout + orphaned secrets), so any change
5
9
  // requires a one-time keyring-only migration (read old id -> write new id ->
6
10
  // delete old) — never a plaintext intermediary.
7
11
  export const KEYRING_SERVICE = 'io.flipstream.cli';
12
+ // Why a keychain call needs a deadline AT ALL: on macOS it can park in mach_msg
13
+ // forever, and nothing inside this process can recover from that. An AbortSignal
14
+ // does not interrupt a syscall already in flight, and process.exit() and
15
+ // process.reallyExit() both hang too, because Node's shutdown joins its libuv
16
+ // workers and the wedged one never finishes. Hence keychain-child.ts: the work
17
+ // runs in a process we can actually kill.
18
+ //
19
+ // How long to wait was argued over seven review rounds, and the useful outcome
20
+ // was learning that the question cannot be answered by detection. Every signal
21
+ // tried was wrong: stdio says nothing (SecurityAgent draws a GUI dialog, so a
22
+ // desktop-launched CLI has pipes and an answerable prompt), SSH_* says nothing
23
+ // (a tmux session started before an SSH login inherits none), and `launchctl
24
+ // managername` says nothing either — the machine this was found on reported Aqua
25
+ // with SSH_CONNECTION unset while its console sat unattended. Aqua proves a
26
+ // screen exists, not that a person is in front of it. There is no evidence
27
+ // available here that somebody is there to click.
28
+ //
29
+ // So the deadline is a judgement rather than a deduction, and it is split three
30
+ // ways. An unattended machine should not sit for minutes, so the DEFAULT is
31
+ // thirty seconds. Somebody who is there gets told at ten what is happening and
32
+ // has twenty more to answer — and if they want longer they can say so, which is
33
+ // the only statement about presence anybody can actually make. A caller that has
34
+ // declared nothing can answer fails fastest of all.
35
+ export const KEYCHAIN_TIMEOUT_MS = 5000;
36
+ export const KEYCHAIN_DEFAULT_TIMEOUT_MS = 30_000;
37
+ export const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 120_000;
38
+ // How long a keychain call may take before we explain the wait on stderr.
39
+ export const KEYCHAIN_NOTICE_AFTER_MS = 10_000;
40
+ export const KEYCHAIN_TIMEOUT_ENV = 'FLIPSTREAM_KEYCHAIN_TIMEOUT_MS';
41
+ export const KEYCHAIN_INTERACTIVE_ENV = 'FLIPSTREAM_KEYCHAIN_INTERACTIVE';
42
+ // setTimeout's ceiling. A larger delay is silently clamped to ~1ms, which would
43
+ // turn "wait longer" into "fail immediately" — the opposite of the request.
44
+ export const KEYCHAIN_MAX_TIMEOUT_MS = 2_147_483_647;
45
+ function isTruthy(value) {
46
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
47
+ }
48
+ // How long to wait for the OS on THIS run. Every path is a stated fact — an
49
+ // exact figure, a declared willingness to wait, or a declared inability to
50
+ // answer — because nothing observable about the session is evidence either way.
51
+ export function keychainDeadlineMs(env = process.env) {
52
+ const override = Number(env[KEYCHAIN_TIMEOUT_ENV]);
53
+ if (Number.isInteger(override) && override > 0 && override <= KEYCHAIN_MAX_TIMEOUT_MS)
54
+ return override;
55
+ if (isTruthy(env[KEYCHAIN_INTERACTIVE_ENV]))
56
+ return KEYCHAIN_INTERACTIVE_TIMEOUT_MS;
57
+ if (isCI(env) || isTruthy(env.FLIPSTREAM_NO_INPUT))
58
+ return KEYCHAIN_TIMEOUT_MS;
59
+ return KEYCHAIN_DEFAULT_TIMEOUT_MS;
60
+ }
61
+ // A keychain call that ran out of time — exit 8, like any other timeout.
62
+ // DISTINCT from "no credentials" on purpose: reporting a stuck keychain as "not
63
+ // logged in" sends a logged-in user to `auth login`, which needs the same
64
+ // keychain and hangs in the same place.
65
+ export function keychainTimeout(operation, timeoutMs = KEYCHAIN_DEFAULT_TIMEOUT_MS) {
66
+ return new TimeoutError(`The OS keychain did not respond within ${timeoutMs} ms (${operation}).`, 'keychain_timeout').withDetails({
67
+ hint: 'A keychain prompt was probably waiting with nobody at that machine to approve it — or the ' +
68
+ 'session cannot show one at all (SSH, a locked console, an unattended box). Over SSH or any ' +
69
+ 'other remote access, use a Flipstream service account from your Flipstream account rather ' +
70
+ 'than an interactive login: it needs no browser and no keychain. Otherwise approve the dialog ' +
71
+ `on that machine and set ${KEYCHAIN_INTERACTIVE_ENV}=1 (or ${KEYCHAIN_TIMEOUT_ENV}=<ms>) to ` +
72
+ 'allow time for it, or set FLIPSTREAM_NO_KEYRING=1 for an in-memory session that keeps nothing ' +
73
+ 'between commands.',
74
+ retryable: true,
75
+ });
76
+ }
77
+ // Say something, once, when a keychain call is taking a human amount of time.
78
+ // This is what stops a deadline long enough to be useful from looking like the
79
+ // original hang: the complaint was never the seconds, it was the silence.
80
+ //
81
+ // SILENT IN MACHINE MODE. Under --json/--ndjson stderr must stay empty (the
82
+ // agent contract, pinned by a test), and an agent has nobody to ask anyway.
83
+ function announceWait() {
84
+ const timer = setTimeout(() => {
85
+ if (isMachineMode())
86
+ return;
87
+ process.stderr.write("→ Still waiting on the OS keychain. Are you filling in the keychain prompt? If there's " +
88
+ 'nobody at that machine — SSH, or any other remote access — use a Flipstream service ' +
89
+ 'account instead; it needs no browser and no keychain. Ctrl-C to stop waiting.\n');
90
+ }, KEYCHAIN_NOTICE_AFTER_MS);
91
+ // Never hold the process open for a notice that may never be due.
92
+ timer.unref?.();
93
+ return () => clearTimeout(timer);
94
+ }
95
+ // Run something under a wall-clock deadline, aborting the signal when the
96
+ // deadline wins. Kept separate from the spawning below so the deadline itself is
97
+ // testable without touching a real keychain.
98
+ export async function withDeadline(operation, run, timeoutMs = keychainDeadlineMs()) {
99
+ const controller = new AbortController();
100
+ let timer;
101
+ const deadline = new Promise((_resolve, reject) => {
102
+ timer = setTimeout(() => {
103
+ controller.abort();
104
+ reject(keychainTimeout(operation, timeoutMs));
105
+ }, timeoutMs);
106
+ });
107
+ try {
108
+ return await Promise.race([run(controller.signal), deadline]);
109
+ }
110
+ finally {
111
+ clearTimeout(timer);
112
+ }
113
+ }
114
+ // This module's sibling child script, in whichever form is on disk: dist/*.js
115
+ // for a published run, src/*.ts under the Bun dev entry. Derived from our OWN
116
+ // url rather than guessed, so it cannot point at a build that is not there.
117
+ function childScript() {
118
+ const self = import.meta.url;
119
+ return fileURLToPath(new URL(self.endsWith('.ts') ? './keychain-child.ts' : './keychain-child.js', self));
120
+ }
121
+ // Ask the child to do one operation, and KILL it if the deadline passes. SIGKILL
122
+ // rather than SIGTERM: a process wedged in the kernel will not run a handler.
123
+ //
124
+ // The runtime is process.execPath, never a node_modules/.bin shim — a shim
125
+ // encodes an assumption about the machine (a `node` on PATH), while this encodes
126
+ // only what the repo already guarantees. `scriptPath` is injectable so the kill
127
+ // path and the failure paths can be tested against helpers that misbehave on
128
+ // purpose.
129
+ export function spawnKeychain(request, signal, scriptPath = childScript()) {
130
+ return new Promise((resolve, reject) => {
131
+ const child = spawn(process.execPath, [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'] });
132
+ signal.addEventListener('abort', () => child.kill('SIGKILL'), { once: true });
133
+ // One line, and then the pipe STAYS OPEN as the child's heartbeat: losing it
134
+ // is how the child learns this process died and kills itself, instead of
135
+ // being reparented and blocking forever. Closing it here would hand that
136
+ // guarantee away for nothing.
137
+ child.stdin.on('error', reject);
138
+ child.stdin.write(`${JSON.stringify(request)}\n`);
139
+ let out = '';
140
+ child.stdout.setEncoding('utf8');
141
+ child.stdout.on('data', (chunk) => {
142
+ out += chunk;
143
+ });
144
+ child.on('error', reject);
145
+ child.on('close', () => {
146
+ // Killed by our own deadline: withDeadline's rejection is already on its
147
+ // way, so settling here would only race it.
148
+ if (signal.aborted)
149
+ return;
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(out.trim());
153
+ }
154
+ catch {
155
+ reject(new Error('the keychain helper returned no readable answer'));
156
+ return;
157
+ }
158
+ if (parsed.ok)
159
+ resolve(parsed.value ?? null);
160
+ // The child's message is the keychain's own wording, which the callers
161
+ // below match on to tell "empty" apart from "unusable".
162
+ else
163
+ reject(new Error(parsed.error ?? 'the keychain helper failed'));
164
+ });
165
+ });
166
+ }
167
+ async function keychain(call) {
168
+ const request = {
169
+ account: call.account,
170
+ op: call.op,
171
+ secret: call.secret,
172
+ service: KEYRING_SERVICE,
173
+ };
174
+ const done = announceWait();
175
+ try {
176
+ return await withDeadline(call.operation, (signal) => spawnKeychain(request, signal, call.scriptPath));
177
+ }
178
+ finally {
179
+ done();
180
+ }
181
+ }
182
+ // "Not found" is the keychain working and holding nothing — the ONLY failure
183
+ // that means absence. A helper that could not spawn, crashed loading its native
184
+ // binding, or answered with something unreadable has told us nothing about
185
+ // whether a credential exists, and must never be read as "there isn't one".
186
+ function isNotFound(error) {
187
+ return /not found|no (matching )?(entry|password|item)|could not be found/i.test(error.message);
188
+ }
8
189
  // TokenStore backed by the OS keychain. The whole Credentials JSON is the single
9
190
  // secret per host (keyed by host).
191
+ //
192
+ // Reads are CACHED for the life of the process. Without this, one command touches
193
+ // the keychain several times — the auth gate reads it, then every authed request
194
+ // re-reads it through withFreshToken, and a paginated call re-reads it per page.
195
+ // On macOS each read is a separate access, so a user who granted "Allow" rather
196
+ // than "Always Allow" gets prompted once per read: two dialogs for `catalog`,
197
+ // three for `query`, more for anything that paginates or resolves a name. Each
198
+ // read is also a process now, so the cache saves spawns as well as dialogs.
199
+ //
200
+ // This costs nothing in EXPOSURE: the credentials are already in memory the moment
201
+ // they are read — they have to be, to go in an Authorization header — so holding
202
+ // them for the rest of a short-lived CLI process reveals nothing new. Nothing is
203
+ // written to disk, and the cache dies with the process.
204
+ //
205
+ // It does cost COHERENCE, and that is worth stating plainly rather than glossing.
206
+ // Another CLI process can log out or rotate tokens underneath us, and this cache
207
+ // will not notice. Two consequences, handled differently:
208
+ //
209
+ // Deleting someone else's session — handled. A failed refresh compare-and-deletes
210
+ // (see auth/refresh.ts): it re-reads past this cache and only clears when the
211
+ // stored token is still the one it tried. That race predates the cache; caching
212
+ // only widened the window.
213
+ //
214
+ // Using a token after a concurrent `auth logout` — accepted. Logout REVOKES
215
+ // upstream, so a stale cached token gets a 401 from the server rather than data,
216
+ // surfacing as data_auth_failed. Re-reading before every request would fix it and
217
+ // would also undo the entire point of caching, for a case that already fails
218
+ // safely.
10
219
  export class KeyringStore {
11
- accessTokenIfFresh(host) {
12
- return freshAccessToken(this.load(host));
220
+ // `null` is cached too: "this host has no credentials" is an answer worth
221
+ // remembering, or a logged-out run re-reads on every check.
222
+ #cache = new Map();
223
+ // Injectable purely so the failure paths can be driven by a helper that
224
+ // misbehaves on purpose; production always uses the sibling script.
225
+ #scriptPath;
226
+ constructor(scriptPath) {
227
+ this.#scriptPath = scriptPath;
13
228
  }
14
- available() {
229
+ async accessTokenIfFresh(host) {
230
+ return freshAccessToken(await this.load(host));
231
+ }
232
+ async available() {
15
233
  try {
16
- new Entry(KEYRING_SERVICE, '__probe__').getPassword();
234
+ await keychain({
235
+ account: '__probe__',
236
+ op: 'get',
237
+ operation: 'probing the keychain',
238
+ scriptPath: this.#scriptPath,
239
+ });
17
240
  return true;
18
241
  }
19
242
  catch (error) {
243
+ // A wedged keychain is NOT an answer to "is one available" — it is the
244
+ // timeout this whole change exists to report. Returning false here sent
245
+ // `auth status` down its unavailable branch and out with exit 7 (network),
246
+ // for the one condition whose entire point is exit 8 and a message naming
247
+ // the prompt. It propagates.
248
+ if (error instanceof TimeoutError)
249
+ throw error;
20
250
  // "not found" means the keychain works but is empty (available); any other
21
251
  // failure (e.g. no Secret Service on headless Linux) means it is unavailable.
22
- return /not found|no (matching )?(entry|password|item)|could not be found/i.test(error.message);
252
+ return isNotFound(error);
23
253
  }
24
254
  }
25
- clear(host) {
255
+ async clear(host) {
26
256
  try {
27
- new Entry(KEYRING_SERVICE, keyOf(host)).deletePassword();
257
+ await keychain({
258
+ account: keyOf(host),
259
+ op: 'delete',
260
+ operation: `clearing credentials for ${host}`,
261
+ scriptPath: this.#scriptPath,
262
+ });
28
263
  }
29
- catch {
30
- // Nothing stored for this host.
264
+ catch (error) {
265
+ // Only a verified "there was nothing to delete" counts as cleared. Anything
266
+ // else — a timeout, a helper that could not start, an unreadable answer —
267
+ // leaves the credential possibly intact, and logout must not report success
268
+ // over it.
269
+ if (!isNotFound(error))
270
+ throw error;
31
271
  }
272
+ // Cached only once the deletion is confirmed, so a failed clear cannot leave
273
+ // this process believing the session is gone.
274
+ this.#cache.set(host, null);
32
275
  }
33
- load(host) {
276
+ async load(host) {
277
+ const cached = this.#cache.get(host);
278
+ if (cached !== undefined)
279
+ return cached;
280
+ return this.reload(host);
281
+ }
282
+ async reload(host) {
283
+ let creds = null;
34
284
  try {
35
- const secret = new Entry(KEYRING_SERVICE, keyOf(host)).getPassword();
36
- return secret ? parseCredentials(secret) : null;
285
+ const secret = await keychain({
286
+ account: keyOf(host),
287
+ op: 'get',
288
+ operation: `reading credentials for ${host}`,
289
+ scriptPath: this.#scriptPath,
290
+ });
291
+ creds = secret ? parseCredentials(secret) : null;
37
292
  }
38
- catch {
39
- return null;
293
+ catch (error) {
294
+ // Absence is the only thing we may infer. Every other failure propagates:
295
+ // "we could not read" reported as "nothing stored" turns a spawn failure
296
+ // into not_logged_in and sends a logged-in user to re-authenticate.
297
+ if (!isNotFound(error))
298
+ throw error;
299
+ creds = null;
40
300
  }
301
+ this.#cache.set(host, creds);
302
+ return creds;
41
303
  }
42
- save(host, creds) {
43
- new Entry(KEYRING_SERVICE, keyOf(host)).setPassword(JSON.stringify(creds));
304
+ async save(host, creds) {
305
+ await keychain({
306
+ account: keyOf(host),
307
+ op: 'set',
308
+ operation: `saving credentials for ${host}`,
309
+ scriptPath: this.#scriptPath,
310
+ secret: JSON.stringify(creds),
311
+ });
312
+ // Keep the cache authoritative: a refresh saves rotated tokens mid-command,
313
+ // and a later read must see them rather than the ones it started with.
314
+ this.#cache.set(host, creds);
44
315
  }
45
316
  }
@@ -2,9 +2,10 @@ import type { TokenStore } from './keyring.js';
2
2
  import { type Credentials } from './credentials.js';
3
3
  export declare class MemoryStore implements TokenStore {
4
4
  private readonly entries;
5
- accessTokenIfFresh(host: string): null | string;
6
- available(): boolean;
7
- clear(host: string): void;
8
- load(host: string): Credentials | null;
9
- save(host: string, creds: Credentials): void;
5
+ accessTokenIfFresh(host: string): Promise<null | string>;
6
+ available(): Promise<boolean>;
7
+ clear(host: string): Promise<void>;
8
+ load(host: string): Promise<Credentials | null>;
9
+ reload(host: string): Promise<Credentials | null>;
10
+ save(host: string, creds: Credentials): Promise<void>;
10
11
  }
@@ -3,19 +3,27 @@ import { freshAccessToken, keyOf } from './credentials.js';
3
3
  // Interchangeable with KeyringStore via the TokenStore interface.
4
4
  export class MemoryStore {
5
5
  entries = new Map();
6
- accessTokenIfFresh(host) {
7
- return freshAccessToken(this.load(host));
6
+ async accessTokenIfFresh(host) {
7
+ return freshAccessToken(await this.load(host));
8
8
  }
9
- available() {
9
+ // Async to match TokenStore, which is async because a REAL keychain call has
10
+ // to be cancellable (see keyring.ts). Nothing here can block; the signature
11
+ // exists so tests drive the same call shape production does.
12
+ async available() {
10
13
  return true;
11
14
  }
12
- clear(host) {
15
+ async clear(host) {
13
16
  this.entries.delete(keyOf(host));
14
17
  }
15
- load(host) {
18
+ async load(host) {
16
19
  return this.entries.get(keyOf(host)) ?? null;
17
20
  }
18
- save(host, creds) {
21
+ // No cache here, so a forced re-read is the same read. Present so the interface
22
+ // is honest and tests exercise the same call sites as the real store.
23
+ async reload(host) {
24
+ return this.load(host);
25
+ }
26
+ async save(host, creds) {
19
27
  this.entries.set(keyOf(host), creds);
20
28
  }
21
29
  }