@phnx-labs/agents-cli 1.22.114 → 1.22.115
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 +110 -0
- package/README.md +22 -102
- package/dist/browser.js +0 -0
- package/dist/cli/command-registry.js +0 -7
- package/dist/commands/accounts.d.ts +3 -77
- package/dist/commands/accounts.js +13 -366
- package/dist/commands/audit.js +1 -1
- package/dist/commands/auth.js +0 -2
- package/dist/commands/browser.d.ts +53 -0
- package/dist/commands/browser.js +199 -9
- package/dist/commands/doctor.d.ts +2 -3
- package/dist/commands/doctor.js +0 -11
- package/dist/commands/events.d.ts +1 -3
- package/dist/commands/events.js +4 -7
- package/dist/commands/exec.d.ts +0 -1
- package/dist/commands/exec.js +2 -7
- package/dist/commands/feed-watch.js +52 -8
- package/dist/commands/focus.js +1 -1
- package/dist/commands/go.d.ts +0 -17
- package/dist/commands/go.js +2 -19
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mcp.js +8 -83
- package/dist/commands/memory.js +4 -47
- package/dist/commands/message.js +4 -4
- package/dist/commands/plugins.js +1 -93
- package/dist/commands/repo.js +0 -44
- package/dist/commands/resume.js +4 -1
- package/dist/commands/secrets-passthrough.js +2 -2
- package/dist/commands/send.d.ts +4 -4
- package/dist/commands/send.js +6 -51
- package/dist/commands/sessions-backup-setup.js +1 -1
- package/dist/commands/sessions-resume.js +0 -1
- package/dist/commands/sessions-share.d.ts +5 -7
- package/dist/commands/sessions-share.js +98 -49
- package/dist/commands/sessions.js +1 -8
- package/dist/commands/setup-browser.js +18 -2
- package/dist/commands/setup-computer.js +20 -6
- package/dist/commands/setup-secrets.js +24 -4
- package/dist/commands/setup-terminal.d.ts +3 -0
- package/dist/commands/setup-terminal.js +22 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +20 -10
- package/dist/commands/skills.js +0 -8
- package/dist/commands/ssh.d.ts +6 -0
- package/dist/commands/ssh.js +92 -233
- package/dist/commands/sync.js +14 -5
- package/dist/commands/teams.js +1 -1
- package/dist/commands/traces.js +1 -1
- package/dist/lib/accounts/add.d.ts +0 -5
- package/dist/lib/accounts/add.js +1 -7
- package/dist/lib/artifacts-client.d.ts +20 -0
- package/dist/lib/artifacts-client.js +46 -0
- package/dist/lib/auth-mint.js +2 -2
- package/dist/lib/browser/runtime-state.d.ts +55 -0
- package/dist/lib/browser/runtime-state.js +99 -18
- package/dist/lib/browser/service.js +21 -1
- package/dist/lib/cli-resources.js +3 -1
- package/dist/lib/cloud/dispatch.js +1 -1
- package/dist/lib/cloudflare/creds.d.ts +10 -0
- package/dist/lib/cloudflare/creds.js +46 -0
- package/dist/lib/cloudflare/provision.d.ts +35 -0
- package/dist/lib/cloudflare/provision.js +144 -0
- package/dist/lib/computer/sessions-list.d.ts +55 -0
- package/dist/lib/computer/sessions-list.js +168 -1
- package/dist/lib/daemon/daemon.js +8 -1
- package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
- package/dist/lib/daemon/feed-stream-service.js +40 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/devices/connect.d.ts +49 -5
- package/dist/lib/devices/connect.js +169 -21
- package/dist/lib/feed/envelope.d.ts +79 -0
- package/dist/lib/feed/envelope.js +23 -0
- package/dist/lib/feed/events.d.ts +6 -0
- package/dist/lib/feed/events.js +8 -0
- package/dist/lib/feed/hub-server.d.ts +86 -0
- package/dist/lib/feed/hub-server.js +334 -0
- package/dist/lib/feed/hub.d.ts +95 -0
- package/dist/lib/feed/hub.js +255 -0
- package/dist/lib/feed/tool-activity.d.ts +108 -0
- package/dist/lib/feed/tool-activity.js +313 -0
- package/dist/lib/feed/tools.d.ts +198 -0
- package/dist/lib/feed/tools.js +265 -0
- package/dist/lib/feed/watch.d.ts +50 -50
- package/dist/lib/feed/watch.js +147 -16
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/git.d.ts +0 -16
- package/dist/lib/git.js +0 -58
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hosts/remote-cmd.d.ts +51 -1
- package/dist/lib/hosts/remote-cmd.js +125 -8
- package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
- package/dist/lib/mcp.js +17 -11
- package/dist/lib/probe.d.ts +4 -1
- package/dist/lib/probe.js +5 -2
- package/dist/lib/pwsh.d.ts +33 -0
- package/dist/lib/pwsh.js +56 -0
- package/dist/lib/redact.d.ts +8 -0
- package/dist/lib/redact.js +11 -0
- package/dist/lib/refresh.d.ts +6 -2
- package/dist/lib/refresh.js +92 -72
- package/dist/lib/secrets-cli.d.ts +11 -0
- package/dist/lib/secrets-cli.js +30 -0
- package/dist/lib/secrets-client.js +3 -2
- package/dist/lib/session/detached.d.ts +7 -0
- package/dist/lib/session/detached.js +29 -0
- package/dist/lib/session/remote/peer-stream.d.ts +24 -2
- package/dist/lib/session/remote/peer-stream.js +33 -6
- package/dist/lib/session/sync/backend.d.ts +3 -3
- package/dist/lib/session/sync/backend.js +3 -3
- package/dist/lib/session/sync/provision.d.ts +1 -1
- package/dist/lib/session/sync/provision.js +2 -2
- package/dist/lib/sessions-client.js +0 -3
- package/dist/lib/setup-tool-install.d.ts +3 -0
- package/dist/lib/setup-tool-install.js +26 -0
- package/dist/lib/setup-tool-status.d.ts +22 -0
- package/dist/lib/setup-tool-status.js +215 -0
- package/dist/lib/share-runtime.d.ts +11 -0
- package/dist/lib/share-runtime.js +63 -0
- package/dist/lib/smart-launch.d.ts +1 -5
- package/dist/lib/smart-launch.js +3 -11
- package/dist/lib/ssh-exec.d.ts +44 -0
- package/dist/lib/ssh-exec.js +119 -0
- package/dist/lib/startup/command-registry.d.ts +6 -4
- package/dist/lib/startup/command-registry.js +10 -7
- package/dist/lib/state.js +2 -2
- package/dist/lib/storage/selection.d.ts +2 -2
- package/dist/lib/storage/selection.js +2 -2
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +18 -10
- package/dist/lib/traces/backend.d.ts +1 -2
- package/dist/lib/traces/backend.js +1 -2
- package/dist/lib/traces/provision.d.ts +1 -1
- package/dist/lib/traces/provision.js +2 -2
- package/dist/lib/types.d.ts +8 -6
- package/package.json +2 -3
- package/dist/commands/artifacts-setup.d.ts +0 -53
- package/dist/commands/artifacts-setup.js +0 -161
- package/dist/commands/artifacts.d.ts +0 -18
- package/dist/commands/artifacts.js +0 -58
- package/dist/commands/attach.d.ts +0 -12
- package/dist/commands/attach.js +0 -86
- package/dist/commands/auth-mint.d.ts +0 -12
- package/dist/commands/auth-mint.js +0 -108
- package/dist/commands/reconnect.d.ts +0 -46
- package/dist/commands/reconnect.js +0 -115
- package/dist/commands/share.d.ts +0 -293
- package/dist/commands/share.js +0 -1424
- package/dist/lib/share/analytics.d.ts +0 -13
- package/dist/lib/share/analytics.js +0 -45
- package/dist/lib/share/backend.d.ts +0 -120
- package/dist/lib/share/backend.js +0 -176
- package/dist/lib/share/capture.d.ts +0 -31
- package/dist/lib/share/capture.js +0 -174
- package/dist/lib/share/config.d.ts +0 -72
- package/dist/lib/share/config.js +0 -211
- package/dist/lib/share/delete.d.ts +0 -123
- package/dist/lib/share/delete.js +0 -173
- package/dist/lib/share/html.d.ts +0 -20
- package/dist/lib/share/html.js +0 -88
- package/dist/lib/share/http-error.d.ts +0 -53
- package/dist/lib/share/http-error.js +0 -65
- package/dist/lib/share/og.d.ts +0 -26
- package/dist/lib/share/og.js +0 -84
- package/dist/lib/share/provision.d.ts +0 -127
- package/dist/lib/share/provision.js +0 -285
- package/dist/lib/share/publish.d.ts +0 -379
- package/dist/lib/share/publish.js +0 -818
- package/dist/lib/share/worker-template.d.ts +0 -27
- package/dist/lib/share/worker-template.js +0 -2424
- package/dist/lib/storage/index.d.ts +0 -14
- package/dist/lib/storage/index.js +0 -14
- package/dist/lib/storage/visibility.d.ts +0 -82
- package/dist/lib/storage/visibility.js +0 -99
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
* `sessionId` doesn't resolve (a rotated/unindexed session) but `launchId`
|
|
59
59
|
* still does via the more authoritative pid registry.
|
|
60
60
|
*/
|
|
61
|
+
import * as fs from 'node:fs';
|
|
62
|
+
import * as path from 'node:path';
|
|
63
|
+
import { getCacheDir } from '../state.js';
|
|
64
|
+
import { machineId } from '../machine-id.js';
|
|
61
65
|
import { query, truncate } from '../feed/events.js';
|
|
62
66
|
import { formatRelativeTime } from '../session/relative-time.js';
|
|
63
67
|
import { getSessionById, listComputerSessionRecords, pruneToolSessions } from '../session/db.js';
|
|
@@ -70,6 +74,21 @@ export const TASK_PREVIEW_MAX_CHARS = 200;
|
|
|
70
74
|
* already reads newest-first and stops at this cutoff mid-scan, so raising
|
|
71
75
|
* it only ever costs as much as the history actually holds. */
|
|
72
76
|
const DEFAULT_ACTION_LIMIT = 5000;
|
|
77
|
+
/** A capture record, accepted only when the producer gave a real path + name. */
|
|
78
|
+
function parseActionCapture(value) {
|
|
79
|
+
if (!value || typeof value !== 'object')
|
|
80
|
+
return undefined;
|
|
81
|
+
const record = value;
|
|
82
|
+
if (typeof record.path !== 'string' || !record.path)
|
|
83
|
+
return undefined;
|
|
84
|
+
const name = typeof record.name === 'string' && record.name ? record.name : path.basename(record.path);
|
|
85
|
+
return {
|
|
86
|
+
path: record.path,
|
|
87
|
+
kind: 'screenshot',
|
|
88
|
+
name,
|
|
89
|
+
...(typeof record.bytes === 'number' ? { bytes: record.bytes } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
73
92
|
function recordToAction(r) {
|
|
74
93
|
if (typeof r.command !== 'string' || typeof r.pid !== 'number')
|
|
75
94
|
return null;
|
|
@@ -91,8 +110,153 @@ function recordToAction(r) {
|
|
|
91
110
|
agent: typeof r.agent === 'string' ? r.agent : undefined,
|
|
92
111
|
machineId: typeof r.machineId === 'string' ? r.machineId : undefined,
|
|
93
112
|
hostname: typeof r.hostname === 'string' ? r.hostname : undefined,
|
|
113
|
+
capture: parseActionCapture(r.capture),
|
|
94
114
|
};
|
|
95
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The standalone engine's OWN action ledger.
|
|
118
|
+
*
|
|
119
|
+
* `agents computer` is a thin consumer of the standalone `computer` engine
|
|
120
|
+
* (PHNX-4075), and that engine ALWAYS appends every action it performs to
|
|
121
|
+
* `<cache>/computer/actions/<day>.jsonl` — independently of whether agents-cli
|
|
122
|
+
* was in the call at all. So a `computer` command the operator ran directly
|
|
123
|
+
* appears ONLY here: it never reached `recordComputerAction`, so it is in
|
|
124
|
+
* neither the feed event ledger nor `computer_sessions`. Reading only those two
|
|
125
|
+
* meant the actions an operator actually performed were invisible to every
|
|
126
|
+
* agents-cli surface.
|
|
127
|
+
*/
|
|
128
|
+
export function standaloneComputerActionsDir() {
|
|
129
|
+
return path.join(getCacheDir(), 'computer', 'actions');
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* One line of the standalone ledger as a {@link ComputerAction}.
|
|
133
|
+
*
|
|
134
|
+
* The on-disk record is the same `computer.action` event the engine reports on
|
|
135
|
+
* fd 4 — the contract `computer-client.ts` `ComputerActionEvent` already
|
|
136
|
+
* describes and `recordComputerAction` already consumes — so this maps the same
|
|
137
|
+
* fields rather than inventing a second vocabulary. A record missing the two
|
|
138
|
+
* fields that make it an action at all (`command`, a parseable timestamp) is
|
|
139
|
+
* skipped, never defaulted: the file is plain JSONL another process may be
|
|
140
|
+
* mid-append to.
|
|
141
|
+
*/
|
|
142
|
+
function standaloneLineToAction(line, observer) {
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(line);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!parsed || typeof parsed !== 'object')
|
|
151
|
+
return null;
|
|
152
|
+
const record = parsed;
|
|
153
|
+
const verb = typeof record.command === 'string' ? record.command : undefined;
|
|
154
|
+
const ts = typeof record.ts === 'string' ? record.ts : undefined;
|
|
155
|
+
if (!verb || !ts)
|
|
156
|
+
return null;
|
|
157
|
+
const tsMs = Date.parse(ts);
|
|
158
|
+
if (Number.isNaN(tsMs))
|
|
159
|
+
return null;
|
|
160
|
+
const text = (key) => (typeof record[key] === 'string' ? record[key] : undefined);
|
|
161
|
+
const num = (key) => (typeof record[key] === 'number' ? record[key] : undefined);
|
|
162
|
+
// The producer identifies the DRIVEN host only for a remote run, and emits no
|
|
163
|
+
// `hostname`/`machineId` of its own at all. Defaulting those to the observing
|
|
164
|
+
// machine HERE, at the source, is what keeps a direct local action attributable:
|
|
165
|
+
// without it `groupIntoComputerRuns` fell back to `machine: 'unknown'`, the row's
|
|
166
|
+
// device read `unknown`, and every locally-run `computer` action vanished the
|
|
167
|
+
// moment a consumer filtered by device. The observer IS the machine that ran it
|
|
168
|
+
// — this ledger is per-machine by construction, written by the engine on the box
|
|
169
|
+
// it ran on — so the fallback is a fact, not a guess. An explicit `host` still
|
|
170
|
+
// wins, because that names a genuinely different driven machine.
|
|
171
|
+
return {
|
|
172
|
+
verb, ts, tsMs,
|
|
173
|
+
// The engine's pid is its own; a record without one still groups by
|
|
174
|
+
// invocationId, which is the identity that actually matters here.
|
|
175
|
+
pid: num('pid') ?? 0,
|
|
176
|
+
invocationId: text('invocationId'),
|
|
177
|
+
targetPid: num('targetPid'),
|
|
178
|
+
bundle: text('bundle'),
|
|
179
|
+
host: text('host'),
|
|
180
|
+
task: text('task'),
|
|
181
|
+
sessionId: text('sessionId'),
|
|
182
|
+
launchId: text('launchId'),
|
|
183
|
+
agent: text('agent'),
|
|
184
|
+
machineId: text('machineId'),
|
|
185
|
+
hostname: text('hostname') ?? observer,
|
|
186
|
+
capture: parseActionCapture(record.capture),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Read the standalone ledger, newest day first, bounded by `limit` actions.
|
|
191
|
+
*
|
|
192
|
+
* Bounded by construction: days are read newest-first and reading stops as soon
|
|
193
|
+
* as the budget is met, so a box with months of history costs the same as one
|
|
194
|
+
* with a day of it.
|
|
195
|
+
*/
|
|
196
|
+
export function listStandaloneComputerActions(opts = {}) {
|
|
197
|
+
const dir = opts.dir ?? standaloneComputerActionsDir();
|
|
198
|
+
const limit = opts.limit ?? DEFAULT_ACTION_LIMIT;
|
|
199
|
+
// This ledger is per-machine by construction, so "who ran it" is this machine
|
|
200
|
+
// unless the record names a driven remote host.
|
|
201
|
+
const observer = opts.observer ?? machineId();
|
|
202
|
+
let days;
|
|
203
|
+
try {
|
|
204
|
+
days = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')).sort().reverse();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return []; // The engine has never run here, or is not installed.
|
|
208
|
+
}
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const day of days) {
|
|
211
|
+
if (out.length >= limit)
|
|
212
|
+
break;
|
|
213
|
+
let lines;
|
|
214
|
+
try {
|
|
215
|
+
lines = fs.readFileSync(path.join(dir, day), 'utf8').split('\n');
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
continue; /* rotated or removed mid-read */
|
|
219
|
+
}
|
|
220
|
+
// Newest last within a day, and the budget favours the newest actions.
|
|
221
|
+
for (let index = lines.length - 1; index >= 0 && out.length < limit; index--) {
|
|
222
|
+
const line = lines[index];
|
|
223
|
+
if (!line)
|
|
224
|
+
continue;
|
|
225
|
+
const action = standaloneLineToAction(line, observer);
|
|
226
|
+
if (action)
|
|
227
|
+
out.push(action);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.sort((a, b) => b.tsMs - a.tsMs);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Union the two ledgers, preferring the standalone record for any run present in
|
|
235
|
+
* both.
|
|
236
|
+
*
|
|
237
|
+
* Dedupe keys on `invocationId`, NOT on a timestamp or pid. When a command is
|
|
238
|
+
* forwarded through `agents computer`, BOTH stores receive it — and the
|
|
239
|
+
* forwarding rewrites `ts` and `pid` on the way through, so the two copies of one
|
|
240
|
+
* action do not agree on either. `invocationId` is minted by the engine and
|
|
241
|
+
* echoed unchanged, which makes it the only field that identifies the same run in
|
|
242
|
+
* both files. A legacy feed record with no invocationId cannot be matched to
|
|
243
|
+
* anything, so it is kept: dropping it would lose history the standalone ledger
|
|
244
|
+
* never had.
|
|
245
|
+
*/
|
|
246
|
+
export function mergeComputerActionSources(standalone, legacy) {
|
|
247
|
+
const standaloneRuns = new Set();
|
|
248
|
+
for (const action of standalone)
|
|
249
|
+
if (action.invocationId)
|
|
250
|
+
standaloneRuns.add(action.invocationId);
|
|
251
|
+
const merged = [...standalone];
|
|
252
|
+
for (const action of legacy) {
|
|
253
|
+
if (action.invocationId && standaloneRuns.has(action.invocationId))
|
|
254
|
+
continue;
|
|
255
|
+
merged.push(action);
|
|
256
|
+
}
|
|
257
|
+
merged.sort((a, b) => b.tsMs - a.tsMs);
|
|
258
|
+
return merged;
|
|
259
|
+
}
|
|
96
260
|
/** Read `computer.action` events straight from the durable event ledger,
|
|
97
261
|
* newest first, bounded by `limit`. Malformed/legacy records (missing
|
|
98
262
|
* `command` or `pid`, or an unparseable `ts`) are skipped, never thrown —
|
|
@@ -240,7 +404,10 @@ function appendPrunedRunsFromDb(rows, limit) {
|
|
|
240
404
|
* flat/`--json` printer's) data source. `machine` narrows to rows whose
|
|
241
405
|
* invoking hostname, machineId, or `--device` target contains the substring. */
|
|
242
406
|
export function buildComputerSessionRows(opts = {}) {
|
|
243
|
-
const actions =
|
|
407
|
+
const actions = mergeComputerActionSources(
|
|
408
|
+
// `observer` is threaded from the caller's scope so a row's device and the
|
|
409
|
+
// scope that reported it can never name the same box differently.
|
|
410
|
+
listStandaloneComputerActions({ limit: opts.limit, ...(opts.observer ? { observer: opts.observer } : {}) }), listComputerActions({ limit: opts.limit }));
|
|
244
411
|
const index = buildLaunchSessionIndex();
|
|
245
412
|
const rows = groupIntoComputerRuns(actions, (sessionId) => getSessionById(sessionId), (launchId) => resolveLaunchSession(index, launchId));
|
|
246
413
|
// Retention runs here, on the listing path, never on the action hot path:
|
|
@@ -878,7 +878,7 @@ export async function runDaemon() {
|
|
|
878
878
|
assertTestDaemonHome();
|
|
879
879
|
// Lifecycle readers and launchers do not run services. Load their code only
|
|
880
880
|
// in the daemon process, before it claims or publishes lifecycle state.
|
|
881
|
-
const [{ BrowserService }, { SessionIndexService }, { SessionSummarizerService }, { SessionTitleService }, { MonitorEngineService }, { AccountUsageService, AccountAuthService }, { CatchupService }, { BrowserIPCService }, { WatchdogService }, { DeviceProbeService }, { SelfHealService }, { SelfUpdateService }, { HarnessUpdateService }, { AuthSyncService }, { UsageSyncService }, { StateDirCheckService }, { SessionStateService }, { AttentionNotifyService }, { WebhookReceiverService }, { HeartbeatService }, { TmuxReapService }, { BrowserTaskReapService },] = await Promise.all([
|
|
881
|
+
const [{ BrowserService }, { SessionIndexService }, { SessionSummarizerService }, { SessionTitleService }, { MonitorEngineService }, { AccountUsageService, AccountAuthService }, { CatchupService }, { BrowserIPCService }, { WatchdogService }, { DeviceProbeService }, { SelfHealService }, { SelfUpdateService }, { HarnessUpdateService }, { AuthSyncService }, { UsageSyncService }, { StateDirCheckService }, { SessionStateService }, { FeedStreamService }, { AttentionNotifyService }, { WebhookReceiverService }, { HeartbeatService }, { TmuxReapService }, { BrowserTaskReapService },] = await Promise.all([
|
|
882
882
|
import('../browser/service.js'),
|
|
883
883
|
import('./session-index-service.js'),
|
|
884
884
|
import('./session-summarizer-service.js'),
|
|
@@ -896,6 +896,7 @@ export async function runDaemon() {
|
|
|
896
896
|
import('./usage-sync-service.js'),
|
|
897
897
|
import('./state-dir-check-service.js'),
|
|
898
898
|
import('./session-state-service.js'),
|
|
899
|
+
import('./feed-stream-service.js'),
|
|
899
900
|
import('./attention-notify-service.js'),
|
|
900
901
|
import('./webhook-receiver-service.js'),
|
|
901
902
|
import('./heartbeat-service.js'),
|
|
@@ -1021,6 +1022,12 @@ export async function runDaemon() {
|
|
|
1021
1022
|
}
|
|
1022
1023
|
else
|
|
1023
1024
|
log('INFO', 'Live session-state service disabled');
|
|
1025
|
+
// The shared feed fan-out. Registered even when disabled at boot so a later
|
|
1026
|
+
// `agents daemon services enable feed-stream` brings it up over SIGHUP, the
|
|
1027
|
+
// same live-transition shape browser IPC uses.
|
|
1028
|
+
supervisor.register(new FeedStreamService(), { enabled: isEnabled('feed-stream') });
|
|
1029
|
+
if (!isEnabled('feed-stream'))
|
|
1030
|
+
log('INFO', 'Shared feed stream service disabled');
|
|
1024
1031
|
const monitorEngineSvc = new MonitorEngineService();
|
|
1025
1032
|
if (isEnabled('monitors'))
|
|
1026
1033
|
supervisor.register(monitorEngineSvc);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared feed collector's lifecycle as a `DaemonService`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE DAEMON OWNS IT. Dialing every peer over ssh and holding those
|
|
5
|
+
* connections open is an ACTION on the fleet, so by the repo's one-scheduler /
|
|
6
|
+
* one-executor rule it belongs to the daemon and not to a UI surface. Before
|
|
7
|
+
* this service, every consumer of `agents feed watch --json` ran its own
|
|
8
|
+
* `watchFleetFeed`, which is N ssh children per peer for N readers. The daemon
|
|
9
|
+
* runs one, and the readers attach over a socket (`feed/hub-server.ts`).
|
|
10
|
+
*
|
|
11
|
+
* The server binds on start, but the fan-out itself is DEMAND-GATED by the hub:
|
|
12
|
+
* with no client connected there is no `watchFleetFeed`, so an idle box holds no
|
|
13
|
+
* peer connections — the same idle-cost rule `SessionStateService` follows for
|
|
14
|
+
* its gather.
|
|
15
|
+
*/
|
|
16
|
+
import { BaseDaemonService, type DaemonContext } from './service.js';
|
|
17
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
18
|
+
export declare class FeedStreamService extends BaseDaemonService {
|
|
19
|
+
readonly id: DaemonServiceId;
|
|
20
|
+
private server;
|
|
21
|
+
protected onStart(ctx: DaemonContext): Promise<void>;
|
|
22
|
+
protected onStop(): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared feed collector's lifecycle as a `DaemonService`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE DAEMON OWNS IT. Dialing every peer over ssh and holding those
|
|
5
|
+
* connections open is an ACTION on the fleet, so by the repo's one-scheduler /
|
|
6
|
+
* one-executor rule it belongs to the daemon and not to a UI surface. Before
|
|
7
|
+
* this service, every consumer of `agents feed watch --json` ran its own
|
|
8
|
+
* `watchFleetFeed`, which is N ssh children per peer for N readers. The daemon
|
|
9
|
+
* runs one, and the readers attach over a socket (`feed/hub-server.ts`).
|
|
10
|
+
*
|
|
11
|
+
* The server binds on start, but the fan-out itself is DEMAND-GATED by the hub:
|
|
12
|
+
* with no client connected there is no `watchFleetFeed`, so an idle box holds no
|
|
13
|
+
* peer connections — the same idle-cost rule `SessionStateService` follows for
|
|
14
|
+
* its gather.
|
|
15
|
+
*/
|
|
16
|
+
import { BaseDaemonService } from './service.js';
|
|
17
|
+
import { FeedHub } from '../feed/hub.js';
|
|
18
|
+
import { FeedHubServer } from '../feed/hub-server.js';
|
|
19
|
+
import { sharedLocalFeedHub, watchFleetFeed } from '../feed/watch.js';
|
|
20
|
+
export class FeedStreamService extends BaseDaemonService {
|
|
21
|
+
id = 'feed-stream';
|
|
22
|
+
server = null;
|
|
23
|
+
async onStart(ctx) {
|
|
24
|
+
// Two collectors, one socket. The fleet hub holds one ssh child per peer plus
|
|
25
|
+
// this box's rows; the local hub is this box ONLY and cannot fan out, so a
|
|
26
|
+
// reader that wants just this machine never causes a peer dial. They are
|
|
27
|
+
// separate hubs rather than one filtered stream because demand must be
|
|
28
|
+
// separate too: a local-only reader must not start the fleet fan-out.
|
|
29
|
+
const fleet = new FeedHub({ watch: watchFleetFeed });
|
|
30
|
+
this.server = new FeedHubServer(fleet, undefined, sharedLocalFeedHub());
|
|
31
|
+
await this.server.start();
|
|
32
|
+
ctx.log('INFO', 'Feed stream hub listening (fan-out starts on first reader)');
|
|
33
|
+
}
|
|
34
|
+
async onStop() {
|
|
35
|
+
const server = this.server;
|
|
36
|
+
this.server = null;
|
|
37
|
+
if (server)
|
|
38
|
+
await server.stop();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* enabled without pulling in the whole daemon lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
/** Every service the daemon can host. IDs are kebab-case and stable. */
|
|
10
|
-
export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'session-title' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer' | 'attention-notify' | 'harness-update';
|
|
10
|
+
export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'session-title' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'feed-stream' | 'session-summarizer' | 'attention-notify' | 'harness-update';
|
|
11
11
|
/** Human-readable metadata for each service. */
|
|
12
12
|
interface DaemonServiceDef {
|
|
13
13
|
id: DaemonServiceId;
|
|
@@ -92,6 +92,11 @@ export const DAEMON_SERVICES = [
|
|
|
92
92
|
title: 'Live session state',
|
|
93
93
|
description: 'Publishes this host\'s active session metadata for sessions watch and fleet consumers.',
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
id: 'feed-stream',
|
|
97
|
+
title: 'Shared feed stream',
|
|
98
|
+
description: 'Owns the one fleet feed fan-out and serves it to every reader over a socket, so N consumers cost one ssh per peer instead of N.',
|
|
99
|
+
},
|
|
95
100
|
{
|
|
96
101
|
id: 'session-index',
|
|
97
102
|
title: 'Session-index warm',
|
|
@@ -30,13 +30,43 @@ export declare function sshTargetFor(device: DeviceProfile): string;
|
|
|
30
30
|
*/
|
|
31
31
|
export declare function fleetDialTarget(device: DeviceProfile): string;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Render `cmd` as the single command string ssh sends to the peer.
|
|
34
|
+
*
|
|
35
|
+
* Windows devices speak PowerShell, so the result is run through
|
|
36
|
+
* `powershell -NoProfile -EncodedCommand`; POSIX devices get it as the remote
|
|
37
|
+
* login shell sees it. Returns undefined when no command was given (interactive
|
|
37
38
|
* login).
|
|
39
|
+
*
|
|
40
|
+
* TWO MODES, and the difference is load-bearing rather than a convenience.
|
|
41
|
+
*
|
|
42
|
+
* The default joins the tokens RAW. That is not a bug to be tidied away: every
|
|
43
|
+
* existing caller of `agents ssh` relies on the remote shell interpreting what it
|
|
44
|
+
* is handed — `agents ssh box 'bash -lc "cd x && make"'` arrives as ONE token
|
|
45
|
+
* whose pipeline, globs and redirections the peer's shell must expand. Quoting
|
|
46
|
+
* that would ship the whole line as a literal argument and break it.
|
|
47
|
+
*
|
|
48
|
+
* `{ argv: true }` is for a caller that genuinely holds an argv ARRAY and needs
|
|
49
|
+
* each element delivered as exactly one token. Joining those raw destroys any
|
|
50
|
+
* token containing a space or a metacharacter — `['--title', 'two words']`
|
|
51
|
+
* arrives as three tokens, and `'a & b'` arrives as a backgrounded command — which
|
|
52
|
+
* is what a native client hitting this path actually hit.
|
|
53
|
+
*
|
|
54
|
+
* So fidelity is opt-in at the call site that knows which shape it has, and the
|
|
55
|
+
* quoting itself reuses the canonical helpers (`shellQuote`, {@link pwshQuote})
|
|
56
|
+
* rather than introducing a third escaping scheme.
|
|
38
57
|
*/
|
|
39
|
-
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]
|
|
58
|
+
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[], opts?: {
|
|
59
|
+
argv?: boolean;
|
|
60
|
+
prelude?: string[];
|
|
61
|
+
}): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Single-quote one token for PowerShell's OWN parser. Inside a single-quoted pwsh
|
|
64
|
+
* string the only special character is `'`, escaped by doubling.
|
|
65
|
+
*
|
|
66
|
+
* This is correct for a value PowerShell itself consumes, and NOT sufficient for
|
|
67
|
+
* an argument handed on to a native program — see the Win32 note below.
|
|
68
|
+
*/
|
|
69
|
+
export declare function pwshQuote(token: string): string;
|
|
40
70
|
/**
|
|
41
71
|
* True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
|
|
42
72
|
* the standalone `browser` binary (`cli/package.json` `bin.browser` →
|
|
@@ -65,6 +95,19 @@ export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
|
|
|
65
95
|
* fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
|
|
66
96
|
* so nothing is doubled.
|
|
67
97
|
*/
|
|
98
|
+
/**
|
|
99
|
+
* The provenance prefix as READY SHELL SYNTAX for the device's shell.
|
|
100
|
+
*
|
|
101
|
+
* These tokens are already quoted/escaped for their target shell — a POSIX
|
|
102
|
+
* `K=V` pair is `shellQuote`d here, and a PowerShell assignment is a complete
|
|
103
|
+
* statement with its own doubled quotes. That matters because argv mode quotes
|
|
104
|
+
* every token it is handed: quoting THESE again turns
|
|
105
|
+
* `'AGENTS_ACTOR=Some Name'` into `''\''AGENTS_ACTOR=Some Name'\'''` and turns a
|
|
106
|
+
* pwsh assignment into an inert string literal. So the prelude is composed
|
|
107
|
+
* SEPARATELY from the caller's argv rather than concatenated into it, and this is
|
|
108
|
+
* the single definition both paths use.
|
|
109
|
+
*/
|
|
110
|
+
export declare function fleetRemotePrelude(device: Pick<DeviceProfile, 'shell'>, provenanceEnv?: Record<string, string>): string[];
|
|
68
111
|
export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>, provenanceEnv?: Record<string, string>): string[];
|
|
69
112
|
/**
|
|
70
113
|
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
@@ -136,6 +179,7 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
|
|
|
136
179
|
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
|
|
137
180
|
agentOnly?: boolean;
|
|
138
181
|
interactiveCwd?: string;
|
|
182
|
+
argv?: boolean;
|
|
139
183
|
}): {
|
|
140
184
|
args: string[];
|
|
141
185
|
env: Record<string, string>;
|
|
@@ -18,12 +18,14 @@ import * as path from 'path';
|
|
|
18
18
|
import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { resolveActor, actorEnv } from '../actor.js';
|
|
20
20
|
import { getCliLaunch } from '../cli-entry.js';
|
|
21
|
-
import { encodePwshBase64 } from '../pwsh.js';
|
|
21
|
+
import { encodePwshBase64, pwshLiteral, pwshNativeExecStatements } from '../pwsh.js';
|
|
22
|
+
import { quoteWin32ExecArg } from '../platform/exec.js';
|
|
22
23
|
import { homeRemainder, remoteCdPrefix } from '../project-root.js';
|
|
23
24
|
import { getCacheDir } from '../state.js';
|
|
24
25
|
import { hostKeyCheckingOpts } from './known-hosts.js';
|
|
25
26
|
import { hostNameFor } from './ssh-config.js';
|
|
26
27
|
import { resolveDeviceProfile } from './resolve-profile.js';
|
|
28
|
+
import { renderPowershellCommand, windowsAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
27
29
|
/** Env var the askpass shim reads to know which bundle holds the password. */
|
|
28
30
|
export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
|
|
29
31
|
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
@@ -72,20 +74,139 @@ export function fleetDialTarget(device) {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
77
|
+
* Render `cmd` as the single command string ssh sends to the peer.
|
|
78
|
+
*
|
|
79
|
+
* Windows devices speak PowerShell, so the result is run through
|
|
80
|
+
* `powershell -NoProfile -EncodedCommand`; POSIX devices get it as the remote
|
|
81
|
+
* login shell sees it. Returns undefined when no command was given (interactive
|
|
79
82
|
* login).
|
|
83
|
+
*
|
|
84
|
+
* TWO MODES, and the difference is load-bearing rather than a convenience.
|
|
85
|
+
*
|
|
86
|
+
* The default joins the tokens RAW. That is not a bug to be tidied away: every
|
|
87
|
+
* existing caller of `agents ssh` relies on the remote shell interpreting what it
|
|
88
|
+
* is handed — `agents ssh box 'bash -lc "cd x && make"'` arrives as ONE token
|
|
89
|
+
* whose pipeline, globs and redirections the peer's shell must expand. Quoting
|
|
90
|
+
* that would ship the whole line as a literal argument and break it.
|
|
91
|
+
*
|
|
92
|
+
* `{ argv: true }` is for a caller that genuinely holds an argv ARRAY and needs
|
|
93
|
+
* each element delivered as exactly one token. Joining those raw destroys any
|
|
94
|
+
* token containing a space or a metacharacter — `['--title', 'two words']`
|
|
95
|
+
* arrives as three tokens, and `'a & b'` arrives as a backgrounded command — which
|
|
96
|
+
* is what a native client hitting this path actually hit.
|
|
97
|
+
*
|
|
98
|
+
* So fidelity is opt-in at the call site that knows which shape it has, and the
|
|
99
|
+
* quoting itself reuses the canonical helpers (`shellQuote`, {@link pwshQuote})
|
|
100
|
+
* rather than introducing a third escaping scheme.
|
|
80
101
|
*/
|
|
81
|
-
export function wrapRemoteCommand(device, cmd) {
|
|
102
|
+
export function wrapRemoteCommand(device, cmd, opts = {}) {
|
|
82
103
|
if (cmd.length === 0)
|
|
83
104
|
return undefined;
|
|
84
|
-
const
|
|
105
|
+
const prelude = opts.prelude ?? [];
|
|
106
|
+
let script;
|
|
107
|
+
if (opts.argv) {
|
|
108
|
+
// Quote EACH caller token so the peer receives it byte-for-byte, then prefix
|
|
109
|
+
// the prelude VERBATIM — it is already shell syntax and re-quoting it would
|
|
110
|
+
// break it (see `fleetRemotePrelude`).
|
|
111
|
+
if (device.shell === 'powershell') {
|
|
112
|
+
script = pwshExactArgvScript(cmd, prelude);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
script = [...prelude, ...cmd.map(shellQuote)].join(' ');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// The default joins raw, which is what lets a caller hand the remote shell
|
|
120
|
+
// something to interpret. See the docblock above for why both must exist.
|
|
121
|
+
script = [...prelude, ...cmd].join(' ');
|
|
122
|
+
}
|
|
85
123
|
if (device.shell === 'powershell') {
|
|
86
|
-
|
|
124
|
+
// Same renderer the Windows `agents` launcher uses, so a long script gets the
|
|
125
|
+
// compressed representation here too rather than only on that path. The
|
|
126
|
+
// interactive login route (`buildInteractiveShellCommand`, -NoExit) is
|
|
127
|
+
// deliberately left alone: it must stay an interactive session.
|
|
128
|
+
return renderPowershellCommand(script);
|
|
87
129
|
}
|
|
88
|
-
return
|
|
130
|
+
return script;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Single-quote one token for PowerShell's OWN parser. Inside a single-quoted pwsh
|
|
134
|
+
* string the only special character is `'`, escaped by doubling.
|
|
135
|
+
*
|
|
136
|
+
* This is correct for a value PowerShell itself consumes, and NOT sufficient for
|
|
137
|
+
* an argument handed on to a native program — see the Win32 note below.
|
|
138
|
+
*/
|
|
139
|
+
export function pwshQuote(token) {
|
|
140
|
+
return `'${token.replace(/'/g, "''")}'`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Emit a PowerShell script that runs `cmd` with EXACT argv, for either kind of
|
|
144
|
+
* target a Windows peer can name.
|
|
145
|
+
*
|
|
146
|
+
* Windows has no argv array: a process receives ONE string and splits it itself.
|
|
147
|
+
* PowerShell 5.1 rebuilds that string when it invokes a native program, and its
|
|
148
|
+
* serializer is lossy — measured on a real peer, an EMPTY argument is dropped and
|
|
149
|
+
* an embedded `"` is discarded, so the callee's argv silently shifts.
|
|
150
|
+
*
|
|
151
|
+
* The obvious fix, the `--%` stop-parsing token, is NOT used, because measurement
|
|
152
|
+
* killed it three ways:
|
|
153
|
+
* - it only applies to a NATIVE command, and `agents` on Windows resolves to
|
|
154
|
+
* `agents.ps1`, so a script target received `--%` as a literal argument and
|
|
155
|
+
* the whole remainder as one string;
|
|
156
|
+
* - a token containing a NEWLINE terminates the directive, producing a parser
|
|
157
|
+
* error;
|
|
158
|
+
* - it performs cmd-style `%VAR%` expansion, so a literal `%PATH%` became six
|
|
159
|
+
* arguments — the exact opposite of exact argv.
|
|
160
|
+
*
|
|
161
|
+
* So the script branches on what the peer's own command discovery finds:
|
|
162
|
+
*
|
|
163
|
+
* - **native executable** — launched through `System.Diagnostics.Process` with a
|
|
164
|
+
* pre-built `Arguments` string escaped by {@link quoteWin32ExecArg}. .NET hands
|
|
165
|
+
* that string to `CreateProcess` essentially verbatim, so the child's
|
|
166
|
+
* `CommandLineToArgvW` reconstructs the tokens exactly; no shell sees it, so
|
|
167
|
+
* no `%VAR%` expansion and no newline sensitivity. `UseShellExecute = $false`
|
|
168
|
+
* with no redirection leaves the child on the inherited handles, which is what
|
|
169
|
+
* lets a binary stdout stream through unchanged.
|
|
170
|
+
* - **anything else** (a `.ps1`/`.cmd` launcher, a function, a cmdlet, an alias)
|
|
171
|
+
* — invoked with a splatted PowerShell array. That is an in-process call, so
|
|
172
|
+
* the native serializer is never involved and every token survives as itself.
|
|
173
|
+
*
|
|
174
|
+
* The exit code is propagated in both branches; a script launcher that sets no
|
|
175
|
+
* `$LASTEXITCODE` is left alone rather than forced to 0.
|
|
176
|
+
*/
|
|
177
|
+
function pwshExactArgvScript(cmd, prelude) {
|
|
178
|
+
// The Agents CLI gets the canonical launcher, not the generic dispatch. On
|
|
179
|
+
// Windows `agents` is an npm `agents.ps1` whose own body splats `$args` into
|
|
180
|
+
// native node.exe — the PowerShell 5.1 lossy path — so even a perfectly
|
|
181
|
+
// splatted call into that script loses an embedded quote one layer deeper.
|
|
182
|
+
// `windowsAgentsInvocation` resolves the package's declared entry and runs it
|
|
183
|
+
// directly, which is the only way the real Agents parser sees exact tokens.
|
|
184
|
+
const bin = cmd[0];
|
|
185
|
+
if (bin === 'agents' || bin === 'ag') {
|
|
186
|
+
// `windowsAgentsInvocation` leaves the child's code in `$zq`.
|
|
187
|
+
return [...prelude, windowsAgentsInvocation(cmd.slice(1), bin), 'exit $zq'].join('\n');
|
|
188
|
+
}
|
|
189
|
+
const program = pwshQuote(cmd[0]);
|
|
190
|
+
const rest = cmd.slice(1);
|
|
191
|
+
const splat = rest.length > 0 ? `@(${rest.map(pwshQuote).join(', ')})` : '@()';
|
|
192
|
+
return [
|
|
193
|
+
...prelude,
|
|
194
|
+
`$ErrorActionPreference='Stop'`,
|
|
195
|
+
`$__c = Get-Command -Name ${program} -ErrorAction Stop`,
|
|
196
|
+
`if ($__c.CommandType -eq 'Application') {`,
|
|
197
|
+
// One emitter for the .NET native-exec block, shared with the Windows
|
|
198
|
+
// `agents` launcher in `hosts/remote-cmd.ts`; a second copy would drift.
|
|
199
|
+
...pwshNativeExecStatements('$__c.Source', pwshLiteral(rest.map(quoteWin32ExecArg).join(' '))).map((line) => ` ${line}`),
|
|
200
|
+
// `pwshNativeExecStatements` names the process handle `$zp`.
|
|
201
|
+
` exit $zp.ExitCode`,
|
|
202
|
+
`}`,
|
|
203
|
+
// `@__a` SPLATS the array into separate arguments. `& $__c @(...)` on an
|
|
204
|
+
// array LITERAL does not splat — it passes one array-valued argument, which
|
|
205
|
+
// a real peer reported back as every token collapsed into one.
|
|
206
|
+
`$__a = ${splat}`,
|
|
207
|
+
`& $__c @__a`,
|
|
208
|
+
`if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }`,
|
|
209
|
+
].join('\n');
|
|
89
210
|
}
|
|
90
211
|
/**
|
|
91
212
|
* True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
|
|
@@ -123,22 +244,43 @@ export function isAgentsBrowserDrive(cmd) {
|
|
|
123
244
|
* fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
|
|
124
245
|
* so nothing is doubled.
|
|
125
246
|
*/
|
|
126
|
-
|
|
247
|
+
/**
|
|
248
|
+
* The provenance prefix as READY SHELL SYNTAX for the device's shell.
|
|
249
|
+
*
|
|
250
|
+
* These tokens are already quoted/escaped for their target shell — a POSIX
|
|
251
|
+
* `K=V` pair is `shellQuote`d here, and a PowerShell assignment is a complete
|
|
252
|
+
* statement with its own doubled quotes. That matters because argv mode quotes
|
|
253
|
+
* every token it is handed: quoting THESE again turns
|
|
254
|
+
* `'AGENTS_ACTOR=Some Name'` into `''\''AGENTS_ACTOR=Some Name'\'''` and turns a
|
|
255
|
+
* pwsh assignment into an inert string literal. So the prelude is composed
|
|
256
|
+
* SEPARATELY from the caller's argv rather than concatenated into it, and this is
|
|
257
|
+
* the single definition both paths use.
|
|
258
|
+
*/
|
|
259
|
+
export function fleetRemotePrelude(device, provenanceEnv = actorEnv(resolveActor())) {
|
|
127
260
|
if (device.shell === 'powershell') {
|
|
128
|
-
|
|
129
|
-
// is always this literal, so `startsWith` would only loosen it for no gain.
|
|
130
|
-
if (cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`)
|
|
131
|
-
return cmd;
|
|
132
|
-
const prelude = [
|
|
261
|
+
return [
|
|
133
262
|
`$env:AGENTS_FLEET_REMOTE='1';`,
|
|
134
263
|
...Object.entries(provenanceEnv).map(([k, v]) => `$env:${k}='${v.replace(/'/g, "''")}';`),
|
|
135
264
|
];
|
|
136
|
-
return [...prelude, ...cmd];
|
|
137
265
|
}
|
|
138
|
-
|
|
266
|
+
return [
|
|
267
|
+
'env',
|
|
268
|
+
'AGENTS_FLEET_REMOTE=1',
|
|
269
|
+
...Object.entries(provenanceEnv).map(([k, v]) => shellQuote(`${k}=${v}`)),
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
/** True when `cmd` already carries the prelude {@link fleetRemotePrelude} emits. */
|
|
273
|
+
function alreadyMarked(cmd, device) {
|
|
274
|
+
if (device.shell === 'powershell')
|
|
275
|
+
return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`;
|
|
276
|
+
return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1';
|
|
277
|
+
}
|
|
278
|
+
export function markFleetRemote(cmd, device, provenanceEnv = actorEnv(resolveActor())) {
|
|
279
|
+
// Exact-match guard: the marker token is always this literal, so `startsWith`
|
|
280
|
+
// would only loosen it for no gain.
|
|
281
|
+
if (alreadyMarked(cmd, device))
|
|
139
282
|
return cmd;
|
|
140
|
-
|
|
141
|
-
return ['env', 'AGENTS_FLEET_REMOTE=1', ...actorTokens, ...cmd];
|
|
283
|
+
return [...fleetRemotePrelude(device, provenanceEnv), ...cmd];
|
|
142
284
|
}
|
|
143
285
|
/**
|
|
144
286
|
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
@@ -229,10 +371,16 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
|
|
|
229
371
|
// Stamp the consent marker on the REMOTE command, not the local ssh env:
|
|
230
372
|
// SSH_ASKPASS lives on this side; AGENTS_FLEET_REMOTE must be visible to the
|
|
231
373
|
// process that runs on the peer.
|
|
232
|
-
|
|
374
|
+
// Provenance is composed as a PRELUDE, not prepended to the argv array. In argv
|
|
375
|
+
// mode every token handed to `wrapRemoteCommand` is quoted, and the prelude is
|
|
376
|
+
// already shell syntax — mixing them in meant the actor pairs were quoted twice
|
|
377
|
+
// (breaking any value with a space or a quote) and the pwsh assignments became
|
|
378
|
+
// inert string literals.
|
|
379
|
+
const needsProvenance = !interactive && isAgentsBrowserDrive(cmd) && !alreadyMarked(cmd, device);
|
|
380
|
+
const prelude = needsProvenance ? fleetRemotePrelude(device) : [];
|
|
233
381
|
const remote = interactive
|
|
234
382
|
? buildInteractiveShellCommand(device, opts.interactiveCwd)
|
|
235
|
-
: wrapRemoteCommand(device,
|
|
383
|
+
: wrapRemoteCommand(device, cmd, { ...(opts.argv ? { argv: true } : {}), ...(prelude.length > 0 ? { prelude } : {}) });
|
|
236
384
|
const env = {};
|
|
237
385
|
const args = [
|
|
238
386
|
...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
|