@phnx-labs/agents-cli 1.20.64 → 1.20.65
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/CHANGELOG.md +39 -3
- package/README.md +37 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.d.ts +12 -0
- package/dist/commands/apply.js +274 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/cloud.js +32 -2
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/exec.js +94 -49
- package/dist/commands/feed.js +25 -11
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/mcp.js +55 -5
- package/dist/commands/monitors.d.ts +12 -0
- package/dist/commands/monitors.js +740 -0
- package/dist/commands/output.js +2 -2
- package/dist/commands/routines.js +23 -2
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +215 -64
- package/dist/commands/serve.js +31 -0
- package/dist/commands/sessions-export.js +8 -3
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +36 -6
- package/dist/commands/ssh.js +45 -2
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +26 -0
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +34 -14
- package/dist/lib/agents.d.ts +18 -0
- package/dist/lib/agents.js +53 -3
- package/dist/lib/auto-dispatch-provider.js +7 -2
- package/dist/lib/auto-dispatch.d.ts +3 -0
- package/dist/lib/auto-dispatch.js +3 -0
- package/dist/lib/browser/chrome.js +2 -2
- package/dist/lib/cloud/antigravity.js +2 -2
- package/dist/lib/cloud/host.d.ts +59 -0
- package/dist/lib/cloud/host.js +224 -0
- package/dist/lib/cloud/registry.js +4 -0
- package/dist/lib/cloud/types.d.ts +6 -4
- package/dist/lib/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.js +5 -1
- package/dist/lib/crabbox/runtimes.js +11 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +62 -19
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/registry.d.ts +15 -0
- package/dist/lib/devices/registry.js +9 -0
- package/dist/lib/exec.d.ts +19 -2
- package/dist/lib/exec.js +41 -13
- package/dist/lib/fleet/apply.d.ts +63 -0
- package/dist/lib/fleet/apply.js +214 -0
- package/dist/lib/fleet/auth-sync.d.ts +67 -0
- package/dist/lib/fleet/auth-sync.js +142 -0
- package/dist/lib/fleet/manifest.d.ts +29 -0
- package/dist/lib/fleet/manifest.js +127 -0
- package/dist/lib/fleet/types.d.ts +129 -0
- package/dist/lib/fleet/types.js +13 -0
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +34 -2
- package/dist/lib/hosts/dispatch.d.ts +29 -8
- package/dist/lib/hosts/dispatch.js +46 -18
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/providers/devices.d.ts +27 -0
- package/dist/lib/hosts/providers/devices.js +98 -0
- package/dist/lib/hosts/registry.d.ts +10 -16
- package/dist/lib/hosts/registry.js +17 -50
- package/dist/lib/hosts/remote-cmd.d.ts +23 -0
- package/dist/lib/hosts/remote-cmd.js +71 -0
- package/dist/lib/hosts/run-target.d.ts +84 -0
- package/dist/lib/hosts/run-target.js +99 -0
- package/dist/lib/hosts/types.d.ts +23 -5
- package/dist/lib/hosts/types.js +22 -4
- package/dist/lib/linear-autoclose.d.ts +30 -0
- package/dist/lib/linear-autoclose.js +22 -0
- package/dist/lib/mcp.d.ts +27 -1
- package/dist/lib/mcp.js +126 -12
- package/dist/lib/monitors/config.d.ts +161 -0
- package/dist/lib/monitors/config.js +372 -0
- package/dist/lib/monitors/dispatch.d.ts +28 -0
- package/dist/lib/monitors/dispatch.js +91 -0
- package/dist/lib/monitors/engine.d.ts +61 -0
- package/dist/lib/monitors/engine.js +201 -0
- package/dist/lib/monitors/sources/command.d.ts +11 -0
- package/dist/lib/monitors/sources/command.js +31 -0
- package/dist/lib/monitors/sources/device.d.ts +13 -0
- package/dist/lib/monitors/sources/device.js +35 -0
- package/dist/lib/monitors/sources/file.d.ts +14 -0
- package/dist/lib/monitors/sources/file.js +57 -0
- package/dist/lib/monitors/sources/http.d.ts +10 -0
- package/dist/lib/monitors/sources/http.js +34 -0
- package/dist/lib/monitors/sources/index.d.ts +14 -0
- package/dist/lib/monitors/sources/index.js +31 -0
- package/dist/lib/monitors/sources/poll.d.ts +9 -0
- package/dist/lib/monitors/sources/poll.js +9 -0
- package/dist/lib/monitors/sources/types.d.ts +18 -0
- package/dist/lib/monitors/sources/types.js +9 -0
- package/dist/lib/monitors/sources/webhook.d.ts +23 -0
- package/dist/lib/monitors/sources/webhook.js +47 -0
- package/dist/lib/monitors/sources/ws.d.ts +14 -0
- package/dist/lib/monitors/sources/ws.js +45 -0
- package/dist/lib/monitors/state.d.ts +69 -0
- package/dist/lib/monitors/state.js +144 -0
- package/dist/lib/platform/exec.d.ts +16 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/plugins.js +101 -2
- package/dist/lib/redact.d.ts +14 -1
- package/dist/lib/redact.js +47 -1
- package/dist/lib/remote-agents-json.js +7 -1
- package/dist/lib/rotate.d.ts +6 -3
- package/dist/lib/rotate.js +0 -1
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +19 -0
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +102 -9
- package/dist/lib/secrets/agent.d.ts +48 -10
- package/dist/lib/secrets/agent.js +123 -15
- package/dist/lib/secrets/bundles.d.ts +26 -0
- package/dist/lib/secrets/bundles.js +59 -8
- package/dist/lib/secrets/mcp.js +4 -2
- package/dist/lib/secrets/remote.d.ts +17 -0
- package/dist/lib/secrets/remote.js +40 -0
- package/dist/lib/self-update.d.ts +20 -0
- package/dist/lib/self-update.js +54 -1
- package/dist/lib/serve/control.d.ts +95 -0
- package/dist/lib/serve/control.js +260 -0
- package/dist/lib/serve/server.d.ts +35 -1
- package/dist/lib/serve/server.js +106 -76
- package/dist/lib/serve/stream.d.ts +43 -0
- package/dist/lib/serve/stream.js +116 -0
- package/dist/lib/serve/token.d.ts +35 -0
- package/dist/lib/serve/token.js +85 -0
- package/dist/lib/session/bundle.d.ts +14 -0
- package/dist/lib/session/bundle.js +12 -1
- package/dist/lib/session/remote-list.js +5 -1
- package/dist/lib/session/state.d.ts +7 -25
- package/dist/lib/session/state.js +16 -6
- package/dist/lib/session/sync/config.js +8 -2
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +86 -7
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- package/dist/lib/tmux/session.d.ts +7 -0
- package/dist/lib/tmux/session.js +3 -1
- package/dist/lib/triggers/webhook.d.ts +18 -0
- package/dist/lib/triggers/webhook.js +105 -0
- package/dist/lib/types.d.ts +26 -1
- package/dist/lib/usage.js +7 -5
- package/dist/lib/versions.js +14 -11
- package/dist/lib/workflows.d.ts +20 -0
- package/dist/lib/workflows.js +24 -0
- package/package.json +2 -1
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* the remaining devices.
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
|
+
import { isControlDevice } from './registry.js';
|
|
11
12
|
import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
|
|
12
13
|
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
14
|
export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
14
15
|
/**
|
|
15
16
|
* Classify each registered device for a fleet operation.
|
|
16
17
|
*
|
|
18
|
+
* - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
|
|
17
19
|
* - Tailscale-offline → skip `offline`
|
|
18
20
|
* - No address → skip `no-address`
|
|
19
21
|
* - Everything else is a target (including this machine, reached over ssh when
|
|
@@ -23,6 +25,11 @@ export function planFleetTargets(reg) {
|
|
|
23
25
|
const names = Object.keys(reg).sort();
|
|
24
26
|
return names.map((name) => {
|
|
25
27
|
const device = reg[name];
|
|
28
|
+
// A control device drives the fleet but never runs agents — never a target
|
|
29
|
+
// for update/run/stats, whatever its platform reads as.
|
|
30
|
+
if (isControlDevice(device)) {
|
|
31
|
+
return { device, skip: 'control' };
|
|
32
|
+
}
|
|
26
33
|
if (device.tailscale && !device.tailscale.online) {
|
|
27
34
|
return { device, skip: 'offline' };
|
|
28
35
|
}
|
|
@@ -42,6 +49,8 @@ export function skipLabel(reason) {
|
|
|
42
49
|
return 'offline';
|
|
43
50
|
case 'no-address':
|
|
44
51
|
return 'no address';
|
|
52
|
+
case 'control':
|
|
53
|
+
return 'control device';
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
/**
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/** Operating-system family of a device, used to pick the remote shell. */
|
|
2
2
|
export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
|
|
3
|
+
/**
|
|
4
|
+
* What a device is *for*. A `worker` (the default) can run agents — it's dialed
|
|
5
|
+
* over SSH for sessions and eligible for run/team placement. A `control` device
|
|
6
|
+
* is a cockpit that drives the fleet but never executes agents itself (an
|
|
7
|
+
* iPhone/iPad running the companion app): it appears in the fleet but is never
|
|
8
|
+
* dialed for SSH nor scheduled work. Absent `role` means `worker`.
|
|
9
|
+
*/
|
|
10
|
+
export type DeviceRole = 'worker' | 'control';
|
|
3
11
|
/** Remote shell dialect derived from the platform. */
|
|
4
12
|
export type DeviceShell = 'powershell' | 'posix';
|
|
5
13
|
/** How `agents ssh` authenticates to a device. Both are first-class, fully
|
|
@@ -41,9 +49,15 @@ export interface DeviceProfile {
|
|
|
41
49
|
address: DeviceAddress;
|
|
42
50
|
auth: DeviceAuth;
|
|
43
51
|
tailscale?: DeviceTailscale;
|
|
52
|
+
/** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
|
|
53
|
+
role?: DeviceRole;
|
|
44
54
|
createdAt: string;
|
|
45
55
|
updatedAt: string;
|
|
46
56
|
}
|
|
57
|
+
/** A device's effective role, defaulting to `worker` when unset. */
|
|
58
|
+
export declare function deviceRole(d: DeviceProfile): DeviceRole;
|
|
59
|
+
/** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
|
|
60
|
+
export declare function isControlDevice(d: DeviceProfile): boolean;
|
|
47
61
|
/** Map of device name to profile. */
|
|
48
62
|
export type DeviceRegistry = Record<string, DeviceProfile>;
|
|
49
63
|
/** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
|
|
@@ -74,6 +88,7 @@ export interface DeviceInput {
|
|
|
74
88
|
address?: DeviceAddress;
|
|
75
89
|
auth?: DeviceAuth;
|
|
76
90
|
tailscale?: DeviceTailscale;
|
|
91
|
+
role?: DeviceRole;
|
|
77
92
|
}
|
|
78
93
|
/**
|
|
79
94
|
* Create the device if absent, otherwise merge the supplied fields into the
|
|
@@ -19,6 +19,14 @@ import * as path from 'path';
|
|
|
19
19
|
import { randomBytes } from 'crypto';
|
|
20
20
|
import lockfile from 'proper-lockfile';
|
|
21
21
|
import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
|
|
22
|
+
/** A device's effective role, defaulting to `worker` when unset. */
|
|
23
|
+
export function deviceRole(d) {
|
|
24
|
+
return d.role ?? 'worker';
|
|
25
|
+
}
|
|
26
|
+
/** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
|
|
27
|
+
export function isControlDevice(d) {
|
|
28
|
+
return deviceRole(d) === 'control';
|
|
29
|
+
}
|
|
22
30
|
function registryPath() {
|
|
23
31
|
return getDevicesRegistryPath();
|
|
24
32
|
}
|
|
@@ -170,6 +178,7 @@ export async function upsertDevice(name, input) {
|
|
|
170
178
|
address: input.address ?? prev?.address ?? { via: 'manual' },
|
|
171
179
|
auth: input.auth ?? prev?.auth ?? { method: 'key' },
|
|
172
180
|
tailscale: input.tailscale ?? prev?.tailscale,
|
|
181
|
+
role: input.role ?? prev?.role,
|
|
173
182
|
createdAt: prev?.createdAt ?? now,
|
|
174
183
|
updatedAt: now,
|
|
175
184
|
};
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -120,6 +120,15 @@ export interface ExecOptions {
|
|
|
120
120
|
mcpConfigPath?: string;
|
|
121
121
|
/** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */
|
|
122
122
|
passthroughArgs?: string[];
|
|
123
|
+
/**
|
|
124
|
+
* Tee-and-tail the child's stdout even when no budget cap is active, so the
|
|
125
|
+
* caller can scan it for rate/usage-limit messages. Claude prints billing
|
|
126
|
+
* refusals ("monthly spend limit", "out of usage credits") to STDOUT, not
|
|
127
|
+
* stderr — a fallback chain that only inspects stderr never cascades on
|
|
128
|
+
* them. Set by runWithFallback for every chain entry; harmless elsewhere
|
|
129
|
+
* (output is mirrored to the parent's stdout exactly like stdio:'inherit').
|
|
130
|
+
*/
|
|
131
|
+
captureStdoutTail?: boolean;
|
|
123
132
|
/**
|
|
124
133
|
* Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
|
|
125
134
|
* when true, spawn the agent directly instead of inside a shared-socket tmux
|
|
@@ -150,7 +159,7 @@ export declare function resolveInteractive(options: Pick<ExecOptions, 'interacti
|
|
|
150
159
|
* @param piped true when the parent's stdout is NOT a TTY (output piped)
|
|
151
160
|
* @param capsActive true when a budget watcher is attached (caps configured)
|
|
152
161
|
*/
|
|
153
|
-
export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean): boolean;
|
|
162
|
+
export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean, captureTail?: boolean): boolean;
|
|
154
163
|
/** Parse an array of KEY=VALUE strings into an env record. Returns undefined for empty input. */
|
|
155
164
|
export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
|
|
156
165
|
/**
|
|
@@ -274,8 +283,16 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
|
|
274
283
|
* `#{pane_pid}`, clean signal delivery on detach/kill).
|
|
275
284
|
* Keys are filtered to valid identifiers so exported shell functions
|
|
276
285
|
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
286
|
+
*
|
|
287
|
+
* `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
|
|
288
|
+
* the KEY names. The env map here carries resolved secrets bundles (options.env),
|
|
289
|
+
* so the real string would embed secret VALUES — which get persisted verbatim
|
|
290
|
+
* into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
|
|
291
|
+
* real values; the stored/informational copy uses the redacted form (RUSH-1758).
|
|
277
292
|
*/
|
|
278
|
-
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv
|
|
293
|
+
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
|
|
294
|
+
redactEnvValues?: boolean;
|
|
295
|
+
}): string;
|
|
279
296
|
/**
|
|
280
297
|
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
281
298
|
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
package/dist/lib/exec.js
CHANGED
|
@@ -147,12 +147,13 @@ export function resolveInteractive(options) {
|
|
|
147
147
|
* @param piped true when the parent's stdout is NOT a TTY (output piped)
|
|
148
148
|
* @param capsActive true when a budget watcher is attached (caps configured)
|
|
149
149
|
*/
|
|
150
|
-
export function shouldTapStdout(interactive, piped, capsActive) {
|
|
150
|
+
export function shouldTapStdout(interactive, piped, capsActive, captureTail = false) {
|
|
151
151
|
if (interactive)
|
|
152
152
|
return false;
|
|
153
153
|
// Always pipe when the caller pipes us downstream (preserve composability),
|
|
154
|
-
//
|
|
155
|
-
|
|
154
|
+
// when caps are active so the watcher can read the stream at a TTY, or when
|
|
155
|
+
// a fallback chain needs a stdout tail for rate-limit detection.
|
|
156
|
+
return piped || capsActive || captureTail;
|
|
156
157
|
}
|
|
157
158
|
/** Pattern for valid environment variable names (C identifier rules). */
|
|
158
159
|
const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -866,11 +867,17 @@ export function shouldWrapInTmux(ctx) {
|
|
|
866
867
|
* `#{pane_pid}`, clean signal delivery on detach/kill).
|
|
867
868
|
* Keys are filtered to valid identifiers so exported shell functions
|
|
868
869
|
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
870
|
+
*
|
|
871
|
+
* `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
|
|
872
|
+
* the KEY names. The env map here carries resolved secrets bundles (options.env),
|
|
873
|
+
* so the real string would embed secret VALUES — which get persisted verbatim
|
|
874
|
+
* into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
|
|
875
|
+
* real values; the stored/informational copy uses the redacted form (RUSH-1758).
|
|
869
876
|
*/
|
|
870
|
-
export function buildTmuxAgentCommand(executable, args, env) {
|
|
877
|
+
export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
871
878
|
const envPrefix = Object.entries(env)
|
|
872
879
|
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
873
|
-
.map(([k, v]) => `${k}=${shellQuote(String(v))}`)
|
|
880
|
+
.map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
|
|
874
881
|
.join(' ');
|
|
875
882
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
876
883
|
return `exec env ${envPrefix} ${agentCmd}`;
|
|
@@ -917,11 +924,16 @@ async function runInTmux(options, executable, args) {
|
|
|
917
924
|
const cwd = options.cwd || process.cwd();
|
|
918
925
|
const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
|
|
919
926
|
const name = slugifyName(`ag-${options.agent}-${idSeed}`);
|
|
920
|
-
const
|
|
927
|
+
const execEnv = buildExecEnv(options);
|
|
928
|
+
// Launch with the real env (secret VALUES materialized into the pane); persist
|
|
929
|
+
// only a value-redacted copy in SessionMeta.cmd so resolved secrets never hit
|
|
930
|
+
// disk via the informational cmd field (RUSH-1758).
|
|
931
|
+
const cmd = buildTmuxAgentCommand(executable, args, execEnv);
|
|
932
|
+
const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
|
|
921
933
|
const labels = { agent: options.agent };
|
|
922
934
|
if (options.sessionId)
|
|
923
935
|
labels.sessionId = options.sessionId;
|
|
924
|
-
const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
|
|
936
|
+
const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
|
|
925
937
|
const pane = meta.pane;
|
|
926
938
|
if (pane) {
|
|
927
939
|
// When the AGENT pane dies, detach the client (don't kill) so the session
|
|
@@ -990,7 +1002,7 @@ async function runInTmux(options, executable, args) {
|
|
|
990
1002
|
await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
|
|
991
1003
|
}
|
|
992
1004
|
await killSession(name, socket).catch(() => { });
|
|
993
|
-
return { exitCode: before.status ?? 0, stderr: '' };
|
|
1005
|
+
return { exitCode: before.status ?? 0, stderr: '', stdout: '' };
|
|
994
1006
|
}
|
|
995
1007
|
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
996
1008
|
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
@@ -1003,10 +1015,10 @@ async function runInTmux(options, executable, args) {
|
|
|
1003
1015
|
await surfacePaneFailure(after.status, `${options.agent} exited`);
|
|
1004
1016
|
}
|
|
1005
1017
|
await killSession(name, socket).catch(() => { });
|
|
1006
|
-
return { exitCode: after.status ?? 0, stderr: '' };
|
|
1018
|
+
return { exitCode: after.status ?? 0, stderr: '', stdout: '' };
|
|
1007
1019
|
}
|
|
1008
1020
|
// Pane still alive → the user detached; keep the session for `agents focus`.
|
|
1009
|
-
return { exitCode: 0, stderr: '' };
|
|
1021
|
+
return { exitCode: 0, stderr: '', stdout: '' };
|
|
1010
1022
|
}
|
|
1011
1023
|
/**
|
|
1012
1024
|
* Spawn an agent process and return its exit code plus a tee'd copy of stderr.
|
|
@@ -1092,7 +1104,7 @@ async function spawnAgent(options) {
|
|
|
1092
1104
|
// PIPE (and later tee) stdout whenever the live budget watcher must read it
|
|
1093
1105
|
// — for ALL non-interactive runs when caps are active, regardless of TTY.
|
|
1094
1106
|
// See shouldTapStdout() for the rationale (FIX 3, issue #346).
|
|
1095
|
-
const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null);
|
|
1107
|
+
const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null, options.captureStdoutTail);
|
|
1096
1108
|
const stdio = interactive
|
|
1097
1109
|
? ['inherit', 'inherit', 'inherit']
|
|
1098
1110
|
: ['inherit', tapStdout ? 'pipe' : 'inherit', 'pipe'];
|
|
@@ -1127,10 +1139,17 @@ async function spawnAgent(options) {
|
|
|
1127
1139
|
timer.mark('startup');
|
|
1128
1140
|
let budgetKilled = false;
|
|
1129
1141
|
let budgetKillTimer;
|
|
1142
|
+
let stdoutTail = '';
|
|
1143
|
+
const STDOUT_TAIL_CAP = 16 * 1024;
|
|
1130
1144
|
if (!interactive && tapStdout && child.stdout) {
|
|
1131
1145
|
// TEE the child's stdout back to the parent's so the user still sees
|
|
1132
1146
|
// output (mirrors stdio:'inherit') while we tap the same stream for usage.
|
|
1133
1147
|
child.stdout.pipe(process.stdout);
|
|
1148
|
+
// Keep a rolling TAIL (billing refusals arrive at the very end of a run)
|
|
1149
|
+
// for the fallback chain's rate-limit scan.
|
|
1150
|
+
child.stdout.on('data', (chunk) => {
|
|
1151
|
+
stdoutTail = (stdoutTail + chunk.toString('utf-8')).slice(-STDOUT_TAIL_CAP);
|
|
1152
|
+
});
|
|
1134
1153
|
// Tap the same stream for budget usage events without consuming the pipe
|
|
1135
1154
|
// (a 'data' listener and .pipe() both receive every chunk). Kill on breach.
|
|
1136
1155
|
if (watcherState) {
|
|
@@ -1200,7 +1219,7 @@ async function spawnAgent(options) {
|
|
|
1200
1219
|
// teams/cloud can tell a budget termination apart from a normal failure.
|
|
1201
1220
|
const exitCode = budgetKilled ? BUDGET_KILL_EXIT_CODE : (code ?? 0);
|
|
1202
1221
|
timer.end({ exitCode, status: budgetKilled ? 'budget_killed' : code === 0 ? 'success' : 'failed' });
|
|
1203
|
-
resolve({ exitCode, stderr: stderrBuffer });
|
|
1222
|
+
resolve({ exitCode, stderr: stderrBuffer, stdout: stdoutTail });
|
|
1204
1223
|
});
|
|
1205
1224
|
});
|
|
1206
1225
|
}
|
|
@@ -1282,6 +1301,12 @@ export const RATE_LIMIT_PATTERNS = [
|
|
|
1282
1301
|
/too many requests/i,
|
|
1283
1302
|
/api[\s_-]?overloaded/i,
|
|
1284
1303
|
/\boverloaded\b/i,
|
|
1304
|
+
// Claude billing refusals — "You've hit your org's monthly spend limit" and
|
|
1305
|
+
// "You're out of usage credits". Both end the run with exit 1 and are exactly
|
|
1306
|
+
// the condition a fallback chain exists to recover from. Printed to STDOUT,
|
|
1307
|
+
// hence the stdout tail in SpawnResult.
|
|
1308
|
+
/spend[\s-]?limit/i,
|
|
1309
|
+
/out of (?:usage )?credits/i,
|
|
1285
1310
|
];
|
|
1286
1311
|
/** Return true if the text contains any known rate-limit or overload indicator. */
|
|
1287
1312
|
export function detectRateLimit(text) {
|
|
@@ -1370,6 +1395,9 @@ export async function runWithFallback(options) {
|
|
|
1370
1395
|
prompt,
|
|
1371
1396
|
env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
|
|
1372
1397
|
sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
|
|
1398
|
+
// Claude prints billing refusals (spend limit / out of credits) to
|
|
1399
|
+
// stdout; tail it so the cascade check below can see them.
|
|
1400
|
+
captureStdoutTail: true,
|
|
1373
1401
|
};
|
|
1374
1402
|
const label = version ? `${agent}@${version}` : agent;
|
|
1375
1403
|
const modelSwapNote = sameHostRetry && envOverride
|
|
@@ -1397,7 +1425,7 @@ export async function runWithFallback(options) {
|
|
|
1397
1425
|
const isLast = i === chain.length - 1;
|
|
1398
1426
|
if (isLast)
|
|
1399
1427
|
return result.exitCode;
|
|
1400
|
-
if (!detectRateLimit(result.stderr)) {
|
|
1428
|
+
if (!detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
|
|
1401
1429
|
return result.exitCode;
|
|
1402
1430
|
}
|
|
1403
1431
|
const next = chain[i + 1];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
|
|
3
|
+
* tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
|
|
4
|
+
* the real fleet over SSH and is verified end-to-end against live devices.
|
|
5
|
+
*
|
|
6
|
+
* Flow per device: probe → install/upgrade agents-cli → add missing agents →
|
|
7
|
+
* sync config → propagate login. Every step reuses an existing primitive
|
|
8
|
+
* (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
|
|
9
|
+
*/
|
|
10
|
+
import type { DeviceProfile } from '../devices/registry.js';
|
|
11
|
+
import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
|
|
12
|
+
/** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
|
|
13
|
+
export declare function agentIdOf(spec: string): string;
|
|
14
|
+
/** Source-side auth availability, computed once from `snapshotAuth`. */
|
|
15
|
+
export interface SourceAuth {
|
|
16
|
+
/** Agent ids the source has a readable, propagatable credential file for. */
|
|
17
|
+
available: Set<string>;
|
|
18
|
+
/** Agent ids whose source auth is device-bound (macOS keychain). */
|
|
19
|
+
bound: Set<string>;
|
|
20
|
+
/** The captured file payloads, keyed by agent. */
|
|
21
|
+
filesByAgent: Map<string, AuthFilePayload[]>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Can we propagate `agent`'s login to a target on `targetPlatform`? False when
|
|
25
|
+
* the agent has no portable file, the source can't provide it (bound / not
|
|
26
|
+
* signed in), or the target consumes credentials from its own keychain.
|
|
27
|
+
*/
|
|
28
|
+
export declare function canPushLogin(agent: string, targetPlatform: string | undefined, src: SourceAuth): boolean;
|
|
29
|
+
export interface DiffContext {
|
|
30
|
+
/** agents-cli version the source is on — the fleet target version. */
|
|
31
|
+
targetCliVersion: string;
|
|
32
|
+
sourceAuth: SourceAuth;
|
|
33
|
+
}
|
|
34
|
+
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
35
|
+
export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
|
|
36
|
+
/** Probe one device: reachability + agents-cli version + installed agent ids. */
|
|
37
|
+
export declare function probeDevice(device: DeviceProfile): DeviceProbe;
|
|
38
|
+
export interface ApplyStep {
|
|
39
|
+
kind: FleetAction['kind'];
|
|
40
|
+
ok: boolean;
|
|
41
|
+
detail: string;
|
|
42
|
+
}
|
|
43
|
+
export interface DeviceApplyResult {
|
|
44
|
+
device: string;
|
|
45
|
+
ok: boolean;
|
|
46
|
+
steps: ApplyStep[];
|
|
47
|
+
note?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ExecContext {
|
|
50
|
+
targetCliVersion: string;
|
|
51
|
+
source: string;
|
|
52
|
+
sourceAuth: SourceAuth;
|
|
53
|
+
/** Set for a dry run — probe + plan only, execute nothing. */
|
|
54
|
+
dryRun?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** Execute one device's planned actions in order. Real SSH — no mocks. */
|
|
57
|
+
export declare function reconcileDevice(row: DeviceDiff, device: DeviceProfile, ctx: ExecContext): DeviceApplyResult;
|
|
58
|
+
/** Run a pool of async tasks with a concurrency cap, preserving input order. */
|
|
59
|
+
export declare function pool<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
60
|
+
/** Reconcile every device row in parallel (capped). */
|
|
61
|
+
export declare function runFleetApply(rows: DeviceDiff[], nameToProfile: Map<string, DeviceProfile>, ctx: ExecContext, concurrency?: number): Promise<DeviceApplyResult[]>;
|
|
62
|
+
/** Default home for source snapshots (overridable in tests). */
|
|
63
|
+
export declare function sourceHome(): string;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
|
|
3
|
+
* tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
|
|
4
|
+
* the real fleet over SSH and is verified end-to-end against live devices.
|
|
5
|
+
*
|
|
6
|
+
* Flow per device: probe → install/upgrade agents-cli → add missing agents →
|
|
7
|
+
* sync config → propagate login. Every step reuses an existing primitive
|
|
8
|
+
* (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
|
|
9
|
+
*/
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import { sshTargetFor } from '../devices/connect.js';
|
|
12
|
+
import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
|
|
13
|
+
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
14
|
+
import { sshExec } from '../ssh-exec.js';
|
|
15
|
+
import { isPropagatableAgent, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './auth-sync.js';
|
|
16
|
+
/** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
|
|
17
|
+
export function agentIdOf(spec) {
|
|
18
|
+
return spec.split('@')[0].trim();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Can we propagate `agent`'s login to a target on `targetPlatform`? False when
|
|
22
|
+
* the agent has no portable file, the source can't provide it (bound / not
|
|
23
|
+
* signed in), or the target consumes credentials from its own keychain.
|
|
24
|
+
*/
|
|
25
|
+
export function canPushLogin(agent, targetPlatform, src) {
|
|
26
|
+
if (!isPropagatableAgent(agent))
|
|
27
|
+
return false;
|
|
28
|
+
if (src.bound.has(agent))
|
|
29
|
+
return false;
|
|
30
|
+
if (targetPlatform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(agent))
|
|
31
|
+
return false;
|
|
32
|
+
return src.available.has(agent);
|
|
33
|
+
}
|
|
34
|
+
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
35
|
+
export function diffFleet(desired, probes, ctx) {
|
|
36
|
+
const devices = [];
|
|
37
|
+
const actions = [];
|
|
38
|
+
for (const d of desired) {
|
|
39
|
+
const probe = probes.get(d.device) ?? {
|
|
40
|
+
device: d.device,
|
|
41
|
+
reachable: false,
|
|
42
|
+
installedAgents: [],
|
|
43
|
+
note: 'not probed',
|
|
44
|
+
};
|
|
45
|
+
const rowActions = [];
|
|
46
|
+
const loginBlocked = [];
|
|
47
|
+
if (probe.reachable) {
|
|
48
|
+
// agents-cli presence.
|
|
49
|
+
if (!probe.cliVersion) {
|
|
50
|
+
rowActions.push({ device: d.device, kind: 'install-cli', detail: `install agents-cli ${ctx.targetCliVersion}` });
|
|
51
|
+
}
|
|
52
|
+
else if (probe.cliVersion !== ctx.targetCliVersion) {
|
|
53
|
+
rowActions.push({ device: d.device, kind: 'upgrade-cli', detail: `agents-cli ${probe.cliVersion} -> ${ctx.targetCliVersion}` });
|
|
54
|
+
}
|
|
55
|
+
// agents.
|
|
56
|
+
for (const spec of d.agents) {
|
|
57
|
+
const id = agentIdOf(spec);
|
|
58
|
+
if (!probe.installedAgents.includes(id)) {
|
|
59
|
+
rowActions.push({ device: d.device, kind: 'add-agent', agent: id, detail: `install ${spec}` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// config.
|
|
63
|
+
if (d.sync.length > 0) {
|
|
64
|
+
rowActions.push({ device: d.device, kind: 'sync-config', detail: `sync config (${d.sync.join(', ')})` });
|
|
65
|
+
}
|
|
66
|
+
// login.
|
|
67
|
+
if (d.login === 'sync') {
|
|
68
|
+
for (const spec of d.agents) {
|
|
69
|
+
const id = agentIdOf(spec);
|
|
70
|
+
if (canPushLogin(id, probe.platform, ctx.sourceAuth)) {
|
|
71
|
+
rowActions.push({ device: d.device, kind: 'push-login', agent: id, detail: `propagate ${id} login` });
|
|
72
|
+
}
|
|
73
|
+
else if (isPropagatableAgent(id) &&
|
|
74
|
+
(ctx.sourceAuth.bound.has(id) || (probe.platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(id)))) {
|
|
75
|
+
// A login-propagation candidate we still can't push: the source token
|
|
76
|
+
// is keychain-bound (unextractable), or the macOS target consumes its
|
|
77
|
+
// own keychain. Surface those as manual — don't fake. Agents that were
|
|
78
|
+
// never propagation candidates (no portable file), or a source that
|
|
79
|
+
// simply isn't signed in, are silently skipped the same on every OS.
|
|
80
|
+
loginBlocked.push(id);
|
|
81
|
+
rowActions.push({ device: d.device, kind: 'needs-login', agent: id, detail: `${id} needs a manual login` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
|
|
87
|
+
actions.push(...rowActions);
|
|
88
|
+
}
|
|
89
|
+
return { devices, actions };
|
|
90
|
+
}
|
|
91
|
+
// ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
|
|
92
|
+
function osHint(platform) {
|
|
93
|
+
return platform === 'windows' ? 'windows' : undefined;
|
|
94
|
+
}
|
|
95
|
+
/** POSIX login shells often miss the shims dir; inject it (mirrors doctor). */
|
|
96
|
+
function remoteEnv(platform) {
|
|
97
|
+
return platform === 'windows' ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
98
|
+
}
|
|
99
|
+
/** Probe one device: reachability + agents-cli version + installed agent ids. */
|
|
100
|
+
export function probeDevice(device) {
|
|
101
|
+
let target;
|
|
102
|
+
try {
|
|
103
|
+
target = sshTargetFor(device);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: e.message };
|
|
107
|
+
}
|
|
108
|
+
const hint = osHint(device.platform);
|
|
109
|
+
const ready = readyProbe(target, hint);
|
|
110
|
+
if (!ready.reachable) {
|
|
111
|
+
return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: 'unreachable' };
|
|
112
|
+
}
|
|
113
|
+
let installed = [];
|
|
114
|
+
const remoteCmd = buildRemoteAgentsInvocation(['teams', 'doctor', '--json'], undefined, hint, remoteEnv(device.platform));
|
|
115
|
+
const res = sshExec(target, remoteCmd, { timeoutMs: 30000, multiplex: true });
|
|
116
|
+
if (res.code === 0) {
|
|
117
|
+
try {
|
|
118
|
+
const map = JSON.parse(res.stdout);
|
|
119
|
+
installed = Object.entries(map).filter(([, e]) => e?.installed).map(([k]) => k);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* agents-cli present but doctor output unparsable — treat as no agents */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
device: device.name,
|
|
127
|
+
reachable: true,
|
|
128
|
+
platform: device.platform,
|
|
129
|
+
cliVersion: ready.version ?? undefined,
|
|
130
|
+
installedAgents: installed,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Execute one device's planned actions in order. Real SSH — no mocks. */
|
|
134
|
+
export function reconcileDevice(row, device, ctx) {
|
|
135
|
+
if (!row.probe.reachable) {
|
|
136
|
+
return { device: row.device, ok: false, steps: [], note: row.probe.note ?? 'unreachable' };
|
|
137
|
+
}
|
|
138
|
+
const steps = [];
|
|
139
|
+
let target;
|
|
140
|
+
try {
|
|
141
|
+
target = sshTargetFor(device);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return { device: row.device, ok: false, steps: [], note: e.message };
|
|
145
|
+
}
|
|
146
|
+
const hint = osHint(device.platform);
|
|
147
|
+
const env = remoteEnv(device.platform);
|
|
148
|
+
let ok = true;
|
|
149
|
+
const sshAgents = (args, input) => sshExec(target, buildRemoteAgentsInvocation(args, undefined, hint, env), { timeoutMs: 300000, multiplex: true, input });
|
|
150
|
+
// 1. agents-cli install/upgrade.
|
|
151
|
+
const cliAction = row.actions.find((a) => a.kind === 'install-cli' || a.kind === 'upgrade-cli');
|
|
152
|
+
if (cliAction) {
|
|
153
|
+
const r = bootstrapAgentsCli(target, ctx.targetCliVersion, hint);
|
|
154
|
+
steps.push({ kind: cliAction.kind, ok: r.ok, detail: cliAction.detail });
|
|
155
|
+
ok = ok && r.ok;
|
|
156
|
+
}
|
|
157
|
+
// 2. agents.
|
|
158
|
+
for (const a of row.actions.filter((x) => x.kind === 'add-agent')) {
|
|
159
|
+
const spec = a.detail.replace(/^install\s+/, '');
|
|
160
|
+
const r = sshAgents(['add', spec, '--yes']);
|
|
161
|
+
steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
|
|
162
|
+
ok = ok && r.code === 0;
|
|
163
|
+
}
|
|
164
|
+
// 3. config sync.
|
|
165
|
+
if (row.actions.some((a) => a.kind === 'sync-config')) {
|
|
166
|
+
const r = sshAgents(['sync']);
|
|
167
|
+
steps.push({ kind: 'sync-config', ok: r.code === 0, detail: `sync config` });
|
|
168
|
+
ok = ok && r.code === 0;
|
|
169
|
+
}
|
|
170
|
+
// 4. login propagation — one bundle for all pushable agents on this device.
|
|
171
|
+
const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
|
|
172
|
+
if (pushAgents.length > 0) {
|
|
173
|
+
const files = [];
|
|
174
|
+
for (const agent of pushAgents)
|
|
175
|
+
files.push(...(ctx.sourceAuth.filesByAgent.get(agent) ?? []));
|
|
176
|
+
const bundle = buildAuthBundle(ctx.source, files);
|
|
177
|
+
const r = sshAgents(['apply', '--recv-auth'], JSON.stringify(bundle));
|
|
178
|
+
steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
|
|
179
|
+
ok = ok && r.code === 0;
|
|
180
|
+
}
|
|
181
|
+
// Surface blocked logins as (non-fatal) informational steps.
|
|
182
|
+
for (const blocked of row.loginBlocked) {
|
|
183
|
+
steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
|
|
184
|
+
}
|
|
185
|
+
return { device: row.device, ok, steps };
|
|
186
|
+
}
|
|
187
|
+
/** Run a pool of async tasks with a concurrency cap, preserving input order. */
|
|
188
|
+
export async function pool(items, limit, fn) {
|
|
189
|
+
const results = new Array(items.length);
|
|
190
|
+
let next = 0;
|
|
191
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
192
|
+
for (;;) {
|
|
193
|
+
const i = next++;
|
|
194
|
+
if (i >= items.length)
|
|
195
|
+
return;
|
|
196
|
+
results[i] = await fn(items[i], i);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
await Promise.all(workers);
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
202
|
+
/** Reconcile every device row in parallel (capped). */
|
|
203
|
+
export async function runFleetApply(rows, nameToProfile, ctx, concurrency = 6) {
|
|
204
|
+
return pool(rows, concurrency, async (row) => {
|
|
205
|
+
const profile = nameToProfile.get(row.device);
|
|
206
|
+
if (!profile)
|
|
207
|
+
return { device: row.device, ok: false, steps: [], note: 'no device profile' };
|
|
208
|
+
return reconcileDevice(row, profile, ctx);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** Default home for source snapshots (overridable in tests). */
|
|
212
|
+
export function sourceHome() {
|
|
213
|
+
return os.homedir();
|
|
214
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login/token propagation — the "one login instead of ~48 OAuth flows" core of
|
|
3
|
+
* `agents apply`.
|
|
4
|
+
*
|
|
5
|
+
* A source machine that is already signed in to a set of harnesses has portable
|
|
6
|
+
* credential files on disk (verified per-agent locations below). `snapshotAuth`
|
|
7
|
+
* captures those; `materializeAuth` writes them into the corresponding paths on
|
|
8
|
+
* a target device. Transport is the caller's job — `apply` streams the bundle
|
|
9
|
+
* over the existing (encrypted, authenticated) SSH channel via `sshExec`'s
|
|
10
|
+
* stdin `input`, so no app-layer crypto is layered on top of SSH here.
|
|
11
|
+
*
|
|
12
|
+
* Honest boundary: on macOS, claude and antigravity keep their tokens in the
|
|
13
|
+
* login keychain, ACL-bound to the harness process — unreadable by us. Those are
|
|
14
|
+
* classified `bound` and surfaced for a one-time manual login, never faked.
|
|
15
|
+
*/
|
|
16
|
+
import type { AuthFilePayload, AuthBundle, AuthSnapshotResult } from './types.js';
|
|
17
|
+
/** A portable credential file location, relative to $HOME. */
|
|
18
|
+
interface AuthFileSpec {
|
|
19
|
+
rel: string;
|
|
20
|
+
mode: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Verified portable auth-file locations per agent (home-relative). Sourced from
|
|
24
|
+
* live inspection of a Linux fleet box + the agent registry. Agents absent here
|
|
25
|
+
* have no portable credential file we can propagate.
|
|
26
|
+
*/
|
|
27
|
+
export declare const FLEET_AUTH_FILES: Record<string, AuthFileSpec[]>;
|
|
28
|
+
/** Agents whose macOS credentials live in the ACL-bound login keychain. */
|
|
29
|
+
export declare const KEYCHAIN_BOUND_ON_MAC: ReadonlySet<string>;
|
|
30
|
+
/** Which agents `apply` can propagate auth for at all. */
|
|
31
|
+
export declare function isPropagatableAgent(agent: string): boolean;
|
|
32
|
+
export interface SnapshotOptions {
|
|
33
|
+
/** Home directory to read credential files from. */
|
|
34
|
+
home: string;
|
|
35
|
+
/** Platform of the source machine (`process.platform`). */
|
|
36
|
+
platform: NodeJS.Platform;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Capture portable credential files for the given agents from a source home.
|
|
40
|
+
* Returns the readable file payloads plus the list of agents whose auth is
|
|
41
|
+
* device-bound (macOS keychain) and therefore cannot be captured. Agents that
|
|
42
|
+
* are simply not signed in (no file on disk) are silently omitted — nothing to
|
|
43
|
+
* propagate, not an error.
|
|
44
|
+
*/
|
|
45
|
+
export declare function snapshotAuth(agents: string[], opts: SnapshotOptions): AuthSnapshotResult;
|
|
46
|
+
/** Assemble the plaintext bundle shipped to a target. */
|
|
47
|
+
export declare function buildAuthBundle(source: string, files: AuthFilePayload[]): AuthBundle;
|
|
48
|
+
export interface MaterializeOptions {
|
|
49
|
+
/** Home directory to write credential files into. */
|
|
50
|
+
home: string;
|
|
51
|
+
}
|
|
52
|
+
export interface MaterializeResult {
|
|
53
|
+
/** Agent ids that received at least one file. */
|
|
54
|
+
written: string[];
|
|
55
|
+
/** `rel: reason` for any file that failed. */
|
|
56
|
+
errors: string[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Write a captured auth bundle into a target home. Parent dirs are created; the
|
|
60
|
+
* captured POSIX mode is restored (0600 for credentials). Writing through an
|
|
61
|
+
* existing symlink lands in the agent's active version home, which is exactly
|
|
62
|
+
* what per-version credential carry-forward expects.
|
|
63
|
+
*/
|
|
64
|
+
export declare function materializeAuth(bundle: AuthBundle, opts: MaterializeOptions): MaterializeResult;
|
|
65
|
+
/** Parse + validate a bundle received on stdin (the remote `--_recv-auth` path). */
|
|
66
|
+
export declare function parseAuthBundle(raw: string): AuthBundle;
|
|
67
|
+
export {};
|