@phnx-labs/agents-cli 1.20.65 → 1.20.66
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 +12 -0
- package/README.md +119 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.d.ts +48 -0
- package/dist/commands/exec.js +65 -0
- package/dist/commands/monitors.js +8 -0
- package/dist/commands/plugins.js +28 -7
- package/dist/commands/sessions-browser.d.ts +82 -0
- package/dist/commands/sessions-browser.js +320 -0
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +121 -4
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +150 -0
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +28 -0
- package/dist/lib/agents.js +68 -0
- package/dist/lib/devices/connect.d.ts +18 -1
- package/dist/lib/devices/connect.js +10 -2
- package/dist/lib/devices/known-hosts.d.ts +62 -0
- package/dist/lib/devices/known-hosts.js +137 -0
- package/dist/lib/hosts/dispatch.js +20 -2
- package/dist/lib/hosts/remote-cmd.js +8 -4
- package/dist/lib/monitors/engine.js +4 -0
- package/dist/lib/monitors/sources/device.js +13 -3
- package/dist/lib/picker.d.ts +53 -0
- package/dist/lib/picker.js +214 -1
- package/dist/lib/plugins.d.ts +31 -1
- package/dist/lib/plugins.js +74 -13
- package/dist/lib/secrets/agent.d.ts +35 -0
- package/dist/lib/secrets/agent.js +114 -55
- package/dist/lib/secrets/filestore.d.ts +9 -0
- package/dist/lib/secrets/filestore.js +21 -8
- package/dist/lib/secrets/index.d.ts +7 -0
- package/dist/lib/secrets/index.js +26 -6
- package/dist/lib/share/capture.d.ts +29 -0
- package/dist/lib/share/capture.js +140 -0
- package/dist/lib/share/config.d.ts +35 -0
- package/dist/lib/share/config.js +100 -0
- package/dist/lib/share/og.d.ts +25 -0
- package/dist/lib/share/og.js +84 -0
- package/dist/lib/share/provision.d.ts +10 -0
- package/dist/lib/share/provision.js +91 -0
- package/dist/lib/share/publish.d.ts +57 -0
- package/dist/lib/share/publish.js +145 -0
- package/dist/lib/share/worker-template.d.ts +2 -0
- package/dist/lib/share/worker-template.js +82 -0
- package/dist/lib/shims.d.ts +13 -0
- package/dist/lib/shims.js +42 -2
- package/dist/lib/ssh-exec.d.ts +24 -0
- package/dist/lib/ssh-exec.js +15 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -27,6 +27,7 @@ import * as net from 'net';
|
|
|
27
27
|
import * as fs from 'fs';
|
|
28
28
|
import * as os from 'os';
|
|
29
29
|
import * as path from 'path';
|
|
30
|
+
import { randomBytes } from 'crypto';
|
|
30
31
|
import { spawn, spawnSync, execFileSync } from 'child_process';
|
|
31
32
|
import { getHelpersDir, readMeta } from '../state.js';
|
|
32
33
|
import { isAlive } from '../platform/process.js';
|
|
@@ -109,6 +110,42 @@ function socketPath() {
|
|
|
109
110
|
function pidPath() {
|
|
110
111
|
return path.join(agentDir(), 'agent.pid');
|
|
111
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Path of the per-broker capability token. It lives in the 0700 agent dir and is
|
|
115
|
+
* written 0600, so only the same UID that owns the broker can read it — the file
|
|
116
|
+
* permission IS the authorization boundary. See isRequestAuthorized.
|
|
117
|
+
*/
|
|
118
|
+
function tokenPath() {
|
|
119
|
+
return path.join(agentDir(), 'agent.token');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Read the current broker capability token, or null if none is present. Clients
|
|
123
|
+
* read it fresh per request and attach it to every non-ping command; a broker
|
|
124
|
+
* restart mints a new token, so a stale read simply fails authorization and the
|
|
125
|
+
* caller falls back to a direct keychain read (soft, never a hard error).
|
|
126
|
+
*/
|
|
127
|
+
export function readAgentToken() {
|
|
128
|
+
try {
|
|
129
|
+
const t = fs.readFileSync(tokenPath(), 'utf-8').trim();
|
|
130
|
+
return t.length > 0 ? t : null;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Mint + persist a fresh capability token (0600) at socket-bind time. Only the
|
|
137
|
+
* process that actually binds the socket calls this, so a losing starter never
|
|
138
|
+
* clobbers the live owner's token. Returns the token for in-memory comparison. */
|
|
139
|
+
function writeAgentToken() {
|
|
140
|
+
const token = randomBytes(32).toString('hex');
|
|
141
|
+
const fp = tokenPath();
|
|
142
|
+
fs.writeFileSync(fp, token, { mode: 0o600 });
|
|
143
|
+
try {
|
|
144
|
+
fs.chmodSync(fp, 0o600);
|
|
145
|
+
}
|
|
146
|
+
catch { /* dir 0700 already gates it */ }
|
|
147
|
+
return token;
|
|
148
|
+
}
|
|
112
149
|
/**
|
|
113
150
|
* Argv for re-invoking THIS cli with a hidden subcommand, so a side-by-side dev
|
|
114
151
|
* build spawns its own helpers rather than the registry-installed one. We always
|
|
@@ -272,6 +309,60 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
272
309
|
export function shouldWipeOnWatchEvent(chunk) {
|
|
273
310
|
return /\bSLEEP\b/.test(chunk);
|
|
274
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Authorization gate applied to every request BEFORE handleAgentRequest touches
|
|
314
|
+
* the store (RUSH-1760). A bare same-UID socket connection is no longer trusted
|
|
315
|
+
* to load/get arbitrary bundle env: each command except the liveness `ping` must
|
|
316
|
+
* carry the per-broker capability token, which lives in a 0600 file inside the
|
|
317
|
+
* 0700 agent dir and so is readable only by the UID that owns the broker.
|
|
318
|
+
*
|
|
319
|
+
* Fail closed: a missing/empty expected token (no token file) rejects every
|
|
320
|
+
* command but ping. `ping` stays unauthenticated — it exposes only the protocol
|
|
321
|
+
* and cli version, and clients need it to detect a reachable broker before they
|
|
322
|
+
* have any reason to read the token. Pure + exported for direct unit testing.
|
|
323
|
+
*/
|
|
324
|
+
export function isRequestAuthorized(req, expectedToken) {
|
|
325
|
+
if (req.cmd === 'ping')
|
|
326
|
+
return true;
|
|
327
|
+
if (!expectedToken)
|
|
328
|
+
return false;
|
|
329
|
+
return req.token === expectedToken;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Build the socket `connection` handler shared by both brokers (standalone and
|
|
333
|
+
* daemon-hosted): newline-framed JSON in, one response line out, with the
|
|
334
|
+
* authorization gate (isRequestAuthorized) applied before `handle`. `token`
|
|
335
|
+
* resolves the currently-expected capability token per request so a token
|
|
336
|
+
* rotation on broker restart is picked up without rebuilding the handler.
|
|
337
|
+
*/
|
|
338
|
+
export function makeConnectionHandler(handle, token) {
|
|
339
|
+
return (conn) => {
|
|
340
|
+
conn.setEncoding('utf-8');
|
|
341
|
+
let buf = '';
|
|
342
|
+
conn.on('data', (chunk) => {
|
|
343
|
+
buf += chunk;
|
|
344
|
+
let nl;
|
|
345
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
346
|
+
const line = buf.slice(0, nl);
|
|
347
|
+
buf = buf.slice(nl + 1);
|
|
348
|
+
if (!line.trim())
|
|
349
|
+
continue;
|
|
350
|
+
let resp;
|
|
351
|
+
try {
|
|
352
|
+
const req = JSON.parse(line);
|
|
353
|
+
resp = isRequestAuthorized(req, token())
|
|
354
|
+
? handle(req)
|
|
355
|
+
: { ok: false, error: 'unauthorized' };
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
resp = { ok: false, error: err.message };
|
|
359
|
+
}
|
|
360
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
conn.on('error', () => { });
|
|
364
|
+
};
|
|
365
|
+
}
|
|
275
366
|
/**
|
|
276
367
|
* Bind the shared broker socket without stealing it from another live owner.
|
|
277
368
|
* Both the standalone service and daemon-hosted broker use this single path so
|
|
@@ -293,6 +384,13 @@ async function bindBrokerSocket(sock, onConnection) {
|
|
|
293
384
|
fs.chmodSync(sock, 0o600);
|
|
294
385
|
}
|
|
295
386
|
catch { /* dir 0700 already gates it */ }
|
|
387
|
+
// Mint the capability token here — the one moment this process is the
|
|
388
|
+
// confirmed socket owner — so a losing starter never clobbers the live
|
|
389
|
+
// owner's token (RUSH-1760).
|
|
390
|
+
try {
|
|
391
|
+
writeAgentToken();
|
|
392
|
+
}
|
|
393
|
+
catch { /* dir 0700 gates the socket regardless */ }
|
|
296
394
|
resolve(server);
|
|
297
395
|
});
|
|
298
396
|
});
|
|
@@ -428,29 +526,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
428
526
|
emptySince = Date.now();
|
|
429
527
|
return resp;
|
|
430
528
|
};
|
|
431
|
-
const onConnection = (
|
|
432
|
-
conn.setEncoding('utf-8');
|
|
433
|
-
let buf = '';
|
|
434
|
-
conn.on('data', (chunk) => {
|
|
435
|
-
buf += chunk;
|
|
436
|
-
let nl;
|
|
437
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
438
|
-
const line = buf.slice(0, nl);
|
|
439
|
-
buf = buf.slice(nl + 1);
|
|
440
|
-
if (!line.trim())
|
|
441
|
-
continue;
|
|
442
|
-
let resp;
|
|
443
|
-
try {
|
|
444
|
-
resp = handle(JSON.parse(line));
|
|
445
|
-
}
|
|
446
|
-
catch (err) {
|
|
447
|
-
resp = { ok: false, error: err.message };
|
|
448
|
-
}
|
|
449
|
-
conn.write(JSON.stringify(resp) + '\n');
|
|
450
|
-
}
|
|
451
|
-
});
|
|
452
|
-
conn.on('error', () => { });
|
|
453
|
-
};
|
|
529
|
+
const onConnection = makeConnectionHandler(handle, readAgentToken);
|
|
454
530
|
let server = null;
|
|
455
531
|
do {
|
|
456
532
|
try {
|
|
@@ -558,29 +634,7 @@ export async function startHostedBroker() {
|
|
|
558
634
|
const store = new Map();
|
|
559
635
|
const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
|
|
560
636
|
const handle = (req) => handleAgentRequest(store, req);
|
|
561
|
-
const onConn = (
|
|
562
|
-
conn.setEncoding('utf-8');
|
|
563
|
-
let buf = '';
|
|
564
|
-
conn.on('data', (chunk) => {
|
|
565
|
-
buf += chunk;
|
|
566
|
-
let nl;
|
|
567
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
568
|
-
const line = buf.slice(0, nl);
|
|
569
|
-
buf = buf.slice(nl + 1);
|
|
570
|
-
if (!line.trim())
|
|
571
|
-
continue;
|
|
572
|
-
let resp;
|
|
573
|
-
try {
|
|
574
|
-
resp = handle(JSON.parse(line));
|
|
575
|
-
}
|
|
576
|
-
catch (err) {
|
|
577
|
-
resp = { ok: false, error: err.message };
|
|
578
|
-
}
|
|
579
|
-
conn.write(JSON.stringify(resp) + '\n');
|
|
580
|
-
}
|
|
581
|
-
});
|
|
582
|
-
conn.on('error', () => { });
|
|
583
|
-
};
|
|
637
|
+
const onConn = makeConnectionHandler(handle, readAgentToken);
|
|
584
638
|
const server = await bindBrokerSocket(sock, onConn);
|
|
585
639
|
if (!server)
|
|
586
640
|
return null;
|
|
@@ -632,6 +686,9 @@ export async function startHostedBroker() {
|
|
|
632
686
|
/** Open the socket, send one request, resolve the one response. Async path —
|
|
633
687
|
* used by the unlock/lock/status commands, which already run in async actions. */
|
|
634
688
|
function request(req, timeoutMs = 2000) {
|
|
689
|
+
// Attach the capability token to every command except ping (the auth gate;
|
|
690
|
+
// RUSH-1760). ping stays tokenless so a client can probe reachability first.
|
|
691
|
+
const authedReq = req.cmd === 'ping' ? req : { ...req, token: readAgentToken() ?? undefined };
|
|
635
692
|
return new Promise((resolve) => {
|
|
636
693
|
const conn = net.createConnection(socketPath());
|
|
637
694
|
let buf = '';
|
|
@@ -649,7 +706,7 @@ function request(req, timeoutMs = 2000) {
|
|
|
649
706
|
};
|
|
650
707
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
651
708
|
conn.on('error', () => finish(null));
|
|
652
|
-
conn.on('connect', () => conn.write(JSON.stringify(
|
|
709
|
+
conn.on('connect', () => conn.write(JSON.stringify(authedReq) + '\n'));
|
|
653
710
|
conn.setEncoding('utf-8');
|
|
654
711
|
conn.on('data', (chunk) => {
|
|
655
712
|
buf += chunk;
|
|
@@ -679,14 +736,15 @@ export function agentSocketExists() {
|
|
|
679
736
|
* -e: [execPath, <socket>, <name>].
|
|
680
737
|
*/
|
|
681
738
|
const SYNC_GET_PROGRAM = `
|
|
682
|
-
const net = require('net');
|
|
683
|
-
const sock = process.argv[1], name = process.argv[2];
|
|
739
|
+
const net = require('net'), fs = require('fs');
|
|
740
|
+
const sock = process.argv[1], name = process.argv[2], tokenPath = process.argv[3];
|
|
741
|
+
let token; try { token = fs.readFileSync(tokenPath, 'utf-8').trim() || undefined; } catch (e) {}
|
|
684
742
|
const c = net.createConnection(sock);
|
|
685
743
|
let buf = '';
|
|
686
744
|
const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
|
|
687
745
|
const timer = setTimeout(miss, 2000);
|
|
688
746
|
c.on('error', miss);
|
|
689
|
-
c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name }) + '\\n'));
|
|
747
|
+
c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name, token }) + '\\n'));
|
|
690
748
|
c.setEncoding('utf-8');
|
|
691
749
|
c.on('data', (d) => {
|
|
692
750
|
buf += d;
|
|
@@ -707,7 +765,7 @@ c.on('data', (d) => {
|
|
|
707
765
|
export function agentGetSync(name) {
|
|
708
766
|
if (!agentSocketExists())
|
|
709
767
|
return null;
|
|
710
|
-
const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name], {
|
|
768
|
+
const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name, tokenPath()], {
|
|
711
769
|
encoding: 'utf-8',
|
|
712
770
|
timeout: 3000,
|
|
713
771
|
});
|
|
@@ -771,14 +829,15 @@ export function agentReachableSync() {
|
|
|
771
829
|
* argv after -e: [execPath, <socket>, <name>].
|
|
772
830
|
*/
|
|
773
831
|
const SYNC_LOCK_PROGRAM = `
|
|
774
|
-
const net = require('net');
|
|
775
|
-
const sock = process.argv[1], name = process.argv[2];
|
|
832
|
+
const net = require('net'), fs = require('fs');
|
|
833
|
+
const sock = process.argv[1], name = process.argv[2], tokenPath = process.argv[3];
|
|
834
|
+
let token; try { token = fs.readFileSync(tokenPath, 'utf-8').trim() || undefined; } catch (e) {}
|
|
776
835
|
const c = net.createConnection(sock);
|
|
777
836
|
let buf = '';
|
|
778
837
|
const down = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
|
|
779
838
|
const timer = setTimeout(down, 2000);
|
|
780
839
|
c.on('error', down);
|
|
781
|
-
c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name }) + '\\n'));
|
|
840
|
+
c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name, token }) + '\\n'));
|
|
782
841
|
c.setEncoding('utf-8');
|
|
783
842
|
c.on('data', (d) => {
|
|
784
843
|
buf += d;
|
|
@@ -803,7 +862,7 @@ export function agentEvictSync(name) {
|
|
|
803
862
|
if (!agentSocketExists())
|
|
804
863
|
return;
|
|
805
864
|
try {
|
|
806
|
-
spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name], { timeout: 3000 });
|
|
865
|
+
spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name, tokenPath()], { timeout: 3000 });
|
|
807
866
|
}
|
|
808
867
|
catch { /* best-effort */ }
|
|
809
868
|
}
|
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { KeychainBackend } from './index.js';
|
|
24
24
|
export declare function fileDir(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Turn off terminal echo on the controlling TTY, or throw — fail CLOSED. If echo
|
|
27
|
+
* cannot be disabled (`stty` missing, no controlling terminal) we must NOT fall
|
|
28
|
+
* through and read the passphrase anyway: that echoes the secret to the screen
|
|
29
|
+
* and into scrollback (RUSH-1764). Refuse and point the user at the environment
|
|
30
|
+
* variable instead. `run` performs the echo-disable and throws iff it fails.
|
|
31
|
+
* Exported so the fail-closed contract has direct test coverage.
|
|
32
|
+
*/
|
|
33
|
+
export declare function disableTtyEchoOrThrow(run: () => void): void;
|
|
25
34
|
/** True if a machine-local passphrase has already been provisioned. */
|
|
26
35
|
export declare function machinePassphraseExists(): boolean;
|
|
27
36
|
/**
|
|
@@ -65,6 +65,24 @@ finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
|
|
|
65
65
|
}
|
|
66
66
|
return (res.stdout?.toString() ?? '').replace(/\r?\n$/, '');
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Turn off terminal echo on the controlling TTY, or throw — fail CLOSED. If echo
|
|
70
|
+
* cannot be disabled (`stty` missing, no controlling terminal) we must NOT fall
|
|
71
|
+
* through and read the passphrase anyway: that echoes the secret to the screen
|
|
72
|
+
* and into scrollback (RUSH-1764). Refuse and point the user at the environment
|
|
73
|
+
* variable instead. `run` performs the echo-disable and throws iff it fails.
|
|
74
|
+
* Exported so the fail-closed contract has direct test coverage.
|
|
75
|
+
*/
|
|
76
|
+
export function disableTtyEchoOrThrow(run) {
|
|
77
|
+
try {
|
|
78
|
+
run();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error('Refusing to prompt for AGENTS_SECRETS_PASSPHRASE: terminal echo could not be ' +
|
|
82
|
+
'disabled (stty unavailable or no controlling TTY), so the passphrase would be ' +
|
|
83
|
+
'shown in cleartext. Set AGENTS_SECRETS_PASSPHRASE in the environment instead.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
68
86
|
function readPassphraseFromTty() {
|
|
69
87
|
if (process.platform === 'win32')
|
|
70
88
|
return readPassphraseFromTtyWindows();
|
|
@@ -72,14 +90,9 @@ function readPassphraseFromTty() {
|
|
|
72
90
|
let echoDisabled = false;
|
|
73
91
|
try {
|
|
74
92
|
fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
// stty not available — fall through; passphrase will echo. Better
|
|
81
|
-
// than refusing to function.
|
|
82
|
-
}
|
|
93
|
+
// Fail closed: if echo can't be turned off, abort rather than echo the secret.
|
|
94
|
+
disableTtyEchoOrThrow(() => execSync('stty -echo < /dev/tty', { stdio: 'ignore' }));
|
|
95
|
+
echoDisabled = true;
|
|
83
96
|
let pass = '';
|
|
84
97
|
const buf = Buffer.alloc(1);
|
|
85
98
|
while (true) {
|
|
@@ -214,6 +214,13 @@ export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
|
214
214
|
* predates that path rejects the unknown command (exit 2) and this throws,
|
|
215
215
|
* rather than silently falling back to an ACL'd `set` (which would behave like
|
|
216
216
|
* `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
|
|
217
|
+
/**
|
|
218
|
+
* argv for writing a bare (non-`agents-cli.`) keychain item via
|
|
219
|
+
* `/usr/bin/security add-generic-password`, deliberately WITHOUT the value: the
|
|
220
|
+
* secret travels over stdin (see setKeychainToken) so it never lands in argv or
|
|
221
|
+
* a `ps` snapshot. Exported so a test can assert the value is absent from argv.
|
|
222
|
+
*/
|
|
223
|
+
export declare function buildAddGenericPasswordArgs(account: string, item: string): string[];
|
|
217
224
|
export declare function setKeychainToken(item: string, value: string, opts?: {
|
|
218
225
|
noAcl?: boolean;
|
|
219
226
|
}): void;
|
|
@@ -844,6 +844,15 @@ function parseBatchRecords(out, record) {
|
|
|
844
844
|
* predates that path rejects the unknown command (exit 2) and this throws,
|
|
845
845
|
* rather than silently falling back to an ACL'd `set` (which would behave like
|
|
846
846
|
* `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
|
|
847
|
+
/**
|
|
848
|
+
* argv for writing a bare (non-`agents-cli.`) keychain item via
|
|
849
|
+
* `/usr/bin/security add-generic-password`, deliberately WITHOUT the value: the
|
|
850
|
+
* secret travels over stdin (see setKeychainToken) so it never lands in argv or
|
|
851
|
+
* a `ps` snapshot. Exported so a test can assert the value is absent from argv.
|
|
852
|
+
*/
|
|
853
|
+
export function buildAddGenericPasswordArgs(account, item) {
|
|
854
|
+
return ['add-generic-password', '-U', '-a', account, '-s', item, '-w'];
|
|
855
|
+
}
|
|
847
856
|
export function setKeychainToken(item, value, opts) {
|
|
848
857
|
// Validate the CLEARTEXT name (a hashed storage name is always clean), then
|
|
849
858
|
// resolve the storage name.
|
|
@@ -872,12 +881,23 @@ export function setKeychainToken(item, value, opts) {
|
|
|
872
881
|
// /usr/bin/security read can't satisfy without popping the legacy password
|
|
873
882
|
// sheet. -U upserts so repeated sets overwrite in place.
|
|
874
883
|
if (!isOurItem(item)) {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
884
|
+
// The secret VALUE must never appear in argv — a `ps` snapshot on a shared
|
|
885
|
+
// host would leak it (RUSH-1764). `security add-generic-password` has no
|
|
886
|
+
// stdin-password flag; instead a BARE `-w` as the LAST option makes it prompt
|
|
887
|
+
// ("password data for new item:" / "retype...") and read the secret from fd 0
|
|
888
|
+
// via readpassphrase(3) -- so the value travels over stdin, never on the
|
|
889
|
+
// command line. The prompt asks twice (enter + confirm), so we pipe the value
|
|
890
|
+
// TWICE; a single line fails the confirm and would store an empty secret. The
|
|
891
|
+
// item stays ACL-free (no biometry gate), so the no-prompt /usr/bin/security
|
|
892
|
+
// read path in getKeychainToken still works. Values are newline-free on darwin
|
|
893
|
+
// (assertValueStorable above), so each line carries the whole secret verbatim
|
|
894
|
+
// (no shell/quoting layer). `timeout` bounds the call so a context that cannot
|
|
895
|
+
// read the prompt fails loudly instead of hanging.
|
|
896
|
+
const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), {
|
|
897
|
+
input: `${value}\n${value}\n`,
|
|
898
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
899
|
+
timeout: 10_000,
|
|
900
|
+
});
|
|
881
901
|
if (sec.status !== 0) {
|
|
882
902
|
const msg = sec.stderr?.toString().trim();
|
|
883
903
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an HTML file to a 1200×630 PNG — the Open Graph cover for a shared plan.
|
|
3
|
+
*
|
|
4
|
+
* When `agents share plan.html` runs, we screenshot the plan's own hero and use it
|
|
5
|
+
* as the `og:image`, so the link unfurls into a card in Slack / iMessage / Twitter /
|
|
6
|
+
* Discord. No AI, no central render service: it's a headless screenshot on the
|
|
7
|
+
* publisher's machine, so it works identically for us and for any user, and costs
|
|
8
|
+
* nothing.
|
|
9
|
+
*
|
|
10
|
+
* Browser resolution reuses the repo's own detector (`findFirstInstalledBrowser`,
|
|
11
|
+
* the same auto-pick behind `agents browser`), then falls back to a managed
|
|
12
|
+
* Chromium in the Playwright/Puppeteer caches (present on machines that have done
|
|
13
|
+
* browser automation), then `PUPPETEER_EXECUTABLE_PATH`. If nothing headless-capable
|
|
14
|
+
* is found, `captureCover` returns null and publishing proceeds without a cover —
|
|
15
|
+
* the link still works, it just won't have a preview image.
|
|
16
|
+
*/
|
|
17
|
+
/** OG standard card size; captured at OG_SCALE× for retina crispness. */
|
|
18
|
+
export declare const OG_WIDTH = 1200;
|
|
19
|
+
export declare const OG_HEIGHT = 630;
|
|
20
|
+
/** Device scale factor for the capture — the served PNG is OG_WIDTH*OG_SCALE × OG_HEIGHT*OG_SCALE. */
|
|
21
|
+
export declare const OG_SCALE = 2;
|
|
22
|
+
/** Ordered list of candidate Chromium-family binaries to try for headless capture. */
|
|
23
|
+
export declare function candidateBrowsers(): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Screenshot `htmlPath`'s top 1200×630 (its hero) to a PNG buffer, or null if no
|
|
26
|
+
* headless-capable browser is available or every candidate fails. Never throws —
|
|
27
|
+
* a cover is a nice-to-have, never a reason to fail a publish.
|
|
28
|
+
*/
|
|
29
|
+
export declare function captureCover(htmlPath: string, timeoutMs?: number): Promise<Buffer | null>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an HTML file to a 1200×630 PNG — the Open Graph cover for a shared plan.
|
|
3
|
+
*
|
|
4
|
+
* When `agents share plan.html` runs, we screenshot the plan's own hero and use it
|
|
5
|
+
* as the `og:image`, so the link unfurls into a card in Slack / iMessage / Twitter /
|
|
6
|
+
* Discord. No AI, no central render service: it's a headless screenshot on the
|
|
7
|
+
* publisher's machine, so it works identically for us and for any user, and costs
|
|
8
|
+
* nothing.
|
|
9
|
+
*
|
|
10
|
+
* Browser resolution reuses the repo's own detector (`findFirstInstalledBrowser`,
|
|
11
|
+
* the same auto-pick behind `agents browser`), then falls back to a managed
|
|
12
|
+
* Chromium in the Playwright/Puppeteer caches (present on machines that have done
|
|
13
|
+
* browser automation), then `PUPPETEER_EXECUTABLE_PATH`. If nothing headless-capable
|
|
14
|
+
* is found, `captureCover` returns null and publishing proceeds without a cover —
|
|
15
|
+
* the link still works, it just won't have a preview image.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { findFirstInstalledBrowser } from '../browser/chrome.js';
|
|
22
|
+
/** OG standard card size; captured at OG_SCALE× for retina crispness. */
|
|
23
|
+
export const OG_WIDTH = 1200;
|
|
24
|
+
export const OG_HEIGHT = 630;
|
|
25
|
+
/** Device scale factor for the capture — the served PNG is OG_WIDTH*OG_SCALE × OG_HEIGHT*OG_SCALE. */
|
|
26
|
+
export const OG_SCALE = 2;
|
|
27
|
+
// Installed browsers that are poor `--headless` hosts (they hang instead of
|
|
28
|
+
// capturing). We still let a managed Chromium or an explicit override handle those
|
|
29
|
+
// machines; we just don't burn a timeout probing these. (Only members of the
|
|
30
|
+
// browser detector's `BrowserType` are meaningful here; Comet is the real case.)
|
|
31
|
+
const BAD_HEADLESS_TYPES = new Set(['comet']);
|
|
32
|
+
/** Ordered list of candidate Chromium-family binaries to try for headless capture. */
|
|
33
|
+
export function candidateBrowsers() {
|
|
34
|
+
const out = [];
|
|
35
|
+
const push = (p) => {
|
|
36
|
+
if (p && fs.existsSync(p) && !out.includes(p))
|
|
37
|
+
out.push(p);
|
|
38
|
+
};
|
|
39
|
+
// 1) An explicit override always wins.
|
|
40
|
+
push(process.env.PUPPETEER_EXECUTABLE_PATH || process.env.AGENTS_SHARE_BROWSER);
|
|
41
|
+
// 2) Managed Chromium in the Playwright / Puppeteer caches — purpose-built for
|
|
42
|
+
// headless, so it's the most reliable capture host when present.
|
|
43
|
+
for (const bin of scanCaches())
|
|
44
|
+
push(bin);
|
|
45
|
+
// 3) The repo's own auto-pick (Chrome → Brave → Edge → Chromium by platform),
|
|
46
|
+
// skipping browsers known to be poor headless hosts so we don't eat a timeout.
|
|
47
|
+
try {
|
|
48
|
+
const pick = findFirstInstalledBrowser();
|
|
49
|
+
if (pick && !BAD_HEADLESS_TYPES.has(pick.browserType))
|
|
50
|
+
push(pick.binary);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// detector is best-effort
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Newest-first Chromium binaries under the Playwright / Puppeteer download caches. */
|
|
58
|
+
function scanCaches() {
|
|
59
|
+
const home = os.homedir();
|
|
60
|
+
const roots = os.platform() === 'darwin'
|
|
61
|
+
? [
|
|
62
|
+
path.join(home, 'Library/Caches/ms-playwright'),
|
|
63
|
+
path.join(home, '.cache/ms-playwright'),
|
|
64
|
+
path.join(home, '.cache/puppeteer/chrome'),
|
|
65
|
+
]
|
|
66
|
+
: [path.join(home, '.cache/ms-playwright'), path.join(home, '.cache/puppeteer/chrome')];
|
|
67
|
+
const rel = os.platform() === 'darwin'
|
|
68
|
+
? [
|
|
69
|
+
'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
|
70
|
+
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
71
|
+
'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
72
|
+
]
|
|
73
|
+
: ['chrome-linux/chrome', 'chrome-linux64/chrome'];
|
|
74
|
+
const found = [];
|
|
75
|
+
for (const root of roots) {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = fs.readdirSync(root).sort().reverse(); // newest version dir first
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (const e of entries) {
|
|
84
|
+
for (const r of rel) {
|
|
85
|
+
const p = path.join(root, e, r);
|
|
86
|
+
if (fs.existsSync(p))
|
|
87
|
+
found.push(p);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Screenshot `htmlPath`'s top 1200×630 (its hero) to a PNG buffer, or null if no
|
|
95
|
+
* headless-capable browser is available or every candidate fails. Never throws —
|
|
96
|
+
* a cover is a nice-to-have, never a reason to fail a publish.
|
|
97
|
+
*/
|
|
98
|
+
export async function captureCover(htmlPath, timeoutMs = 15_000) {
|
|
99
|
+
const abs = path.resolve(htmlPath);
|
|
100
|
+
if (!fs.existsSync(abs))
|
|
101
|
+
return null;
|
|
102
|
+
const fileUrl = `file://${abs.split('/').map(encodeURIComponent).join('/')}`;
|
|
103
|
+
for (const bin of candidateBrowsers()) {
|
|
104
|
+
const outPng = path.join(os.tmpdir(), `agents-share-cover-${process.pid}-${Date.now()}.png`);
|
|
105
|
+
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-share-chrome-'));
|
|
106
|
+
try {
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
execFile(bin, [
|
|
109
|
+
'--headless=new',
|
|
110
|
+
'--disable-gpu',
|
|
111
|
+
'--hide-scrollbars',
|
|
112
|
+
'--no-first-run',
|
|
113
|
+
'--no-default-browser-check',
|
|
114
|
+
`--force-device-scale-factor=${OG_SCALE}`,
|
|
115
|
+
// Bound page JS (count-up animations etc.) so the shot fires promptly
|
|
116
|
+
// instead of waiting on long-running timers.
|
|
117
|
+
'--virtual-time-budget=8000',
|
|
118
|
+
`--window-size=${OG_WIDTH},${OG_HEIGHT}`,
|
|
119
|
+
`--user-data-dir=${userDir}`,
|
|
120
|
+
`--screenshot=${outPng}`,
|
|
121
|
+
fileUrl,
|
|
122
|
+
], { timeout: timeoutMs }, (err) => (err ? reject(err) : resolve()));
|
|
123
|
+
});
|
|
124
|
+
if (fs.existsSync(outPng)) {
|
|
125
|
+
const buf = fs.readFileSync(outPng);
|
|
126
|
+
// A valid PNG starts with the 8-byte signature; guard against 0-byte writes.
|
|
127
|
+
if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50)
|
|
128
|
+
return buf;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// try the next candidate
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
fs.rmSync(outPng, { force: true });
|
|
136
|
+
fs.rmSync(userDir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ShareConfig {
|
|
2
|
+
/** Public base, e.g. `https://share.agents-cli.sh` or `https://agent-share.<acct>.workers.dev`. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
accountId: string;
|
|
5
|
+
workerName: string;
|
|
6
|
+
bucketName: string;
|
|
7
|
+
/** Custom domain when mapped (e.g. `share.agents-cli.sh`). */
|
|
8
|
+
domain?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const SHARE_BUNDLE = "share";
|
|
11
|
+
export declare const SHARE_TOKEN_KEY = "SHARE_WRITE_TOKEN";
|
|
12
|
+
export declare const DEFAULT_CF_BUNDLE = "cloudflare.com";
|
|
13
|
+
export declare const DEFAULT_WORKER_NAME = "agents-share";
|
|
14
|
+
export declare const DEFAULT_BUCKET_NAME = "agents-share";
|
|
15
|
+
/** Read the persisted endpoint config, or null if `agents share setup`/`join` never ran. */
|
|
16
|
+
export declare function readShareConfig(): ShareConfig | null;
|
|
17
|
+
/** Persist the endpoint config to `agents.yaml` (syncs across the fleet). */
|
|
18
|
+
export declare function writeShareConfig(cfg: ShareConfig): void;
|
|
19
|
+
/** A fresh 32-byte hex write token. */
|
|
20
|
+
export declare function generateWriteToken(): string;
|
|
21
|
+
/** Persist the raw write token into the `share` secrets bundle (keychain-backed,
|
|
22
|
+
* fleet-injectable). Mirrors the add-key sequence in `commands/secrets.ts`. */
|
|
23
|
+
export declare function storeWriteToken(token: string): void;
|
|
24
|
+
/** Read the raw write token from the `share` secrets bundle. Throws with an
|
|
25
|
+
* actionable message if absent (run setup/join first). */
|
|
26
|
+
export declare function readWriteToken(): string;
|
|
27
|
+
/** Cloudflare API credentials for provisioning, read from `cloudflare.com` (or a
|
|
28
|
+
* user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
|
|
29
|
+
export declare function readCloudflareCreds(bundle?: string, override?: {
|
|
30
|
+
apiToken?: string;
|
|
31
|
+
accountId?: string;
|
|
32
|
+
}): {
|
|
33
|
+
apiToken: string;
|
|
34
|
+
accountId: string;
|
|
35
|
+
};
|