@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
package/dist/lib/feed/watch.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { SessionProjection } from '../session/projection.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
2
|
import * as fs from 'node:fs';
|
|
4
3
|
import * as path from 'node:path';
|
|
5
4
|
import { loadDevices, isDialableDevice } from '../devices/registry.js';
|
|
@@ -12,21 +11,26 @@ import { watchLocalSessions } from '../session/watch.js';
|
|
|
12
11
|
import { readBlock, readResolution, blockIdForSession } from './feed.js';
|
|
13
12
|
import { reconcileAttention } from './attention.js';
|
|
14
13
|
import { ActivityStream } from './activity-stream.js';
|
|
14
|
+
import { collectToolRows, watchToolActivity } from './tool-activity.js';
|
|
15
|
+
import { FeedWatchState } from './envelope.js';
|
|
16
|
+
import { FeedHub } from './hub.js';
|
|
17
|
+
import { getCachedToolSetup, subscribeToolSetup } from '../setup-tool-status.js';
|
|
15
18
|
import { PR_STATUS_TTL_MS, readPullRequestStatus, withPullRequestStatus } from './pr-status.js';
|
|
16
|
-
export
|
|
17
|
-
streamId;
|
|
18
|
-
sequence = 0;
|
|
19
|
-
constructor(streamId = randomUUID()) { this.streamId = streamId; }
|
|
20
|
-
emit(event) {
|
|
21
|
-
return { v: 1, streamId: this.streamId, sequence: ++this.sequence, ...event };
|
|
22
|
-
}
|
|
23
|
-
}
|
|
19
|
+
export { FeedWatchState } from './envelope.js';
|
|
24
20
|
/** Reuse session ownership reconciliation for the combined operator stream. */
|
|
25
21
|
export class FeedSessionProjection {
|
|
26
22
|
state;
|
|
27
23
|
sessions = new SessionProjection();
|
|
28
24
|
observations = new Map();
|
|
29
25
|
projectedAttention = new Map();
|
|
26
|
+
/**
|
|
27
|
+
* Tool rows per observing scope. A reset replaces one scope's rows and must
|
|
28
|
+
* not touch another's — the same per-scope rule the agent rows follow, and
|
|
29
|
+
* the reason a peer reconnecting cannot erase the local machine's tasks.
|
|
30
|
+
*/
|
|
31
|
+
toolsByScope = new Map();
|
|
32
|
+
/** Whole-set per scope; see the `setup.snapshot` docblock in `envelope.ts`. */
|
|
33
|
+
setupByScope = new Map();
|
|
30
34
|
constructor(state = new FeedWatchState()) {
|
|
31
35
|
this.state = state;
|
|
32
36
|
}
|
|
@@ -43,6 +47,8 @@ export class FeedSessionProjection {
|
|
|
43
47
|
attention.set(row.rowKey, item);
|
|
44
48
|
}
|
|
45
49
|
this.observations.set(scope, attention);
|
|
50
|
+
this.toolsByScope.set(scope, new Map(event.tools.map((tool) => [tool.rowKey, tool])));
|
|
51
|
+
this.setupByScope.set(scope, event.setup);
|
|
46
52
|
}
|
|
47
53
|
else if (event.type === 'agent.upsert') {
|
|
48
54
|
sessionEvent = { ...base, type: 'upsert', rowKey: event.rowKey, row: event.agent };
|
|
@@ -59,6 +65,20 @@ export class FeedSessionProjection {
|
|
|
59
65
|
else if (event.type === 'attention.remove') {
|
|
60
66
|
this.observations.get(scope)?.delete(event.rowKey);
|
|
61
67
|
}
|
|
68
|
+
else if (event.type === 'tool.upsert') {
|
|
69
|
+
if (!this.toolsByScope.has(scope))
|
|
70
|
+
this.toolsByScope.set(scope, new Map());
|
|
71
|
+
this.toolsByScope.get(scope).set(event.rowKey, event.tool);
|
|
72
|
+
return [this.state.emit({ type: 'tool.upsert', scope, rowKey: event.rowKey, tool: event.tool })];
|
|
73
|
+
}
|
|
74
|
+
else if (event.type === 'tool.remove') {
|
|
75
|
+
this.toolsByScope.get(scope)?.delete(event.rowKey);
|
|
76
|
+
return [this.state.emit({ type: 'tool.remove', scope, rowKey: event.rowKey })];
|
|
77
|
+
}
|
|
78
|
+
else if (event.type === 'setup.snapshot') {
|
|
79
|
+
this.setupByScope.set(scope, event.setup);
|
|
80
|
+
return [this.state.emit({ type: 'setup.snapshot', scope, capturedAt: event.capturedAt, setup: event.setup })];
|
|
81
|
+
}
|
|
62
82
|
else {
|
|
63
83
|
const { v: _v, streamId: _streamId, sequence: _sequence, ...payload } = event;
|
|
64
84
|
return [this.state.emit(payload)];
|
|
@@ -77,7 +97,9 @@ export class FeedSessionProjection {
|
|
|
77
97
|
if (rowEvent.type === 'reset') {
|
|
78
98
|
resetScopes.add(rowEvent.scope);
|
|
79
99
|
result.push(this.state.emit({ type: 'reset', scope: rowEvent.scope, capturedAt: rowEvent.capturedAt, agents: rowEvent.rows,
|
|
80
|
-
attention: [...next.values()].filter(value => value.scope === rowEvent.scope).map(value => value.item)
|
|
100
|
+
attention: [...next.values()].filter(value => value.scope === rowEvent.scope).map(value => value.item),
|
|
101
|
+
tools: [...(this.toolsByScope.get(rowEvent.scope)?.values() ?? [])],
|
|
102
|
+
setup: this.setupByScope.get(rowEvent.scope) ?? [] }));
|
|
81
103
|
}
|
|
82
104
|
else if (rowEvent.type === 'upsert') {
|
|
83
105
|
result.push(this.state.emit({ type: 'agent.upsert', scope: rowEvent.scope, rowKey: rowEvent.rowKey, agent: rowEvent.row }));
|
|
@@ -136,11 +158,11 @@ async function projectAgent(agent, gh) {
|
|
|
136
158
|
const pullRequest = await pullRequestFor(agent, gh);
|
|
137
159
|
return { agent: withPullRequestStatus(agent, pullRequest), attention: attentionFor(agent, pullRequest) };
|
|
138
160
|
}
|
|
139
|
-
export async function projectSessionEnvelope(event, state, gh) {
|
|
161
|
+
export async function projectSessionEnvelope(event, state, gh, tools = [], setup = []) {
|
|
140
162
|
if (event.type === 'reset') {
|
|
141
163
|
const projected = await Promise.all(event.rows.map((row) => projectAgent(row, gh)));
|
|
142
164
|
const attention = projected.map((p) => p.attention).filter((item) => item !== undefined);
|
|
143
|
-
return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: projected.map((p) => p.agent), attention })];
|
|
165
|
+
return [state.emit({ type: 'reset', capturedAt: event.capturedAt, scope: event.scope, agents: projected.map((p) => p.agent), attention, tools, setup })];
|
|
144
166
|
}
|
|
145
167
|
if (event.type === 'upsert') {
|
|
146
168
|
const { agent, attention } = await projectAgent(event.row, gh);
|
|
@@ -198,6 +220,23 @@ export async function watchLocalFeed(options) {
|
|
|
198
220
|
// dropping it from a window the caller believes was covered.
|
|
199
221
|
const activity = new ActivityStream();
|
|
200
222
|
let activityCursor = Date.now();
|
|
223
|
+
// The tool rows as this machine last projected them. Held here rather than
|
|
224
|
+
// re-collected per reset: a session-watch reconnect emits a fresh reset, and
|
|
225
|
+
// re-reading the browser tree and the ledger for it would reintroduce exactly
|
|
226
|
+
// the per-render cost `tool-activity.ts` exists to remove.
|
|
227
|
+
let toolRows = collectToolRows(options.scope, options.tools?.sources).rows;
|
|
228
|
+
// Setup rows come from the setup teammate's cache, never from a probe here:
|
|
229
|
+
// `getCachedToolSetup` reads metadata plus the last EXPLICIT health check, so
|
|
230
|
+
// publishing it costs no subprocess and cannot unlock a secret store.
|
|
231
|
+
const readSetup = options.setup?.read ?? getCachedToolSetup;
|
|
232
|
+
let setupRows = (() => {
|
|
233
|
+
try {
|
|
234
|
+
return readSetup();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
201
240
|
const agents = new Map();
|
|
202
241
|
const attention = new Map();
|
|
203
242
|
// The PR status last projected onto each row, so a merge or a check verdict
|
|
@@ -245,7 +284,30 @@ export async function watchLocalFeed(options) {
|
|
|
245
284
|
await reconcileRows();
|
|
246
285
|
});
|
|
247
286
|
}, options.activityPollMs ?? 500);
|
|
248
|
-
const
|
|
287
|
+
const toolWatch = watchToolActivity({
|
|
288
|
+
...options.tools, scope: options.scope, signal: options.signal, initial: toolRows,
|
|
289
|
+
onDiff: (diff) => {
|
|
290
|
+
for (const tool of diff.upserts) {
|
|
291
|
+
toolRows = [...toolRows.filter((row) => row.rowKey !== tool.rowKey), tool];
|
|
292
|
+
options.emit(state.emit({ type: 'tool.upsert', scope: options.scope, rowKey: tool.rowKey, tool }));
|
|
293
|
+
}
|
|
294
|
+
for (const rowKey of diff.removes) {
|
|
295
|
+
toolRows = toolRows.filter((row) => row.rowKey !== rowKey);
|
|
296
|
+
options.emit(state.emit({ type: 'tool.remove', scope: options.scope, rowKey }));
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
// The setup cache is file-backed and notifies on change, so a Settings pane
|
|
301
|
+
// costs no polling: `subscribeToolSetup` is the invalidation the setup side
|
|
302
|
+
// publishes for exactly this consumer.
|
|
303
|
+
const stopSetupWatch = (options.setup?.subscribe ?? subscribeToolSetup)((rows) => {
|
|
304
|
+
const next = JSON.stringify(rows);
|
|
305
|
+
if (next === JSON.stringify(setupRows))
|
|
306
|
+
return;
|
|
307
|
+
setupRows = rows;
|
|
308
|
+
options.emit(state.emit({ type: 'setup.snapshot', scope: options.scope, capturedAt: Date.now(), setup: rows }));
|
|
309
|
+
});
|
|
310
|
+
const stopActivity = () => { clearInterval(activityTimer); stopAttentionWatch(); activity.close(); toolWatch.stop(); stopSetupWatch(); };
|
|
249
311
|
options.signal.addEventListener('abort', stopActivity, { once: true });
|
|
250
312
|
try {
|
|
251
313
|
await watchLocalSessions({ ...options.sessions, scope: options.scope, signal: options.signal, emit: (event) => {
|
|
@@ -261,7 +323,7 @@ export async function watchLocalFeed(options) {
|
|
|
261
323
|
attention.delete(event.rowKey);
|
|
262
324
|
prStatus.delete(event.rowKey);
|
|
263
325
|
}
|
|
264
|
-
pending = pending.then(() => projectSessionEnvelope(event, state, options.gh)).then((events) => {
|
|
326
|
+
pending = pending.then(() => projectSessionEnvelope(event, state, options.gh, toolRows, setupRows)).then((events) => {
|
|
265
327
|
for (const projected of events) {
|
|
266
328
|
if (projected.type === 'reset') {
|
|
267
329
|
attention.clear();
|
|
@@ -301,6 +363,75 @@ function remoteFeedWatchCommand(os) {
|
|
|
301
363
|
? buildWindowsAgentsCommand({ args })
|
|
302
364
|
: `bash -lc ${shellQuote(`agents ${args.map(shellQuote).join(' ')}`)}`;
|
|
303
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* The one ingress for an envelope produced by ANOTHER agents-cli.
|
|
368
|
+
*
|
|
369
|
+
* `tools` was added to the `reset` payload within protocol v1, so a peer on an
|
|
370
|
+
* older CLI is a correct v1 producer that simply reports no tool rows — which is
|
|
371
|
+
* true of it, not a data defect to paper over. Normalizing HERE, at the single
|
|
372
|
+
* place a foreign envelope enters, is what keeps every consumer downstream able
|
|
373
|
+
* to treat the field as present instead of each re-checking it. A mixed-version
|
|
374
|
+
* fleet is the normal state during a rollout, so an absent field must not take
|
|
375
|
+
* the whole fan-out down.
|
|
376
|
+
*/
|
|
377
|
+
export function normalizePeerEnvelope(event) {
|
|
378
|
+
if (event.type !== 'reset')
|
|
379
|
+
return event;
|
|
380
|
+
if (Array.isArray(event.tools) && Array.isArray(event.setup))
|
|
381
|
+
return event;
|
|
382
|
+
return { ...event, tools: event.tools ?? [], setup: event.setup ?? [] };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The process-wide local collector, shared by refcount.
|
|
386
|
+
*
|
|
387
|
+
* `watchLocalFeed` is cheap per tick but NOT free to duplicate: each call builds
|
|
388
|
+
* its own `ActivityStream` cursor set over the activity directory, its own
|
|
389
|
+
* recursive tool watchers, its own feed/resolution watchers and its own
|
|
390
|
+
* setup subscription. Two callers in one process paid all of that twice and
|
|
391
|
+
* produced identical envelopes — and there are genuinely two callers on a daemon
|
|
392
|
+
* box: the fleet fan-out (which watches the local scope alongside every peer)
|
|
393
|
+
* and anything else that wants this machine's rows.
|
|
394
|
+
*
|
|
395
|
+
* One {@link FeedHub} over `watchLocalFeed` collapses them: the watcher starts on
|
|
396
|
+
* the first subscriber, stops on the last, and a late subscriber is caught up
|
|
397
|
+
* from held state instead of constructing a second cursor set.
|
|
398
|
+
*
|
|
399
|
+
* This is deliberately PROCESS-wide and not machine-wide. Making `feed watch
|
|
400
|
+
* --json --local` attach to the daemon instead would be the machine-wide
|
|
401
|
+
* version, and it would put a daemon dependency on the one command the fleet
|
|
402
|
+
* fan-out runs over ssh on every peer — a peer with a sick daemon would drop off
|
|
403
|
+
* the fleet stream entirely rather than degrade. The cross-process sharing that
|
|
404
|
+
* matters is the FLEET stream, which is what the daemon hub serves.
|
|
405
|
+
*/
|
|
406
|
+
let sharedLocal = null;
|
|
407
|
+
/** The shared local collector, constructed on first use. Exposed for tests. */
|
|
408
|
+
export function sharedLocalFeedHub() {
|
|
409
|
+
return sharedLocal ??= new FeedHub({
|
|
410
|
+
watch: (hubOptions) => watchLocalFeed({
|
|
411
|
+
scope: machineId(), signal: hubOptions.signal, emit: hubOptions.emit,
|
|
412
|
+
}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/** Reset the shared collector. Tests only — it must not leak across cases. */
|
|
416
|
+
export async function resetSharedLocalFeedHub() {
|
|
417
|
+
const hub = sharedLocal;
|
|
418
|
+
sharedLocal = null;
|
|
419
|
+
await hub?.close();
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Subscribe to this machine's rows through the shared collector, until abort.
|
|
423
|
+
*/
|
|
424
|
+
export function subscribeSharedLocalFeed(options) {
|
|
425
|
+
const detach = sharedLocalFeedHub().subscribe(options.emit);
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
if (options.signal.aborted) {
|
|
428
|
+
detach();
|
|
429
|
+
resolve();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
options.signal.addEventListener('abort', () => { detach(); resolve(); }, { once: true });
|
|
433
|
+
});
|
|
434
|
+
}
|
|
304
435
|
export async function watchFleetFeed(options) {
|
|
305
436
|
const coordinator = new FeedWatchState();
|
|
306
437
|
const projection = new FeedSessionProjection(coordinator);
|
|
@@ -308,7 +439,7 @@ export async function watchFleetFeed(options) {
|
|
|
308
439
|
for (const projected of projection.apply(event))
|
|
309
440
|
options.emit(projected);
|
|
310
441
|
};
|
|
311
|
-
const local =
|
|
442
|
+
const local = subscribeSharedLocalFeed({ signal: options.signal, emit: forward });
|
|
312
443
|
let devices;
|
|
313
444
|
try {
|
|
314
445
|
devices = await loadDevices();
|
|
@@ -331,7 +462,7 @@ export async function watchFleetFeed(options) {
|
|
|
331
462
|
const event = JSON.parse(line);
|
|
332
463
|
if (event.v !== 1)
|
|
333
464
|
return false;
|
|
334
|
-
forward(event);
|
|
465
|
+
forward(normalizePeerEnvelope(event));
|
|
335
466
|
return true;
|
|
336
467
|
}
|
|
337
468
|
catch {
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export declare function relTime(iso: string): string;
|
|
|
61
61
|
export declare function humanDuration(ms: number): string;
|
|
62
62
|
/**
|
|
63
63
|
* Human-readable byte size. Previously copy-pasted into five files
|
|
64
|
-
* (`commands/prune.ts`, `commands/
|
|
64
|
+
* (`commands/prune.ts`, `commands/inspect.ts`,
|
|
65
65
|
* `commands/sessions.ts`, `lib/browser/sessions-list.ts`) — this is the
|
|
66
66
|
* canonical home.
|
|
67
67
|
*/
|
package/dist/lib/format.js
CHANGED
|
@@ -115,7 +115,7 @@ export function humanDuration(ms) {
|
|
|
115
115
|
}
|
|
116
116
|
/**
|
|
117
117
|
* Human-readable byte size. Previously copy-pasted into five files
|
|
118
|
-
* (`commands/prune.ts`, `commands/
|
|
118
|
+
* (`commands/prune.ts`, `commands/inspect.ts`,
|
|
119
119
|
* `commands/sessions.ts`, `lib/browser/sessions-list.ts`) — this is the
|
|
120
120
|
* canonical home.
|
|
121
121
|
*/
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -106,22 +106,6 @@ export declare function readRepoState(repoPath: string): RepoStateSnapshot | nul
|
|
|
106
106
|
export declare function resolveSnapshotSha(repoRoot: string): string | undefined;
|
|
107
107
|
/** Test seam: clear the memoized snapshot-sha cache between test cases. */
|
|
108
108
|
export declare function _resetSnapshotShaCacheForTest(): void;
|
|
109
|
-
/**
|
|
110
|
-
* Resolve the GitHub username synchronously for `agents artifacts share`. Order:
|
|
111
|
-
* 1. `AGENTS_SHARE_GITHUB_USER` env override
|
|
112
|
-
* 2. `git config --global github.user`
|
|
113
|
-
* Falls back to null so callers can decide whether to require auth or proceed
|
|
114
|
-
* without a namespace.
|
|
115
|
-
*/
|
|
116
|
-
export declare function resolveGitHubUsernameSync(): string | null;
|
|
117
|
-
/**
|
|
118
|
-
* Resolve the GitHub username asynchronously. Order:
|
|
119
|
-
* 1. `AGENTS_SHARE_GITHUB_USER` env override
|
|
120
|
-
* 2. `gh api user --jq ".login"`
|
|
121
|
-
* 3. `git config --global github.user`
|
|
122
|
-
* Returns null if none succeed.
|
|
123
|
-
*/
|
|
124
|
-
export declare function resolveGitHubUsername(): Promise<string | null>;
|
|
125
109
|
/**
|
|
126
110
|
* Get the remote URL for origin in a git repo.
|
|
127
111
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -389,64 +389,6 @@ export function resolveSnapshotSha(repoRoot) {
|
|
|
389
389
|
export function _resetSnapshotShaCacheForTest() {
|
|
390
390
|
_snapshotShaCache.clear();
|
|
391
391
|
}
|
|
392
|
-
/**
|
|
393
|
-
* Get the current GitHub username using gh CLI.
|
|
394
|
-
* Returns null if gh is not installed or user is not authenticated.
|
|
395
|
-
*/
|
|
396
|
-
async function getGitHubUsername() {
|
|
397
|
-
try {
|
|
398
|
-
const { exec } = await import('child_process');
|
|
399
|
-
const { promisify } = await import('util');
|
|
400
|
-
const execAsync = promisify(exec);
|
|
401
|
-
const { stdout } = await execAsync('gh api user --jq ".login"');
|
|
402
|
-
return stdout.trim() || null;
|
|
403
|
-
}
|
|
404
|
-
catch {
|
|
405
|
-
/* gh CLI not installed or not authenticated */
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
const GITHUB_USER_ENV = 'AGENTS_SHARE_GITHUB_USER';
|
|
410
|
-
/** Best-effort sync read of `github.user` from git config. */
|
|
411
|
-
function readGitConfigUser() {
|
|
412
|
-
try {
|
|
413
|
-
return execFileSync('git', ['config', '--global', 'github.user'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
414
|
-
.toString()
|
|
415
|
-
.trim() || null;
|
|
416
|
-
}
|
|
417
|
-
catch {
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Resolve the GitHub username synchronously for `agents artifacts share`. Order:
|
|
423
|
-
* 1. `AGENTS_SHARE_GITHUB_USER` env override
|
|
424
|
-
* 2. `git config --global github.user`
|
|
425
|
-
* Falls back to null so callers can decide whether to require auth or proceed
|
|
426
|
-
* without a namespace.
|
|
427
|
-
*/
|
|
428
|
-
export function resolveGitHubUsernameSync() {
|
|
429
|
-
const env = process.env[GITHUB_USER_ENV]?.trim();
|
|
430
|
-
if (env)
|
|
431
|
-
return env;
|
|
432
|
-
return readGitConfigUser();
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Resolve the GitHub username asynchronously. Order:
|
|
436
|
-
* 1. `AGENTS_SHARE_GITHUB_USER` env override
|
|
437
|
-
* 2. `gh api user --jq ".login"`
|
|
438
|
-
* 3. `git config --global github.user`
|
|
439
|
-
* Returns null if none succeed.
|
|
440
|
-
*/
|
|
441
|
-
export async function resolveGitHubUsername() {
|
|
442
|
-
const env = process.env[GITHUB_USER_ENV]?.trim();
|
|
443
|
-
if (env)
|
|
444
|
-
return env;
|
|
445
|
-
const gh = await getGitHubUsername();
|
|
446
|
-
if (gh)
|
|
447
|
-
return gh;
|
|
448
|
-
return readGitConfigUser();
|
|
449
|
-
}
|
|
450
392
|
/**
|
|
451
393
|
* Get the remote URL for origin in a git repo.
|
|
452
394
|
*/
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* it would claim a distribution responsibility agents-cli no longer has.
|
|
42
42
|
*/
|
|
43
43
|
export const HELPER_RELEASES = {
|
|
44
|
-
menubar: { tagPrefix: 'menubar', floor: '1.
|
|
44
|
+
menubar: { tagPrefix: 'menubar', floor: '1.5.0' },
|
|
45
45
|
};
|
|
46
46
|
/** The release tag for one helper at one version, e.g. `menubar/v1.0.0`. */
|
|
47
47
|
export function helperTag(helper, version) {
|
|
@@ -158,12 +158,62 @@ export declare const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'Silen
|
|
|
158
158
|
* every remote-JSON boundary regardless of the peer's OS.
|
|
159
159
|
*/
|
|
160
160
|
export declare function stripClixml(stdout: string): string;
|
|
161
|
+
/**
|
|
162
|
+
* Statements that run the Agents CLI on a Windows peer with EXACT argv, leaving the
|
|
163
|
+
* child's exit code in `$zq`.
|
|
164
|
+
*
|
|
165
|
+
* `& agents …` cannot be used, and the reason is not our quoting. On Windows
|
|
166
|
+
* `agents` is an npm-generated `agents.ps1`, whose body ends in
|
|
167
|
+
*
|
|
168
|
+
* & "node$exe" --no-warnings=… "$basedir/node_modules/@phnx-labs/agents-cli/dist/index.js" $args
|
|
169
|
+
*
|
|
170
|
+
* — `$args` splatted into a NATIVE program, which is the PowerShell 5.1 lossy
|
|
171
|
+
* serializer. So the loss happens INSIDE the user's shim, after our tokens were
|
|
172
|
+
* already correct: measured on a real peer, `no-such-"menu"-proof` reached the
|
|
173
|
+
* Agents parser as `no-such-menu-proof`. Quoting harder upstream cannot fix that,
|
|
174
|
+
* and rewriting a user's npm shim is not ours to do.
|
|
175
|
+
*
|
|
176
|
+
* Instead the shim is bypassed the same way `getCliLaunch` (`lib/cli-entry.ts`)
|
|
177
|
+
* resolves a launch locally: a node-script entry becomes `<node> <entry> …args`.
|
|
178
|
+
* The entry and runtime are resolved ON THE PEER from the launcher's own location —
|
|
179
|
+
* the identical two-step the shim itself performs — and executed through .NET with
|
|
180
|
+
* a `CommandLineToArgvW`-escaped argument string.
|
|
181
|
+
*
|
|
182
|
+
* A peer whose `agents` is already a native executable is used directly. A peer
|
|
183
|
+
* where neither resolves FAILS LOUD: falling back to `& agents` would silently
|
|
184
|
+
* restore the argument loss, which is worse than an error naming what is missing.
|
|
185
|
+
*
|
|
186
|
+
* The entry comes from the package's DECLARED `bin` rather than a literal
|
|
187
|
+
* `dist/index.js`, so it follows the package instead of needing to be kept in sync
|
|
188
|
+
* with it.
|
|
189
|
+
*/
|
|
190
|
+
export declare function windowsAgentsInvocation(args: string[], binName?: 'agents' | 'ag'): string;
|
|
161
191
|
export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
|
|
162
192
|
/**
|
|
163
193
|
* Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
|
|
164
|
-
* Windows remote: a `powershell -NoProfile
|
|
194
|
+
* Windows remote: a rendered `powershell -NoProfile` call. The
|
|
165
195
|
* Windows counterpart of `bash -lc '<...>'`, shared by every `--device` site.
|
|
166
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* Render a PowerShell script as the single command string ssh sends to a Windows
|
|
199
|
+
* peer, compressing it when that is genuinely shorter.
|
|
200
|
+
*
|
|
201
|
+
* The plain route is `-EncodedCommand <base64 of UTF-16LE>`, which inflates the
|
|
202
|
+
* script by ~2.67x. That matters because OpenSSH-for-Windows caps the whole remote
|
|
203
|
+
* command far below cmd.exe's 8191 — bisected against a live peer, 2934 characters
|
|
204
|
+
* succeed and 3102 fail — so a longer script eats the headroom a caller's
|
|
205
|
+
* arguments, `--remote-cwd` and forwarded env need.
|
|
206
|
+
*
|
|
207
|
+
* The alternative is to deflate the UTF-8 script and emit a small fixed bootstrap
|
|
208
|
+
* that inflates it back. That trades ~2.67x for ~1.33x on the compressible part,
|
|
209
|
+
* which on scripts of this shape is a large net win. The variable-free bootstrap
|
|
210
|
+
* uses `-Command` with a base64 payload and leaves stdin available to the child.
|
|
211
|
+
*
|
|
212
|
+
* Applied ONLY when the result is actually shorter: for a small script the fixed
|
|
213
|
+
* bootstrap costs more than it saves, so picking the shorter of the two keeps the
|
|
214
|
+
* plain form in play for every small caller and is obviously correct either way.
|
|
215
|
+
*/
|
|
216
|
+
export declare function renderPowershellCommand(script: string): string;
|
|
167
217
|
export declare function buildWindowsAgentsCommand(cmd: WindowsAgentsCommand): string;
|
|
168
218
|
/**
|
|
169
219
|
* Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
|
|
9
9
|
*/
|
|
10
10
|
import { shellQuote } from '../ssh-exec.js';
|
|
11
|
+
import { pwshLiteral, pwshNativeExecStatements } from '../pwsh.js';
|
|
12
|
+
import { quoteWin32ExecArg } from '../platform/exec.js';
|
|
13
|
+
import * as zlib from 'node:zlib';
|
|
11
14
|
/**
|
|
12
15
|
* Remove routing flags (and their values) from a command's args, leaving the
|
|
13
16
|
* rest untouched and in order so they forward verbatim to the remote binary.
|
|
@@ -136,8 +139,6 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
136
139
|
// On a --device dispatch the remote box runs its own run-exit sync, so this
|
|
137
140
|
// flag is never forwarded — it is a local-exit-behavior toggle, not remote.
|
|
138
141
|
traceSync: 'local-only',
|
|
139
|
-
// Deprecated alias for --device auto; resolved on the launching box before SSH.
|
|
140
|
-
smart: 'local-only',
|
|
141
142
|
// Broadcast mode (agents run --broadcast) is its own fan-out dispatch — mutually
|
|
142
143
|
// exclusive with --host. All broadcast options are local-only; exec.ts handles them
|
|
143
144
|
// before any SSH dispatch path is reached.
|
|
@@ -293,30 +294,146 @@ export function stripClixml(stdout) {
|
|
|
293
294
|
.replace(/#< CLIXML[^\n]*\r?\n?(?:\s*<Objs\b[\s\S]*?<\/Objs>)*/g, '')
|
|
294
295
|
.trim();
|
|
295
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Statements that run the Agents CLI on a Windows peer with EXACT argv, leaving the
|
|
299
|
+
* child's exit code in `$zq`.
|
|
300
|
+
*
|
|
301
|
+
* `& agents …` cannot be used, and the reason is not our quoting. On Windows
|
|
302
|
+
* `agents` is an npm-generated `agents.ps1`, whose body ends in
|
|
303
|
+
*
|
|
304
|
+
* & "node$exe" --no-warnings=… "$basedir/node_modules/@phnx-labs/agents-cli/dist/index.js" $args
|
|
305
|
+
*
|
|
306
|
+
* — `$args` splatted into a NATIVE program, which is the PowerShell 5.1 lossy
|
|
307
|
+
* serializer. So the loss happens INSIDE the user's shim, after our tokens were
|
|
308
|
+
* already correct: measured on a real peer, `no-such-"menu"-proof` reached the
|
|
309
|
+
* Agents parser as `no-such-menu-proof`. Quoting harder upstream cannot fix that,
|
|
310
|
+
* and rewriting a user's npm shim is not ours to do.
|
|
311
|
+
*
|
|
312
|
+
* Instead the shim is bypassed the same way `getCliLaunch` (`lib/cli-entry.ts`)
|
|
313
|
+
* resolves a launch locally: a node-script entry becomes `<node> <entry> …args`.
|
|
314
|
+
* The entry and runtime are resolved ON THE PEER from the launcher's own location —
|
|
315
|
+
* the identical two-step the shim itself performs — and executed through .NET with
|
|
316
|
+
* a `CommandLineToArgvW`-escaped argument string.
|
|
317
|
+
*
|
|
318
|
+
* A peer whose `agents` is already a native executable is used directly. A peer
|
|
319
|
+
* where neither resolves FAILS LOUD: falling back to `& agents` would silently
|
|
320
|
+
* restore the argument loss, which is worse than an error naming what is missing.
|
|
321
|
+
*
|
|
322
|
+
* The entry comes from the package's DECLARED `bin` rather than a literal
|
|
323
|
+
* `dist/index.js`, so it follows the package instead of needing to be kept in sync
|
|
324
|
+
* with it.
|
|
325
|
+
*/
|
|
326
|
+
export function windowsAgentsInvocation(args, binName = 'agents') {
|
|
327
|
+
const escaped = pwshLiteral(args.map(quoteWin32ExecArg).join(' '));
|
|
328
|
+
return [
|
|
329
|
+
// Set before any resolution: a non-terminating failure below would otherwise
|
|
330
|
+
// leave the exit code null, and `exit $null` reports 0.
|
|
331
|
+
`$ErrorActionPreference = 'Stop'`,
|
|
332
|
+
`$zc = Get-Command ${powershellQuote(binName)} -ErrorAction Stop`,
|
|
333
|
+
`$ze = $zc.Source`,
|
|
334
|
+
`$zr = ''`,
|
|
335
|
+
// A real native executable needs no interpreter prefix. `CommandType` alone is
|
|
336
|
+
// NOT that test: `Get-Command` reports `Application` for `agents.cmd` too, and
|
|
337
|
+
// a .cmd launcher re-parses through cmd.exe with the same argument loss as the
|
|
338
|
+
// .ps1 one — so a cmd-only install must not bypass this either.
|
|
339
|
+
`if ($zc.CommandType -ne 'Application' -or $ze -match '\\.(cmd|bat)$') {`,
|
|
340
|
+
` $zb = Split-Path $ze`,
|
|
341
|
+
` $zk = Join-Path $zb 'node_modules\\@phnx-labs\\agents-cli'`,
|
|
342
|
+
// The package's DECLARED bin, so the entry follows the package rather than a
|
|
343
|
+
// hand-synced `dist/index.js` literal that would rot on upgrade. One check
|
|
344
|
+
// covers a missing bin AND the single-string `bin` form (which yields $null).
|
|
345
|
+
` $zl = (Get-Content -Raw (Join-Path $zk 'package.json') | ConvertFrom-Json).bin.${binName}`,
|
|
346
|
+
` if (-not $zl) { throw "agents package at $zk declares no bin.${binName}" }`,
|
|
347
|
+
` $zt = Join-Path $zk $zl`,
|
|
348
|
+
` if (-not [IO.File]::Exists($zt)) { throw "agents CLI entry not found at $zt" }`,
|
|
349
|
+
// The npm shim prefers a node.exe beside itself before PATH; match that.
|
|
350
|
+
` $zd = Join-Path $zb 'node.exe'`,
|
|
351
|
+
` $ze = if ([IO.File]::Exists($zd)) { $zd } else { 'node' }`,
|
|
352
|
+
// A Windows path cannot contain `"`, so wrapping is sufficient escaping here.
|
|
353
|
+
` $zr = '--no-warnings=ExperimentalWarning "' + $zt + '" '`,
|
|
354
|
+
`}`,
|
|
355
|
+
...pwshNativeExecStatements('$ze', `$zr + ${escaped}`),
|
|
356
|
+
// Reported through a plain variable rather than by assigning the automatic
|
|
357
|
+
// $LASTEXITCODE, which nothing set here since no PowerShell command ran.
|
|
358
|
+
`$zq = $zp.ExitCode`,
|
|
359
|
+
// Never let an unknown outcome read as success.
|
|
360
|
+
`if ($null -eq $zq) { $zq = 1 }`,
|
|
361
|
+
// NEWLINE-joined, and that is required rather than stylistic: the caller joins
|
|
362
|
+
// its parts with `'; '`, which between a closing `}` and `else`/an indented block
|
|
363
|
+
// would produce invalid PowerShell.
|
|
364
|
+
].join('\n');
|
|
365
|
+
}
|
|
296
366
|
export function windowsAgentsScript(cmd) {
|
|
297
367
|
const { args, env, cwd, propagateExit = true, remapExit255 = false } = cmd;
|
|
298
|
-
|
|
368
|
+
// `Stop` FIRST, before the env assignments and the Set-Location: a failing
|
|
369
|
+
// `Set-Location` (a cwd that does not exist on the peer) must abort rather than
|
|
370
|
+
// continue into the launcher and run the command in the wrong directory.
|
|
371
|
+
const parts = [POWERSHELL_PROGRESS_SILENCE, `$ErrorActionPreference = 'Stop'`];
|
|
299
372
|
if (env)
|
|
300
373
|
for (const [k, v] of Object.entries(env))
|
|
301
374
|
parts.push(`$env:${k} = ${powershellQuote(v)}`);
|
|
302
375
|
if (cwd)
|
|
303
376
|
parts.push(`Set-Location -LiteralPath ${powershellQuote(cwd)}`);
|
|
304
|
-
parts.push(
|
|
377
|
+
parts.push(windowsAgentsInvocation(args));
|
|
305
378
|
if (propagateExit) {
|
|
306
379
|
if (remapExit255)
|
|
307
|
-
parts.push('
|
|
380
|
+
parts.push('if ($zq -eq 255) { exit 254 }', 'exit $zq');
|
|
308
381
|
else
|
|
309
|
-
parts.push('exit $
|
|
382
|
+
parts.push('exit $zq');
|
|
310
383
|
}
|
|
311
384
|
return parts.join('; ');
|
|
312
385
|
}
|
|
313
386
|
/**
|
|
314
387
|
* Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
|
|
315
|
-
* Windows remote: a `powershell -NoProfile
|
|
388
|
+
* Windows remote: a rendered `powershell -NoProfile` call. The
|
|
316
389
|
* Windows counterpart of `bash -lc '<...>'`, shared by every `--device` site.
|
|
317
390
|
*/
|
|
391
|
+
/**
|
|
392
|
+
* Render a PowerShell script as the single command string ssh sends to a Windows
|
|
393
|
+
* peer, compressing it when that is genuinely shorter.
|
|
394
|
+
*
|
|
395
|
+
* The plain route is `-EncodedCommand <base64 of UTF-16LE>`, which inflates the
|
|
396
|
+
* script by ~2.67x. That matters because OpenSSH-for-Windows caps the whole remote
|
|
397
|
+
* command far below cmd.exe's 8191 — bisected against a live peer, 2934 characters
|
|
398
|
+
* succeed and 3102 fail — so a longer script eats the headroom a caller's
|
|
399
|
+
* arguments, `--remote-cwd` and forwarded env need.
|
|
400
|
+
*
|
|
401
|
+
* The alternative is to deflate the UTF-8 script and emit a small fixed bootstrap
|
|
402
|
+
* that inflates it back. That trades ~2.67x for ~1.33x on the compressible part,
|
|
403
|
+
* which on scripts of this shape is a large net win. The variable-free bootstrap
|
|
404
|
+
* uses `-Command` with a base64 payload and leaves stdin available to the child.
|
|
405
|
+
*
|
|
406
|
+
* Applied ONLY when the result is actually shorter: for a small script the fixed
|
|
407
|
+
* bootstrap costs more than it saves, so picking the shorter of the two keeps the
|
|
408
|
+
* plain form in play for every small caller and is obviously correct either way.
|
|
409
|
+
*/
|
|
410
|
+
export function renderPowershellCommand(script) {
|
|
411
|
+
const plain = `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
412
|
+
// Raw DEFLATE (RFC 1951) — what .NET's `DeflateStream` reads. `deflateSync`
|
|
413
|
+
// would prepend a zlib header that `DeflateStream` rejects.
|
|
414
|
+
const packed = zlib.deflateRawSync(Buffer.from(script, 'utf-8'), { level: 9 }).toString('base64');
|
|
415
|
+
// The blob is embedded DIRECTLY in a `-Command` string rather than wrapped in a
|
|
416
|
+
// second `-EncodedCommand`: routing an already-base64 payload through the encoded
|
|
417
|
+
// form would inflate it by another 2.67x and cancel most of the compression.
|
|
418
|
+
//
|
|
419
|
+
// The bootstrap is deliberately VARIABLE-FREE — one nested expression, no `$`
|
|
420
|
+
// anywhere. A `$b = …` form is unsafe here: OpenSSH-for-Windows may have
|
|
421
|
+
// PowerShell as its `DefaultShell`, in which case the outer double-quoted string
|
|
422
|
+
// is parsed by PowerShell first and `$b` would be expanded before our script ever
|
|
423
|
+
// runs. With no `$`, no `%`, no backtick and no cmd metacharacter — base64's
|
|
424
|
+
// alphabet is `A-Za-z0-9+/=` and the rest is ASCII punctuation neither shell
|
|
425
|
+
// touches inside quotes — the same text survives either default shell. stdin is
|
|
426
|
+
// untouched by both routes.
|
|
427
|
+
const bootstrap = 'iex ([IO.StreamReader]::new([IO.Compression.DeflateStream]::new('
|
|
428
|
+
+ `[IO.MemoryStream]::new([Convert]::FromBase64String('${packed}')),`
|
|
429
|
+
+ '[IO.Compression.CompressionMode]::Decompress),[Text.Encoding]::UTF8).ReadToEnd())';
|
|
430
|
+
const compressed = `powershell -NoProfile -Command "${bootstrap}"`;
|
|
431
|
+
// Only when genuinely shorter: for a small script the fixed bootstrap costs more
|
|
432
|
+
// than it saves, so this keeps the plain form in play for every small caller.
|
|
433
|
+
return compressed.length < plain.length ? compressed : plain;
|
|
434
|
+
}
|
|
318
435
|
export function buildWindowsAgentsCommand(cmd) {
|
|
319
|
-
return
|
|
436
|
+
return renderPowershellCommand(windowsAgentsScript(cmd));
|
|
320
437
|
}
|
|
321
438
|
/**
|
|
322
439
|
* Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only decoder for a rendered remote PowerShell command.
|
|
3
|
+
*
|
|
4
|
+
* `renderPowershellCommand` emits whichever of two representations is shorter: the
|
|
5
|
+
* plain `-EncodedCommand <base64 UTF-16LE>`, or a `-Command` bootstrap carrying a
|
|
6
|
+
* deflated UTF-8 payload. Tests assert on the SCRIPT, not the representation, so
|
|
7
|
+
* every suite needs both — and four copies of that parsing is exactly how one of
|
|
8
|
+
* them silently stops decoding the form it was meant to check.
|
|
9
|
+
*
|
|
10
|
+
* Named `*.test-fixture.ts` after the existing `daemon.test-fixture.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import * as zlib from 'node:zlib';
|
|
13
|
+
/** The script behind either render route. Throws if the command is neither. */
|
|
14
|
+
export function decodeRenderedPowershell(command) {
|
|
15
|
+
const encoded = /-EncodedCommand (\S+)\s*$/.exec(command);
|
|
16
|
+
if (encoded)
|
|
17
|
+
return Buffer.from(encoded[1], 'base64').toString('utf16le');
|
|
18
|
+
const packed = /FromBase64String\('([A-Za-z0-9+/=]+)'\)/.exec(command);
|
|
19
|
+
if (!packed)
|
|
20
|
+
throw new Error(`not a rendered PowerShell command: ${command}`);
|
|
21
|
+
return zlib.inflateRawSync(Buffer.from(packed[1], 'base64')).toString('utf-8');
|
|
22
|
+
}
|