@phnx-labs/agents-cli 1.20.63 → 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 +56 -3
- package/README.md +46 -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 +149 -77
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +52 -19
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- 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 +43 -16
- 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.d.ts +2 -0
- package/dist/commands/sessions-export.js +284 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +40 -6
- package/dist/commands/ssh.js +143 -5
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +27 -1
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +35 -14
- package/dist/lib/agents.d.ts +36 -0
- package/dist/lib/agents.js +80 -20
- 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/browser/drivers/ssh.js +19 -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/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +162 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +35 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +68 -20
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -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/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -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/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- 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 +37 -2
- package/dist/lib/routines.js +54 -12
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +348 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- 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 +164 -0
- package/dist/lib/session/bundle.js +200 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- 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/agents.d.ts +54 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.js +8 -2
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/sync.d.ts +23 -2
- package/dist/lib/session/sync/sync.js +177 -74
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +99 -8
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +9 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- 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 +35 -1
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +163 -21
- 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
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 {};
|
|
@@ -0,0 +1,142 @@
|
|
|
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 * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
/**
|
|
19
|
+
* Verified portable auth-file locations per agent (home-relative). Sourced from
|
|
20
|
+
* live inspection of a Linux fleet box + the agent registry. Agents absent here
|
|
21
|
+
* have no portable credential file we can propagate.
|
|
22
|
+
*/
|
|
23
|
+
export const FLEET_AUTH_FILES = {
|
|
24
|
+
claude: [{ rel: '.claude/.credentials.json', mode: 0o600 }],
|
|
25
|
+
codex: [{ rel: '.codex/auth.json', mode: 0o600 }],
|
|
26
|
+
gemini: [{ rel: '.gemini/oauth_creds.json', mode: 0o600 }],
|
|
27
|
+
grok: [{ rel: '.grok/auth.json', mode: 0o600 }],
|
|
28
|
+
kimi: [{ rel: '.kimi-code/credentials/kimi-code.json', mode: 0o600 }],
|
|
29
|
+
opencode: [{ rel: '.local/share/opencode/auth.json', mode: 0o600 }],
|
|
30
|
+
droid: [
|
|
31
|
+
{ rel: '.factory/auth.v2.file', mode: 0o600 },
|
|
32
|
+
{ rel: '.factory/auth.v2.key', mode: 0o600 },
|
|
33
|
+
],
|
|
34
|
+
antigravity: [{ rel: '.gemini/antigravity-cli/antigravity-oauth-token', mode: 0o600 }],
|
|
35
|
+
};
|
|
36
|
+
/** Agents whose macOS credentials live in the ACL-bound login keychain. */
|
|
37
|
+
export const KEYCHAIN_BOUND_ON_MAC = new Set(['claude', 'antigravity']);
|
|
38
|
+
/** Which agents `apply` can propagate auth for at all. */
|
|
39
|
+
export function isPropagatableAgent(agent) {
|
|
40
|
+
return agent in FLEET_AUTH_FILES;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Capture portable credential files for the given agents from a source home.
|
|
44
|
+
* Returns the readable file payloads plus the list of agents whose auth is
|
|
45
|
+
* device-bound (macOS keychain) and therefore cannot be captured. Agents that
|
|
46
|
+
* are simply not signed in (no file on disk) are silently omitted — nothing to
|
|
47
|
+
* propagate, not an error.
|
|
48
|
+
*/
|
|
49
|
+
export function snapshotAuth(agents, opts) {
|
|
50
|
+
const files = [];
|
|
51
|
+
const bound = [];
|
|
52
|
+
for (const agent of agents) {
|
|
53
|
+
const specs = FLEET_AUTH_FILES[agent];
|
|
54
|
+
if (!specs)
|
|
55
|
+
continue; // not propagatable — caller surfaces separately if desired
|
|
56
|
+
if (opts.platform === 'darwin' && KEYCHAIN_BOUND_ON_MAC.has(agent)) {
|
|
57
|
+
bound.push(agent);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const spec of specs) {
|
|
61
|
+
const abs = path.join(opts.home, spec.rel);
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.statSync(abs);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue; // not signed in for this agent — nothing to carry
|
|
68
|
+
}
|
|
69
|
+
if (!stat.isFile())
|
|
70
|
+
continue;
|
|
71
|
+
const content = fs.readFileSync(abs); // follows symlinks into version homes
|
|
72
|
+
files.push({
|
|
73
|
+
agent,
|
|
74
|
+
rel: spec.rel,
|
|
75
|
+
contentB64: content.toString('base64'),
|
|
76
|
+
mode: (stat.mode & 0o777) || spec.mode,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { files, bound };
|
|
81
|
+
}
|
|
82
|
+
/** Assemble the plaintext bundle shipped to a target. */
|
|
83
|
+
export function buildAuthBundle(source, files) {
|
|
84
|
+
return { v: 1, source, files };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Write a captured auth bundle into a target home. Parent dirs are created; the
|
|
88
|
+
* captured POSIX mode is restored (0600 for credentials). Writing through an
|
|
89
|
+
* existing symlink lands in the agent's active version home, which is exactly
|
|
90
|
+
* what per-version credential carry-forward expects.
|
|
91
|
+
*/
|
|
92
|
+
export function materializeAuth(bundle, opts) {
|
|
93
|
+
const written = new Set();
|
|
94
|
+
const errors = [];
|
|
95
|
+
for (const f of bundle.files) {
|
|
96
|
+
const abs = path.join(opts.home, f.rel);
|
|
97
|
+
try {
|
|
98
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
99
|
+
fs.writeFileSync(abs, Buffer.from(f.contentB64, 'base64'));
|
|
100
|
+
fs.chmodSync(abs, f.mode || 0o600);
|
|
101
|
+
written.add(f.agent);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
errors.push(`${f.rel}: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { written: [...written], errors };
|
|
108
|
+
}
|
|
109
|
+
/** Parse + validate a bundle received on stdin (the remote `--_recv-auth` path). */
|
|
110
|
+
export function parseAuthBundle(raw) {
|
|
111
|
+
let doc;
|
|
112
|
+
try {
|
|
113
|
+
doc = JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new Error(`auth bundle is not valid JSON: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
if (typeof doc !== 'object' || doc === null)
|
|
119
|
+
throw new Error('auth bundle must be an object.');
|
|
120
|
+
const o = doc;
|
|
121
|
+
if (o.v !== 1)
|
|
122
|
+
throw new Error(`unsupported auth bundle version ${JSON.stringify(o.v)}.`);
|
|
123
|
+
if (typeof o.source !== 'string')
|
|
124
|
+
throw new Error('auth bundle missing source.');
|
|
125
|
+
if (!Array.isArray(o.files))
|
|
126
|
+
throw new Error('auth bundle missing files[].');
|
|
127
|
+
for (const f of o.files) {
|
|
128
|
+
if (typeof f !== 'object' || f === null ||
|
|
129
|
+
typeof f.agent !== 'string' ||
|
|
130
|
+
typeof f.rel !== 'string' ||
|
|
131
|
+
typeof f.contentB64 !== 'string' ||
|
|
132
|
+
typeof f.mode !== 'number') {
|
|
133
|
+
throw new Error('auth bundle has a malformed file entry.');
|
|
134
|
+
}
|
|
135
|
+
// Reject path traversal — rel must stay under $HOME.
|
|
136
|
+
const rel = f.rel;
|
|
137
|
+
if (rel.startsWith('/') || rel.split('/').includes('..')) {
|
|
138
|
+
throw new Error(`auth bundle rejected unsafe path: ${rel}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return doc;
|
|
142
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse + validate the `fleet:` block of a profile manifest and resolve it into
|
|
3
|
+
* per-device desired state.
|
|
4
|
+
*
|
|
5
|
+
* `fleet:` is additive to the `Meta` schema (`types.ts`). Any `-f <file>` that
|
|
6
|
+
* carries a `fleet:` block is a valid manifest, so `ag apply -f agents.yaml`
|
|
7
|
+
* works against the project file. The functions here are pure (no SSH, no
|
|
8
|
+
* registry I/O) so they're fully unit-testable; device enumeration is injected.
|
|
9
|
+
*/
|
|
10
|
+
import type { FleetManifest, DeviceDesired } from './types.js';
|
|
11
|
+
/** Validate a raw `fleet:` object (already YAML-parsed) into a typed manifest. */
|
|
12
|
+
export declare function parseFleetManifest(raw: unknown): FleetManifest;
|
|
13
|
+
/** Read a YAML file and extract + validate its `fleet:` block. */
|
|
14
|
+
export declare function readFleetFile(filePath: string): FleetManifest;
|
|
15
|
+
export interface ResolveContext {
|
|
16
|
+
/** Device names currently online (used to expand `devices: all`). */
|
|
17
|
+
onlineDevices: string[];
|
|
18
|
+
/** All registered device names (used to validate explicit entries). */
|
|
19
|
+
registeredDevices: string[];
|
|
20
|
+
/** The source machine, always excluded from the target set. */
|
|
21
|
+
source: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Expand a manifest into concrete per-device desired states. `devices: all`
|
|
25
|
+
* expands to every online registered device minus the source; an explicit map
|
|
26
|
+
* validates each name against the registry. Devices with no desired agents,
|
|
27
|
+
* sync scopes, and `login: skip` are still returned (probe/report only).
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveDesired(manifest: FleetManifest, ctx: ResolveContext): DeviceDesired[];
|