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.
- package/README.md +168 -12
- package/dist/commands/auth/clear-all.d.ts +1 -0
- package/dist/commands/auth/clear-all.js +91 -24
- package/dist/commands/auth/login.js +8 -2
- package/dist/commands/auth/logout.js +2 -2
- package/dist/commands/auth/status.js +35 -8
- package/dist/commands/catalog.d.ts +15 -0
- package/dist/commands/catalog.js +110 -0
- package/dist/commands/connections/list.d.ts +1 -0
- package/dist/commands/connections/list.js +28 -2
- package/dist/commands/contract.d.ts +11 -0
- package/dist/commands/contract.js +35 -0
- package/dist/commands/health.d.ts +10 -0
- package/dist/commands/health.js +31 -0
- package/dist/commands/log/add.js +7 -3
- package/dist/commands/log/list.js +1 -1
- package/dist/commands/query.d.ts +15 -2
- package/dist/commands/query.js +256 -43
- package/dist/commands/skills/install.d.ts +16 -0
- package/dist/commands/skills/install.js +55 -0
- package/dist/commands/workspaces/connections.js +4 -2
- package/dist/commands/workspaces/get.js +5 -3
- package/dist/commands/workspaces/list.js +4 -1
- package/dist/lib/api/errors.d.ts +1 -0
- package/dist/lib/api/errors.js +13 -2
- package/dist/lib/api/http.d.ts +2 -0
- package/dist/lib/api/http.js +40 -4
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/retry.d.ts +1 -1
- package/dist/lib/api/retry.js +1 -1
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/claims.d.ts +1 -1
- package/dist/lib/auth/claims.js +2 -2
- package/dist/lib/auth/flow.js +10 -2
- package/dist/lib/auth/headless.js +14 -10
- package/dist/lib/auth/refresh.js +33 -3
- package/dist/lib/auth/session.js +1 -1
- package/dist/lib/command/base.d.ts +4 -0
- package/dist/lib/command/base.js +102 -3
- package/dist/lib/command/flags.d.ts +4 -0
- package/dist/lib/command/flags.js +11 -0
- package/dist/lib/command/planner.d.ts +9 -0
- package/dist/lib/command/planner.js +14 -0
- package/dist/lib/config/constants.d.ts +3 -1
- package/dist/lib/config/constants.js +14 -1
- package/dist/lib/config/xdg.d.ts +4 -0
- package/dist/lib/config/xdg.js +56 -1
- package/dist/lib/errors.d.ts +20 -1
- package/dist/lib/errors.js +125 -17
- package/dist/lib/output/dialogs.d.ts +27 -0
- package/dist/lib/output/dialogs.js +94 -0
- package/dist/lib/output/interactivity.d.ts +11 -0
- package/dist/lib/output/interactivity.js +54 -0
- package/dist/lib/output/machine-mode.d.ts +2 -0
- package/dist/lib/output/machine-mode.js +25 -0
- package/dist/lib/output/redact.d.ts +1 -0
- package/dist/lib/output/redact.js +12 -0
- package/dist/lib/output/runlog.d.ts +3 -0
- package/dist/lib/output/runlog.js +72 -0
- package/dist/lib/output/sanitize.d.ts +2 -0
- package/dist/lib/output/sanitize.js +57 -0
- package/dist/lib/output/sidecar.d.ts +30 -0
- package/dist/lib/output/sidecar.js +58 -0
- package/dist/lib/output/table.js +5 -1
- package/dist/lib/output/trace.d.ts +11 -0
- package/dist/lib/output/trace.js +89 -0
- package/dist/lib/planner/catalog.d.ts +26 -0
- package/dist/lib/planner/catalog.js +60 -0
- package/dist/lib/planner/client.d.ts +14 -0
- package/dist/lib/planner/client.js +47 -0
- package/dist/lib/planner/connection.d.ts +14 -0
- package/dist/lib/planner/connection.js +139 -0
- package/dist/lib/planner/diagnose.d.ts +8 -0
- package/dist/lib/planner/diagnose.js +50 -0
- package/dist/lib/planner/errors.d.ts +14 -0
- package/dist/lib/planner/errors.js +129 -0
- package/dist/lib/planner/filters.d.ts +8 -0
- package/dist/lib/planner/filters.js +74 -0
- package/dist/lib/planner/request.d.ts +24 -0
- package/dist/lib/planner/request.js +51 -0
- package/dist/lib/planner/suggest.d.ts +2 -0
- package/dist/lib/planner/suggest.js +45 -0
- package/dist/lib/planner/vocabulary.d.ts +9 -0
- package/dist/lib/planner/vocabulary.js +95 -0
- package/dist/lib/skills/install.d.ts +24 -0
- package/dist/lib/skills/install.js +69 -0
- package/dist/lib/store/keychain-child.d.ts +11 -0
- package/dist/lib/store/keychain-child.js +135 -0
- package/dist/lib/store/keyring.d.ts +27 -10
- package/dist/lib/store/keyring.js +288 -17
- package/dist/lib/store/memory-store.d.ts +6 -5
- package/dist/lib/store/memory-store.js +14 -6
- package/docs/AGENT-CONTRACT.md +238 -0
- package/oclif.manifest.json +392 -8
- package/package.json +7 -3
- 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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
229
|
+
async accessTokenIfFresh(host) {
|
|
230
|
+
return freshAccessToken(await this.load(host));
|
|
231
|
+
}
|
|
232
|
+
async available() {
|
|
15
233
|
try {
|
|
16
|
-
|
|
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
|
|
252
|
+
return isNotFound(error);
|
|
23
253
|
}
|
|
24
254
|
}
|
|
25
|
-
clear(host) {
|
|
255
|
+
async clear(host) {
|
|
26
256
|
try {
|
|
27
|
-
|
|
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
|
-
//
|
|
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 =
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|