@phnx-labs/agents-cli 1.22.25 → 1.22.26
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 +183 -0
- package/README.md +17 -2
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +8 -0
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +43 -0
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +33 -1
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +23 -0
- package/dist/lib/hosts/passthrough.js +45 -0
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +1 -14
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +221 -13
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { connectBrowserIPC } from './ipc.js';
|
|
3
|
+
import { assertRemoteControlAllowed } from './remote-control.js';
|
|
4
|
+
function parseRequest(line) {
|
|
5
|
+
const parsed = JSON.parse(line);
|
|
6
|
+
if (!parsed ||
|
|
7
|
+
typeof parsed !== 'object' ||
|
|
8
|
+
Array.isArray(parsed) ||
|
|
9
|
+
typeof parsed.action !== 'string') {
|
|
10
|
+
throw new Error('Each input line must be a JSON object with an action');
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
function writeResponse(output, response) {
|
|
15
|
+
output.write(`${JSON.stringify(response)}\n`);
|
|
16
|
+
}
|
|
17
|
+
function writeErrorResponse(output, error) {
|
|
18
|
+
writeResponse(output, {
|
|
19
|
+
ok: false,
|
|
20
|
+
error: error instanceof Error ? error.message : String(error),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read browser IPC requests as NDJSON and write one NDJSON response per line.
|
|
25
|
+
* The Node process and daemon connection stay alive until input closes.
|
|
26
|
+
*/
|
|
27
|
+
export async function runBrowserIPCStream(options) {
|
|
28
|
+
const client = await connectBrowserIPC({ autoStartDaemon: options.autoStartDaemon });
|
|
29
|
+
const lines = createInterface({ input: options.input, crlfDelay: Infinity });
|
|
30
|
+
let defaultTask = options.task;
|
|
31
|
+
try {
|
|
32
|
+
for await (const line of lines) {
|
|
33
|
+
if (!line.trim())
|
|
34
|
+
continue;
|
|
35
|
+
let request;
|
|
36
|
+
try {
|
|
37
|
+
request = parseRequest(line);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
writeErrorResponse(options.output, error);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (request.action === 'start') {
|
|
44
|
+
try {
|
|
45
|
+
assertRemoteControlAllowed();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
writeErrorResponse(options.output, error);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
request = {
|
|
52
|
+
...request,
|
|
53
|
+
taskName: request.taskName ?? defaultTask,
|
|
54
|
+
actor: request.actor ?? options.actor,
|
|
55
|
+
launchId: request.launchId ?? options.launchId,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
else if (!request.task && defaultTask) {
|
|
59
|
+
request = { ...request, task: defaultTask };
|
|
60
|
+
}
|
|
61
|
+
const response = await client.request(request);
|
|
62
|
+
if (response.task)
|
|
63
|
+
defaultTask = response.task;
|
|
64
|
+
writeResponse(options.output, response);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
lines.close();
|
|
69
|
+
await client.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta } from '../types.js';
|
|
2
|
+
/** Why the owner sink cannot deliver from this box. */
|
|
3
|
+
export type OwnerSinkReason = 'rush-not-on-path' | 'rush-signed-out';
|
|
4
|
+
export interface OwnerSinkStatus {
|
|
5
|
+
/** Owner delivery is configured for this fleet (humans.yaml / notify.owner).
|
|
6
|
+
* When false, no finding is emitted — an un-opted-in box is not "broken". */
|
|
7
|
+
configured: boolean;
|
|
8
|
+
/** This box can actually deliver an owner notification right now. */
|
|
9
|
+
reachable: boolean;
|
|
10
|
+
/** Resolved owner channel (e.g. `imessage`). */
|
|
11
|
+
channel?: string;
|
|
12
|
+
/** Resolved transport after `notify.transports` mapping (usually === channel). */
|
|
13
|
+
transport?: string;
|
|
14
|
+
/** Set only when `reachable` is false. */
|
|
15
|
+
reason?: OwnerSinkReason;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Probe whether THIS box can deliver an owner notification right now. Returns
|
|
19
|
+
* `configured:false` (and the caller emits no finding) when the fleet has no
|
|
20
|
+
* owner channel configured — an un-opted-in box is not broken. When configured,
|
|
21
|
+
* reports whether the resolved transport can actually deliver from here.
|
|
22
|
+
*
|
|
23
|
+
* Only the concrete rush-backed failure the lane hits is reported as unreachable;
|
|
24
|
+
* non-rush transports (desktop / mailbox / …) deliver locally and are treated as
|
|
25
|
+
* reachable rather than probed, so this never invents a critical it cannot back.
|
|
26
|
+
*/
|
|
27
|
+
export declare function probeOwnerSink(meta: Meta): Promise<OwnerSinkStatus>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-delivery-sink reachability probe (RUSH-2262).
|
|
3
|
+
*
|
|
4
|
+
* The feed/notify owner-delivery lane (`agents notify`, `agents feed post
|
|
5
|
+
* --level important` / `--blocked`) reaches the owner over the rush-backed owner
|
|
6
|
+
* channel (iMessage via `rush message send`). That transport can only deliver
|
|
7
|
+
* from a context that BOTH finds the `rush` CLI on PATH and can read its
|
|
8
|
+
* keychain-bound session. So a headless Linux fleet box (no rush) or a non-GUI
|
|
9
|
+
* SSH session on a mac (login keychain locked) structurally cannot escalate —
|
|
10
|
+
* and the failure is silent until a block is filed, surfacing only as the
|
|
11
|
+
* after-the-fact `owner failed: …` line. `agents doctor` had no signal for it,
|
|
12
|
+
* which is exactly the gap RUSH-2258 / RUSH-2262 flagged.
|
|
13
|
+
*
|
|
14
|
+
* This probes the SAME transport the lane uses, from the SAME context doctor runs
|
|
15
|
+
* in, so `agents doctor` can fail loud when this box cannot reach the owner. It is
|
|
16
|
+
* deliberately honest about context: `rush whoami` is what tells a real signed-in
|
|
17
|
+
* session apart from a keychain that is present but unreadable HERE. The session
|
|
18
|
+
* token is a keychain item, NOT `~/.rush/user.yaml` (RUSH-2262), so this never
|
|
19
|
+
* reads that file — checking it is the mistake that made a signed-in box look
|
|
20
|
+
* signed out.
|
|
21
|
+
*
|
|
22
|
+
* `agents notify --dry-run` is NOT this probe: it short-circuits before the
|
|
23
|
+
* `which rush` preflight (`providers/rush.ts`), so it reports `ok:true` on a box
|
|
24
|
+
* with no rush at all. Resolvability (does the envelope build?) and reachability
|
|
25
|
+
* (can this box actually deliver?) are different questions; this answers the
|
|
26
|
+
* second.
|
|
27
|
+
*/
|
|
28
|
+
import { execFile } from 'child_process';
|
|
29
|
+
import { promisify } from 'util';
|
|
30
|
+
import { readOwnerDest } from './send.js';
|
|
31
|
+
import { RUSH_CHANNELS } from './providers/rush.js';
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
async function rushOnPath() {
|
|
34
|
+
try {
|
|
35
|
+
await execFileAsync('which', ['rush'], { timeout: 4000 });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Signed-in state from `rush whoami`, read with THIS context's keychain access.
|
|
43
|
+
* 'unknown' (a timeout or output we can't classify) is treated as reachable by
|
|
44
|
+
* the caller — a slow or unexpected probe must not cry wolf and paint a false
|
|
45
|
+
* critical. Only a definitive "not logged in" is reported as signed out. */
|
|
46
|
+
async function rushSignedIn() {
|
|
47
|
+
const classify = (s) => {
|
|
48
|
+
const out = s.toLowerCase();
|
|
49
|
+
if (/not (logged in|signed in)/.test(out))
|
|
50
|
+
return 'no';
|
|
51
|
+
if (/logged in as|session:\s*valid/.test(out))
|
|
52
|
+
return 'yes';
|
|
53
|
+
return 'unknown';
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const { stdout, stderr } = await execFileAsync('rush', ['whoami'], { timeout: 5000 });
|
|
57
|
+
return classify(`${stdout}\n${stderr}`);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const e = err;
|
|
61
|
+
if (e.killed)
|
|
62
|
+
return 'unknown'; // timed out — do not conclude signed-out
|
|
63
|
+
// A signed-out rush commonly exits non-zero; trust an explicit message only.
|
|
64
|
+
return classify(`${e.stdout ?? ''}\n${e.stderr ?? ''}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Probe whether THIS box can deliver an owner notification right now. Returns
|
|
69
|
+
* `configured:false` (and the caller emits no finding) when the fleet has no
|
|
70
|
+
* owner channel configured — an un-opted-in box is not broken. When configured,
|
|
71
|
+
* reports whether the resolved transport can actually deliver from here.
|
|
72
|
+
*
|
|
73
|
+
* Only the concrete rush-backed failure the lane hits is reported as unreachable;
|
|
74
|
+
* non-rush transports (desktop / mailbox / …) deliver locally and are treated as
|
|
75
|
+
* reachable rather than probed, so this never invents a critical it cannot back.
|
|
76
|
+
*/
|
|
77
|
+
export async function probeOwnerSink(meta) {
|
|
78
|
+
const dest = readOwnerDest(meta);
|
|
79
|
+
if (!dest)
|
|
80
|
+
return { configured: false, reachable: false };
|
|
81
|
+
const channel = dest.channel;
|
|
82
|
+
const transport = meta.notify?.transports?.[channel] ?? channel;
|
|
83
|
+
if (RUSH_CHANNELS.includes(transport)) {
|
|
84
|
+
if (!(await rushOnPath())) {
|
|
85
|
+
return { configured: true, reachable: false, channel, transport, reason: 'rush-not-on-path' };
|
|
86
|
+
}
|
|
87
|
+
if ((await rushSignedIn()) === 'no') {
|
|
88
|
+
return { configured: true, reachable: false, channel, transport, reason: 'rush-signed-out' };
|
|
89
|
+
}
|
|
90
|
+
return { configured: true, reachable: true, channel, transport };
|
|
91
|
+
}
|
|
92
|
+
return { configured: true, reachable: true, channel, transport };
|
|
93
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentId } from '../types.js';
|
|
2
2
|
import type { DuplicateVersionHook } from '../hooks.js';
|
|
3
3
|
import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
|
|
4
|
+
import type { OwnerSinkStatus } from '../channels/owner-sink.js';
|
|
4
5
|
import type { SyncStatusRow, OrphanRow } from '../drift.js';
|
|
5
6
|
import type { FetchStatusMarker } from '../auto-pull.js';
|
|
6
7
|
import type { VersionResourceReport } from '../doctor-diff.js';
|
|
@@ -10,7 +11,7 @@ export type FindingSeverity = 'critical' | 'warning';
|
|
|
10
11
|
* the JSON consumer group by kind. */
|
|
11
12
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
12
13
|
* below owns it, and a second copy in these comments is a fourth place to drift. */
|
|
13
|
-
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli"];
|
|
14
|
+
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli", "owner-sink-unreachable"];
|
|
14
15
|
/**
|
|
15
16
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
16
17
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -106,6 +107,11 @@ export interface LocalFindingInputs {
|
|
|
106
107
|
* sweep deliberately skips isolated copies, so a collapsed row would print a
|
|
107
108
|
* remediation that does not fix them. */
|
|
108
109
|
isolatedVersions?: string[];
|
|
110
|
+
/** Whether the feed/notify owner-delivery lane can reach the owner from this box
|
|
111
|
+
* (RUSH-2262). Collected by `probeOwnerSink` in the command (it spawns the real
|
|
112
|
+
* `rush` transport, so it stays out of this pure module). Absent → no probe ran
|
|
113
|
+
* → no finding. */
|
|
114
|
+
ownerSink?: OwnerSinkStatus;
|
|
109
115
|
}
|
|
110
116
|
/**
|
|
111
117
|
* Fold this machine's signals into findings. Missing hooks/plugins and unwired
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* with. Keep this list exhaustive; a kind missing from it is a doc that lies.
|
|
19
19
|
* CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
|
|
20
20
|
* unwired-hook (a hook on disk that settings.json never fires) ·
|
|
21
|
-
* cli-missing
|
|
21
|
+
* cli-missing · owner-sink-unreachable (the feed/notify owner lane
|
|
22
|
+
* cannot reach the owner from this box).
|
|
22
23
|
* WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
|
|
23
24
|
* never-synced · stale · repo-behind · repo-drift · version-skew ·
|
|
24
25
|
* fleet-resource-gap · orphan · duplicate-hook ·
|
|
@@ -97,6 +98,7 @@ export const ALL_FINDING_KINDS = [
|
|
|
97
98
|
'env-secret-export', // the file-store master key live in THIS process's env
|
|
98
99
|
'exec-policy', // Windows execution policy blocks agents.ps1
|
|
99
100
|
'stale-cli',
|
|
101
|
+
'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
|
|
100
102
|
];
|
|
101
103
|
/**
|
|
102
104
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
@@ -116,6 +118,9 @@ export const FINDING_SEVERITY = {
|
|
|
116
118
|
'missing-plugin': 'critical',
|
|
117
119
|
'unwired-hook': 'critical',
|
|
118
120
|
'cli-missing': 'critical',
|
|
121
|
+
// A factory that cannot escalate a blocked agent to the owner is not healthy,
|
|
122
|
+
// and the failure is otherwise silent until a block is filed (RUSH-2262/2258).
|
|
123
|
+
'owner-sink-unreachable': 'critical',
|
|
119
124
|
// Everything else is resolvable by a routine sync/cleanup and does not block
|
|
120
125
|
// the harness right now. RUSH-2162 moved never-synced and duplicate-hook-drift
|
|
121
126
|
// here: both are stale-sync states that one `agents sync` resolves.
|
|
@@ -251,6 +256,13 @@ export function remediationFor(finding) {
|
|
|
251
256
|
return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
|
|
252
257
|
case 'stale-cli':
|
|
253
258
|
return 'upgrade';
|
|
259
|
+
case 'owner-sink-unreachable':
|
|
260
|
+
// The lane delivers over the rush-backed owner channel, which needs rush on
|
|
261
|
+
// PATH AND a usable session in THIS context. Non-interactive shells miss a
|
|
262
|
+
// ~/.zshrc export (RUSH-2258), and the session is keychain-bound, not in
|
|
263
|
+
// ~/.rush/user.yaml (RUSH-2262) — so `rush login` here, or the Rush App for
|
|
264
|
+
// a GUI keychain, is what makes it reachable.
|
|
265
|
+
return "put rush on PATH for non-interactive shells (~/.zshenv) and run 'rush login'";
|
|
254
266
|
}
|
|
255
267
|
}
|
|
256
268
|
function finding(f) {
|
|
@@ -293,6 +305,22 @@ export function buildLocalFindings(input) {
|
|
|
293
305
|
message: `${agentName(agent)} binary not found`,
|
|
294
306
|
}));
|
|
295
307
|
}
|
|
308
|
+
// owner-sink-unreachable — the feed/notify owner-delivery lane can't reach the
|
|
309
|
+
// owner from this box (RUSH-2262). Only when owner delivery is CONFIGURED for the
|
|
310
|
+
// fleet; an un-opted-in box is not broken. The message names the concrete reason.
|
|
311
|
+
const sink = input.ownerSink;
|
|
312
|
+
if (sink?.configured && !sink.reachable) {
|
|
313
|
+
const chan = sink.channel ?? 'owner';
|
|
314
|
+
const why = sink.reason === 'rush-not-on-path'
|
|
315
|
+
? `rush CLI not on this box's PATH`
|
|
316
|
+
: sink.reason === 'rush-signed-out'
|
|
317
|
+
? 'rush has no usable session here'
|
|
318
|
+
: 'transport unreachable';
|
|
319
|
+
out.push(finding({
|
|
320
|
+
severity: FINDING_SEVERITY['owner-sink-unreachable'], kind: 'owner-sink-unreachable', device,
|
|
321
|
+
message: `${chan} → owner unreachable: ${why}`,
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
296
324
|
// Per-version resource reports → missing hook/plugin (critical), unwired hook
|
|
297
325
|
// (critical), other missing kinds (warning), content drift (warning).
|
|
298
326
|
for (const report of input.reports) {
|
|
@@ -782,6 +810,10 @@ function subjectLabel(f) {
|
|
|
782
810
|
return f.version ? `${f.agent} @${f.version}` : f.agent;
|
|
783
811
|
}
|
|
784
812
|
function critLabel(f) {
|
|
813
|
+
// Machine-level criticals with no agent get a category subject so the left
|
|
814
|
+
// column is not blank; the owner-sink row reads `owner …`, not an empty label.
|
|
815
|
+
if (f.kind === 'owner-sink-unreachable')
|
|
816
|
+
return { left: 'owner', account: '', message: f.message };
|
|
785
817
|
return {
|
|
786
818
|
left: subjectLabel(f),
|
|
787
819
|
account: f.account ?? '',
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
|
|
9
9
|
*/
|
|
10
10
|
import type { DeviceProfile } from '../devices/registry.js';
|
|
11
|
+
import { type RemoteBackend } from '../secrets/push.js';
|
|
11
12
|
import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
|
|
12
13
|
/** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
|
|
13
14
|
export declare function agentIdOf(spec: string): string;
|
|
@@ -54,21 +55,76 @@ export interface DiffContext {
|
|
|
54
55
|
/** agents-cli version the source is on — the fleet target version. */
|
|
55
56
|
targetCliVersion: string;
|
|
56
57
|
sourceAuth: SourceAuth;
|
|
57
|
-
/** Secrets-bundle names the profile declares.
|
|
58
|
-
* can't be pushed, so each reachable device surfaces them as a manual recreate
|
|
59
|
-
* (`needs-secret`) — informational, never an executed mutation. */
|
|
58
|
+
/** Secrets-bundle names the profile declares. */
|
|
60
59
|
secretsBundles?: string[];
|
|
60
|
+
/** `--provision-secrets`. OFF by default: pushing a bundle moves credential
|
|
61
|
+
* VALUES to another machine, so it is opted into per invocation and never
|
|
62
|
+
* defaulted from the shared `agents.yaml` (RUSH-1968). */
|
|
63
|
+
provisionSecrets?: boolean;
|
|
64
|
+
/** Is this device's host key pinned? Injected so `decideSecretPush` stays pure
|
|
65
|
+
* and its refusals are testable against real known_hosts fixtures with no
|
|
66
|
+
* network. Absent = treated as unpinned, i.e. refuse. */
|
|
67
|
+
isHostPinned?: (device: string) => boolean;
|
|
68
|
+
/** `--force`: push a declared bundle even when the device already has it. */
|
|
69
|
+
forceSecrets?: boolean;
|
|
61
70
|
}
|
|
62
71
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
63
72
|
export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
|
|
73
|
+
/** Why a declared bundle is or is not pushed to one device. */
|
|
74
|
+
export interface SecretPushDecision {
|
|
75
|
+
push: boolean;
|
|
76
|
+
/** Where it would land on the remote. Only meaningful when `push`. */
|
|
77
|
+
backend: RemoteBackend;
|
|
78
|
+
/** Set when `push` is false — rendered as the `needs-secret` reminder. */
|
|
79
|
+
reason: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether `fleet apply` may push one declared bundle to one device.
|
|
83
|
+
*
|
|
84
|
+
* PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
|
|
85
|
+
* every branch is unit-testable with no live fleet. Three gates, in order, and
|
|
86
|
+
* each REFUSAL still yields a `needs-secret` reminder rather than silence:
|
|
87
|
+
*
|
|
88
|
+
* 1. `--provision-secrets` must be set. Off by default, and deliberately a
|
|
89
|
+
* flag rather than an `agents.yaml` field: the manifest is shared, so a
|
|
90
|
+
* file-level default means someone else's `apply -y` ships credential
|
|
91
|
+
* values without deciding to (RUSH-1968's shape of accident).
|
|
92
|
+
* 2. The device must be reachable — nothing to push to otherwise.
|
|
93
|
+
* 3. The host key must be PINNED. This moves credential values to another
|
|
94
|
+
* machine, so it reuses the same bar `agents exec --copy-creds` already
|
|
95
|
+
* sets (EXEC-34): an unpinned device earns its pin through a normal
|
|
96
|
+
* `agents ssh <device>` first.
|
|
97
|
+
*
|
|
98
|
+
* Backend follows the platform, and this is the load-bearing default of the
|
|
99
|
+
* whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
|
|
100
|
+
* box has no keychain (`lib/secrets/linux.ts`), and the file store there
|
|
101
|
+
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
102
|
+
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
103
|
+
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
104
|
+
*/
|
|
105
|
+
export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
|
|
64
106
|
export interface ProbeOptions {
|
|
65
107
|
/** Also fetch per-agent installed versions (one extra `agents view --json`
|
|
66
108
|
* round-trip). Enable only when the plan has a version-pinned spec. */
|
|
67
109
|
withVersions?: boolean;
|
|
110
|
+
/** Also fetch which secrets bundles the device already has (one extra
|
|
111
|
+
* `agents secrets list --json`). Enable only when the manifest declares
|
|
112
|
+
* bundles and provisioning is on — same cost discipline as `withVersions`. */
|
|
113
|
+
withSecrets?: boolean;
|
|
68
114
|
}
|
|
69
115
|
/** Probe one device: reachability + agents-cli version + installed agent ids
|
|
70
116
|
* (and, when `withVersions`, the installed version strings per agent). */
|
|
71
117
|
export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
|
|
118
|
+
/**
|
|
119
|
+
* Narrow a remote `secrets list --json` payload to `name -> updated_at`.
|
|
120
|
+
*
|
|
121
|
+
* Exported and pure so the parse is unit-tested against real payload shapes with
|
|
122
|
+
* no live fleet. Returns `{}` rather than throwing on anything unexpected: the
|
|
123
|
+
* remote runs its own agents-cli version, and a parse failure must degrade to
|
|
124
|
+
* "unknown, so push" — never to "present, so skip", which would silently leave a
|
|
125
|
+
* device unprovisioned.
|
|
126
|
+
*/
|
|
127
|
+
export declare function parseRemoteBundles(stdout: string): Record<string, string>;
|
|
72
128
|
export interface ApplyStep {
|
|
73
129
|
kind: FleetAction['kind'];
|
|
74
130
|
ok: boolean;
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
|
|
9
9
|
*/
|
|
10
10
|
import * as os from 'os';
|
|
11
|
+
import { pushBundleToHost } from '../secrets/push.js';
|
|
11
12
|
import { sshTargetFor } from '../devices/connect.js';
|
|
12
13
|
import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
|
|
13
14
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
@@ -167,14 +168,33 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
|
-
// secrets
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
171
|
+
// secrets. Declared once at the manifest level, so every reachable device
|
|
172
|
+
// is considered. Historically this was ALWAYS a manual reminder — "surfaced,
|
|
173
|
+
// never pushed" — and that gap is a direct cause of RUSH-1968: an operator
|
|
174
|
+
// who needed secrets on a worker box had no supported path, so they
|
|
175
|
+
// hand-exported the file store's master key across the fleet instead.
|
|
176
|
+
//
|
|
177
|
+
// It is now pushable, but only deliberately. `--provision-secrets` is off by
|
|
178
|
+
// default and is a FLAG, not a manifest field: `agents.yaml` is shared, and
|
|
179
|
+
// a file-level default would mean someone else's `apply -y` silently ships
|
|
180
|
+
// credential values — the same shape of accident this ticket is about.
|
|
181
|
+
// Everything the gate refuses stays a `needs-secret` reminder, so nothing
|
|
182
|
+
// is ever silently skipped.
|
|
174
183
|
if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
|
|
175
184
|
for (const bundle of ctx.secretsBundles) {
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
const decision = decideSecretPush(bundle, d, probe, ctx);
|
|
186
|
+
if (decision.push) {
|
|
187
|
+
rowActions.push({
|
|
188
|
+
device: d.device,
|
|
189
|
+
kind: 'push-secret',
|
|
190
|
+
bundle,
|
|
191
|
+
detail: `push secrets bundle '${bundle}' (${decision.backend} backend)`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
secretsNeeded.push(bundle);
|
|
196
|
+
rowActions.push({ device: d.device, kind: 'needs-secret', bundle, detail: decision.reason });
|
|
197
|
+
}
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
}
|
|
@@ -183,6 +203,66 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
183
203
|
}
|
|
184
204
|
return { devices, actions };
|
|
185
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Decide whether `fleet apply` may push one declared bundle to one device.
|
|
208
|
+
*
|
|
209
|
+
* PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
|
|
210
|
+
* every branch is unit-testable with no live fleet. Three gates, in order, and
|
|
211
|
+
* each REFUSAL still yields a `needs-secret` reminder rather than silence:
|
|
212
|
+
*
|
|
213
|
+
* 1. `--provision-secrets` must be set. Off by default, and deliberately a
|
|
214
|
+
* flag rather than an `agents.yaml` field: the manifest is shared, so a
|
|
215
|
+
* file-level default means someone else's `apply -y` ships credential
|
|
216
|
+
* values without deciding to (RUSH-1968's shape of accident).
|
|
217
|
+
* 2. The device must be reachable — nothing to push to otherwise.
|
|
218
|
+
* 3. The host key must be PINNED. This moves credential values to another
|
|
219
|
+
* machine, so it reuses the same bar `agents exec --copy-creds` already
|
|
220
|
+
* sets (EXEC-34): an unpinned device earns its pin through a normal
|
|
221
|
+
* `agents ssh <device>` first.
|
|
222
|
+
*
|
|
223
|
+
* Backend follows the platform, and this is the load-bearing default of the
|
|
224
|
+
* whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
|
|
225
|
+
* box has no keychain (`lib/secrets/linux.ts`), and the file store there
|
|
226
|
+
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
227
|
+
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
228
|
+
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
229
|
+
*/
|
|
230
|
+
export function decideSecretPush(bundle, desired, probe, ctx) {
|
|
231
|
+
const device = desired.device;
|
|
232
|
+
const backend = probe.platform === 'linux' ? 'file' : 'keychain';
|
|
233
|
+
const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
|
|
234
|
+
if (!ctx.provisionSecrets) {
|
|
235
|
+
return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
|
|
236
|
+
}
|
|
237
|
+
if (!probe.reachable) {
|
|
238
|
+
return { push: false, backend, reason: manual };
|
|
239
|
+
}
|
|
240
|
+
// Already there? Skip — otherwise every `apply` re-resolves the bundle, and a
|
|
241
|
+
// resolve can prompt for Touch ID, so a converged fleet would nag on every run.
|
|
242
|
+
//
|
|
243
|
+
// Known limitation, stated rather than hidden: this compares PRESENCE (and
|
|
244
|
+
// carries `updated_at` for a future content check). It is a timestamp
|
|
245
|
+
// heuristic, not a content hash — a bundle whose VALUES changed locally still
|
|
246
|
+
// reads as present. `--force` is the way to overwrite regardless.
|
|
247
|
+
// hasOwnProperty, NOT `in`: `in` walks the prototype chain, so a bundle named
|
|
248
|
+
// `toString` / `constructor` / `valueOf` would read as present on an EMPTY map
|
|
249
|
+
// and be silently skipped — leaving that device unprovisioned, the worse of the
|
|
250
|
+
// two errors this gate can make.
|
|
251
|
+
if (!ctx.forceSecrets && probe.remoteBundles
|
|
252
|
+
&& Object.prototype.hasOwnProperty.call(probe.remoteBundles, bundle)) {
|
|
253
|
+
return { push: false, backend, reason: `secrets bundle '${bundle}' already present on ${device} — pass --force to overwrite` };
|
|
254
|
+
}
|
|
255
|
+
if (!ctx.isHostPinned?.(device)) {
|
|
256
|
+
// Same bar as `exec --copy-creds`: never ship credential values to a host
|
|
257
|
+
// whose key we have not pinned.
|
|
258
|
+
return {
|
|
259
|
+
push: false,
|
|
260
|
+
backend,
|
|
261
|
+
reason: `${manual} — host key not pinned; run \`agents ssh ${device}\` once to pin it, then re-apply`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { push: true, backend, reason: '' };
|
|
265
|
+
}
|
|
186
266
|
// ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
|
|
187
267
|
function osHint(platform) {
|
|
188
268
|
return platform === 'windows' ? 'windows' : undefined;
|
|
@@ -225,6 +305,15 @@ export function probeDevice(device, opts) {
|
|
|
225
305
|
if (vres.code === 0)
|
|
226
306
|
installedVersions = parseInstalledVersions(vres.stdout);
|
|
227
307
|
}
|
|
308
|
+
let remoteBundles;
|
|
309
|
+
if (opts?.withSecrets) {
|
|
310
|
+
// Metadata only — `secrets list --json` returns names + timestamps and never
|
|
311
|
+
// values, which is why this is safe to run across the fleet.
|
|
312
|
+
const listCmd = buildRemoteAgentsInvocation(['secrets', 'list', '--json'], undefined, hint, remoteEnv(device.platform));
|
|
313
|
+
const lres = sshExec(target, listCmd, { timeoutMs: 30000, multiplex: true });
|
|
314
|
+
if (lres.code === 0)
|
|
315
|
+
remoteBundles = parseRemoteBundles(lres.stdout);
|
|
316
|
+
}
|
|
228
317
|
return {
|
|
229
318
|
device: device.name,
|
|
230
319
|
reachable: true,
|
|
@@ -232,8 +321,52 @@ export function probeDevice(device, opts) {
|
|
|
232
321
|
cliVersion: ready.version ?? undefined,
|
|
233
322
|
installedAgents: installed,
|
|
234
323
|
installedVersions,
|
|
324
|
+
remoteBundles,
|
|
235
325
|
};
|
|
236
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Narrow a remote `secrets list --json` payload to `name -> updated_at`.
|
|
329
|
+
*
|
|
330
|
+
* Exported and pure so the parse is unit-tested against real payload shapes with
|
|
331
|
+
* no live fleet. Returns `{}` rather than throwing on anything unexpected: the
|
|
332
|
+
* remote runs its own agents-cli version, and a parse failure must degrade to
|
|
333
|
+
* "unknown, so push" — never to "present, so skip", which would silently leave a
|
|
334
|
+
* device unprovisioned.
|
|
335
|
+
*/
|
|
336
|
+
export function parseRemoteBundles(stdout) {
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(stdout);
|
|
339
|
+
const rows = Array.isArray(parsed)
|
|
340
|
+
? parsed
|
|
341
|
+
: Array.isArray(parsed?.bundles)
|
|
342
|
+
? parsed.bundles
|
|
343
|
+
: [];
|
|
344
|
+
// Null-prototype: a remote-supplied name is used as a KEY here, so `{}` would
|
|
345
|
+
// let `__proto__` hit the prototype setter instead of becoming an own
|
|
346
|
+
// property (and then read back as absent). It also means the presence check
|
|
347
|
+
// cannot see inherited names.
|
|
348
|
+
const out = Object.create(null);
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
if (!row || typeof row !== 'object')
|
|
351
|
+
continue;
|
|
352
|
+
const r = row;
|
|
353
|
+
const name = typeof r.name === 'string' ? r.name : undefined;
|
|
354
|
+
if (!name)
|
|
355
|
+
continue;
|
|
356
|
+
// `updatedAt` is the real field name in `secrets list --json` — verified
|
|
357
|
+
// against a live payload, not assumed. `updated_at` is accepted too so an
|
|
358
|
+
// older remote is not silently recorded with an empty timestamp.
|
|
359
|
+
const ts = typeof r.updatedAt === 'string' ? r.updatedAt
|
|
360
|
+
: typeof r.updated_at === 'string' ? r.updated_at
|
|
361
|
+
: '';
|
|
362
|
+
out[name] = ts;
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
237
370
|
/** Execute one device's planned actions in order. Real SSH — no mocks. */
|
|
238
371
|
export function reconcileDevice(row, device, ctx) {
|
|
239
372
|
if (!row.probe.reachable) {
|
|
@@ -290,6 +423,50 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
290
423
|
steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
|
|
291
424
|
ok = ok && r.code === 0;
|
|
292
425
|
}
|
|
426
|
+
// 5. secrets provisioning — LAST, and deliberately so. It is the most
|
|
427
|
+
// sensitive mutation apply performs (credential VALUES crossing to another
|
|
428
|
+
// machine), so every lower-risk step above is already recorded before we
|
|
429
|
+
// touch it: a failure here never obscures what did land.
|
|
430
|
+
//
|
|
431
|
+
// Resolve ONCE per device even for several bundles is not possible (a resolve
|
|
432
|
+
// is per bundle), but each bundle resolves once and pushes once — the read can
|
|
433
|
+
// prompt, so it must not repeat.
|
|
434
|
+
const pushSecrets = row.actions.filter((a) => a.kind === 'push-secret');
|
|
435
|
+
for (const action of pushSecrets) {
|
|
436
|
+
const bundle = action.bundle;
|
|
437
|
+
if (!bundle) {
|
|
438
|
+
// A push-secret action without a bundle name is a planner bug, not a
|
|
439
|
+
// recoverable state — fail loud rather than push nothing and report ok.
|
|
440
|
+
steps.push({ kind: 'push-secret', ok: false, detail: 'push-secret action carried no bundle name' });
|
|
441
|
+
ok = false;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const backend = device.platform === 'linux' ? 'file' : 'keychain';
|
|
445
|
+
try {
|
|
446
|
+
const out = pushBundleToHost(bundle, target, {
|
|
447
|
+
remoteBackend: backend,
|
|
448
|
+
operation: `fleet apply ${row.device}`,
|
|
449
|
+
// No passphrase, ever, from this path. On the file backend the remote
|
|
450
|
+
// auto-provisions its OWN machine-local key, which is the entire point:
|
|
451
|
+
// each box gets an unshared at-rest key instead of the fleet-wide shared
|
|
452
|
+
// secret RUSH-1968 is about.
|
|
453
|
+
});
|
|
454
|
+
steps.push({
|
|
455
|
+
kind: 'push-secret',
|
|
456
|
+
ok: out.ok,
|
|
457
|
+
detail: out.ok
|
|
458
|
+
? `secrets '${bundle}' -> ${row.device} (${backend}): ${out.message}`
|
|
459
|
+
: `secrets '${bundle}' -> ${row.device}: ${out.message}`,
|
|
460
|
+
});
|
|
461
|
+
ok = ok && out.ok;
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
// A local resolve failure (locked store, missing bundle, multi-line value)
|
|
465
|
+
// is reported per bundle rather than aborting the whole device.
|
|
466
|
+
steps.push({ kind: 'push-secret', ok: false, detail: `secrets '${bundle}': ${e.message}` });
|
|
467
|
+
ok = false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
293
470
|
// Surface blocked logins as (non-fatal) informational steps.
|
|
294
471
|
for (const blocked of row.loginBlocked) {
|
|
295
472
|
steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
|
|
@@ -95,13 +95,28 @@ export interface DeviceProbe {
|
|
|
95
95
|
* undefined, version-pinned specs fall back to id-level presence.
|
|
96
96
|
*/
|
|
97
97
|
installedVersions?: Record<string, string[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Secrets bundles already present on the device: name -> `updated_at` (or ''
|
|
100
|
+
* when the remote reports none). Only populated when the manifest declares
|
|
101
|
+
* bundles AND `--provision-secrets` is set — a fleet that uses no bundles
|
|
102
|
+
* never pays for the extra round trip.
|
|
103
|
+
*
|
|
104
|
+
* METADATA ONLY. `agents secrets list --json` returns names and timestamps and
|
|
105
|
+
* explicitly never values, which is what makes this probe safe to run.
|
|
106
|
+
*/
|
|
107
|
+
remoteBundles?: Record<string, string>;
|
|
98
108
|
/** Reason string when `reachable` is false or the probe partially failed. */
|
|
99
109
|
note?: string;
|
|
100
110
|
}
|
|
101
111
|
/** One planned action against a device, in a single reconcile dimension. */
|
|
102
112
|
export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
113
|
+
/** Push a declared secrets bundle to the device over SSH. Opt-in only
|
|
114
|
+
* (`--provision-secrets`) and gated on a pinned host key, because this moves
|
|
115
|
+
* credential VALUES to another machine (RUSH-1968). */
|
|
116
|
+
| 'push-secret'
|
|
117
|
+
/** A declared secrets bundle that could NOT be pushed — the flag is off, the
|
|
118
|
+
* host key isn't pinned, or the bundle is already current. Surfaced as a manual
|
|
119
|
+
* recreate, like `needs-login`. */
|
|
105
120
|
| 'needs-secret';
|
|
106
121
|
export interface FleetAction {
|
|
107
122
|
device: string;
|
|
@@ -111,6 +126,10 @@ export interface FleetAction {
|
|
|
111
126
|
/** Full agent spec for `add-agent` (e.g. `claude@2.1.170`) so the plan can show
|
|
112
127
|
* the exact version being installed; equals the id for a bare/latest spec. */
|
|
113
128
|
spec?: string;
|
|
129
|
+
/** Bundle name for `push-secret` / `needs-secret`, so the executor pushes the
|
|
130
|
+
* bundle the planner decided on rather than re-deriving it from the detail
|
|
131
|
+
* string. */
|
|
132
|
+
bundle?: string;
|
|
114
133
|
/** Human, one-line description of the action. */
|
|
115
134
|
detail: string;
|
|
116
135
|
}
|