@phnx-labs/agents-cli 1.22.40 → 1.22.41
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 +22 -0
- package/README.md +5 -0
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +0 -1
- package/dist/cli/command-registry.js +1 -2
- package/dist/commands/browser.d.ts +15 -0
- package/dist/commands/browser.js +115 -40
- package/dist/commands/feed.js +3 -11
- package/dist/commands/secrets.js +87 -97
- package/dist/commands/sessions-picker.d.ts +1 -0
- package/dist/commands/sessions-picker.js +27 -5
- package/dist/commands/sessions-share.d.ts +25 -0
- package/dist/commands/sessions-share.js +166 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup-browser.js +1 -1
- package/dist/commands/setup-preferences.js +2 -2
- package/dist/commands/webhook.js +14 -9
- package/dist/lib/browser/cdp.js +4 -0
- package/dist/lib/browser/chrome.js +12 -1
- package/dist/lib/browser/drivers/ssh.js +1 -0
- package/dist/lib/browser/profiles.d.ts +3 -3
- package/dist/lib/browser/profiles.js +7 -7
- package/dist/lib/browser/service.d.ts +24 -1
- package/dist/lib/browser/service.js +38 -14
- package/dist/lib/browser/types.d.ts +1 -1
- package/dist/lib/daemon-webhooks.d.ts +3 -1
- package/dist/lib/daemon-webhooks.js +12 -7
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/observe-aliases.d.ts +2 -2
- package/dist/lib/observe-aliases.js +2 -11
- package/dist/lib/project-key.js +7 -0
- package/dist/lib/runner.js +21 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +1 -1
- package/dist/lib/secrets/headless.d.ts +16 -0
- package/dist/lib/secrets/headless.js +21 -0
- package/dist/lib/secrets/remote.d.ts +9 -7
- package/dist/lib/secrets/remote.js +18 -9
- package/dist/lib/session/share-html.d.ts +55 -0
- package/dist/lib/session/share-html.js +319 -0
- package/dist/lib/settings-manifest.d.ts +2 -0
- package/dist/lib/settings-manifest.js +81 -3
- package/dist/lib/share/publish.d.ts +38 -0
- package/dist/lib/share/publish.js +81 -8
- package/dist/lib/startup/command-registry.d.ts +2 -1
- package/dist/lib/startup/command-registry.js +4 -2
- package/dist/lib/triggers/handlers.d.ts +37 -2
- package/dist/lib/triggers/handlers.js +56 -5
- package/dist/lib/triggers/webhook.d.ts +80 -6
- package/dist/lib/triggers/webhook.js +127 -3
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/wrap.d.ts +33 -0
- package/dist/lib/wrap.js +70 -0
- package/package.json +1 -1
package/dist/commands/secrets.js
CHANGED
|
@@ -18,7 +18,7 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon/daemon.js';
|
|
|
18
18
|
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
|
|
19
19
|
import { resolveBundleForPush, pushResolvedBundleToHost, bundleEnvToDotenv } from '../lib/secrets/push.js';
|
|
20
20
|
export { bundleEnvToDotenv };
|
|
21
|
-
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
21
|
+
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, isAgentInvocationContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
22
22
|
import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
|
|
23
23
|
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
24
24
|
import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
|
|
@@ -38,7 +38,6 @@ import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
|
|
|
38
38
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
39
39
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
40
40
|
import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
|
|
41
|
-
import { getVaultSession, vaultExists } from '../lib/secrets/vault.js';
|
|
42
41
|
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
43
42
|
import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
|
|
44
43
|
import { registerSecretsImportKeyringCommand } from './secrets-import.js';
|
|
@@ -924,14 +923,17 @@ export function registerSecretsCommands(program) {
|
|
|
924
923
|
# Stop a noisy automation bundle from prompting every run: ask once a week
|
|
925
924
|
agents secrets policy prod hold
|
|
926
925
|
|
|
927
|
-
#
|
|
928
|
-
|
|
926
|
+
# Run a one-off command with secrets injected (values ride the child env, never stdout)
|
|
927
|
+
agents secrets exec prod -- ./deploy.sh
|
|
928
|
+
|
|
929
|
+
# Need one value inside a script? Read it under injection, not with a printer
|
|
930
|
+
agents secrets exec prod -- printenv STRIPE_API_KEY
|
|
931
|
+
|
|
932
|
+
# Deliberately reveal values at your terminal (interactive only)
|
|
933
|
+
agents secrets view prod --reveal
|
|
929
934
|
|
|
930
935
|
# Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
|
|
931
936
|
agents secrets export prod --device yosemite-s0 --device yosemite-s1 --force
|
|
932
|
-
|
|
933
|
-
# Run a one-off command with secrets injected
|
|
934
|
-
agents secrets exec prod -- ./deploy.sh
|
|
935
937
|
`,
|
|
936
938
|
notes: `
|
|
937
939
|
Bundles are containers; secrets are the variables inside them. Keychain values
|
|
@@ -1222,13 +1224,22 @@ export function registerSecretsCommands(program) {
|
|
|
1222
1224
|
.command('view [name]')
|
|
1223
1225
|
.alias('show')
|
|
1224
1226
|
.description('Show a bundle. Keychain values are masked by default — pass --reveal to see them.')
|
|
1225
|
-
.option('--reveal', 'Print keychain-backed values in the clear (
|
|
1226
|
-
.option('--plaintext', 'Allow --reveal in non-interactive shells (use with care)')
|
|
1227
|
+
.option('--reveal', 'Print keychain-backed values in the clear (interactive terminal only)')
|
|
1227
1228
|
.option('--json', 'Emit machine-readable JSON (values masked unless --reveal) instead of the human view')
|
|
1228
1229
|
.option('--device <target>', 'Show a bundle on a remote device over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
1229
1230
|
.option('--devices <list>', 'Comma-separated devices to show in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
1230
1231
|
.action(async (name, opts) => {
|
|
1231
1232
|
try {
|
|
1233
|
+
// `--reveal` is a deliberate HUMAN reveal: interactive terminal only, and
|
|
1234
|
+
// never inside an agent session (an agent under tmux has a TTY, so the
|
|
1235
|
+
// env markers are the load-bearing check). The old `--plaintext` escape
|
|
1236
|
+
// that allowed a piped/non-TTY reveal is removed (RUSH-2774) — printed
|
|
1237
|
+
// values land in an agent's context and session transcript.
|
|
1238
|
+
if (opts.reveal && (isAgentInvocationContext() || !isInteractiveTerminal())) {
|
|
1239
|
+
console.error(chalk.red('--reveal prints values in the clear and needs a real interactive terminal outside an agent session.'));
|
|
1240
|
+
console.error(chalk.dim('Run the consuming command under injection instead: agents secrets exec <bundle> -- <cmd>'));
|
|
1241
|
+
process.exit(1);
|
|
1242
|
+
}
|
|
1232
1243
|
const targets = parseHostsOption({ device: opts.device, devices: opts.devices });
|
|
1233
1244
|
if (targets.length > 0) {
|
|
1234
1245
|
if (!name) {
|
|
@@ -1238,14 +1249,13 @@ export function registerSecretsCommands(program) {
|
|
|
1238
1249
|
const args = ['view', name];
|
|
1239
1250
|
if (opts.reveal)
|
|
1240
1251
|
args.push('--reveal');
|
|
1241
|
-
if (opts.plaintext)
|
|
1242
|
-
args.push('--plaintext');
|
|
1243
1252
|
if (opts.json)
|
|
1244
1253
|
args.push('--json');
|
|
1245
1254
|
// With --reveal, force a TTY so the remote keychain prompt can surface
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
|
|
1255
|
+
// and the remote's own interactive-terminal gate is satisfied. The
|
|
1256
|
+
// early gate above already proved this side is an interactive,
|
|
1257
|
+
// non-agent terminal.
|
|
1258
|
+
const tty = Boolean(opts.reveal);
|
|
1249
1259
|
// `--reveal` streams the plaintext value back over ssh stdout — a
|
|
1250
1260
|
// secret-bearing read that must pin the host key and not multiplex,
|
|
1251
1261
|
// whether it runs on the interactive (tty) or `--plaintext` non-tty path.
|
|
@@ -1288,14 +1298,10 @@ export function registerSecretsCommands(program) {
|
|
|
1288
1298
|
emitSecretAudit({ event: 'secrets.view', bundle: bundle.name, operation: opts.json ? 'view --json' : 'view', source: 'view', status: 'success' });
|
|
1289
1299
|
if (opts.json) {
|
|
1290
1300
|
// Machine-readable discovery for agents. Values are null unless --reveal
|
|
1291
|
-
// (
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
1301
|
+
// (already gated above: interactive terminal, outside an agent session).
|
|
1302
|
+
// Gated on the explicit flag, not stdout.isTTY, so a piped human view
|
|
1303
|
+
// never silently swaps formats.
|
|
1294
1304
|
const reveal = Boolean(opts.reveal);
|
|
1295
|
-
if (reveal && !isInteractiveTerminal() && !opts.plaintext) {
|
|
1296
|
-
console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
|
|
1297
|
-
process.exit(1);
|
|
1298
|
-
}
|
|
1299
1305
|
const revealed = new Map();
|
|
1300
1306
|
if (reveal) {
|
|
1301
1307
|
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
@@ -1415,10 +1421,6 @@ export function registerSecretsCommands(program) {
|
|
|
1415
1421
|
return;
|
|
1416
1422
|
}
|
|
1417
1423
|
const reveal = Boolean(opts.reveal);
|
|
1418
|
-
if (reveal && !isInteractiveTerminal() && !opts.plaintext) {
|
|
1419
|
-
console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
|
|
1420
|
-
process.exit(1);
|
|
1421
|
-
}
|
|
1422
1424
|
// An explicit human `view --reveal` at a terminal is a deliberate value
|
|
1423
1425
|
// access: resolve interactively (one Touch ID) so a locked bundle shows
|
|
1424
1426
|
// its values. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
|
|
@@ -1491,50 +1493,38 @@ export function registerSecretsCommands(program) {
|
|
|
1491
1493
|
});
|
|
1492
1494
|
cmd
|
|
1493
1495
|
.command('get <item> [key]')
|
|
1494
|
-
.description('Print one
|
|
1496
|
+
.description('Print one raw keychain item by name, for shell hooks/automation. Cross-platform. Bundle values are never printed — run commands under `agents secrets exec` instead.')
|
|
1495
1497
|
.action((item, key) => {
|
|
1496
|
-
if (key
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
const value = getKeychainToken(item);
|
|
1505
|
-
// Raw item reads bypass readAndResolveBundleEnv, so audit here too.
|
|
1506
|
-
// `item` is the keychain service name, never the value.
|
|
1507
|
-
emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
|
|
1508
|
-
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
1509
|
-
}
|
|
1510
|
-
catch {
|
|
1511
|
-
// Missing item is a normal, quiet outcome for a hook probe: exit 1,
|
|
1512
|
-
// print nothing to stdout. Callers test the exit code / empty capture.
|
|
1513
|
-
process.exit(1);
|
|
1514
|
-
}
|
|
1515
|
-
return;
|
|
1498
|
+
if (key !== undefined) {
|
|
1499
|
+
// The bundle-key form (`get <bundle> <KEY>`) is REMOVED (RUSH-2774): a
|
|
1500
|
+
// one-liner that prints a bundle credential is the exfiltration path
|
|
1501
|
+
// this change closes, for every caller. The injection replacement is
|
|
1502
|
+
// just as short for a human script and never touches this stdout.
|
|
1503
|
+
console.error(chalk.red(`'secrets get <bundle> <KEY>' has been removed — printing a bundle value to stdout is the exfiltration path this blocks.`));
|
|
1504
|
+
console.error(chalk.dim(`Use: agents secrets exec ${item} -- printenv ${key} (or run the consuming command directly under exec)`));
|
|
1505
|
+
process.exit(1);
|
|
1516
1506
|
}
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1507
|
+
// Raw keychain item path — a single ad-hoc token, not a credential bundle.
|
|
1508
|
+
// Deliberately NOT agent-gated (unlike the removed bundle-key form): shell
|
|
1509
|
+
// hooks that run inside agent sessions inherit the session env markers and
|
|
1510
|
+
// legitimately capture a raw item into their own variables (e.g. the
|
|
1511
|
+
// posthog analytics hook), where the value never reaches the transcript.
|
|
1512
|
+
// A single ad-hoc token is the accepted narrower residual (RUSH-2774).
|
|
1520
1513
|
try {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
//
|
|
1526
|
-
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
|
|
1531
|
-
process.exit(1);
|
|
1532
|
-
}
|
|
1533
|
-
const value = env[key];
|
|
1514
|
+
// Routes through the platform keychain layer: macOS reads bare items
|
|
1515
|
+
// via /usr/bin/security (no Touch ID), Linux via secret-tool with the
|
|
1516
|
+
// encrypted-file fallback. The value goes to stdout (newline-terminated
|
|
1517
|
+
// so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
|
|
1518
|
+
// to stderr so they never pollute the captured value.
|
|
1519
|
+
const value = getKeychainToken(item);
|
|
1520
|
+
// Raw item reads bypass readAndResolveBundleEnv, so audit here too.
|
|
1521
|
+
// `item` is the keychain service name, never the value.
|
|
1522
|
+
emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
|
|
1534
1523
|
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
1535
1524
|
}
|
|
1536
|
-
catch
|
|
1537
|
-
|
|
1525
|
+
catch {
|
|
1526
|
+
// Missing item is a normal, quiet outcome for a hook probe: exit 1,
|
|
1527
|
+
// print nothing to stdout. Callers test the exit code / empty capture.
|
|
1538
1528
|
process.exit(1);
|
|
1539
1529
|
}
|
|
1540
1530
|
});
|
|
@@ -2133,18 +2123,23 @@ Examples:
|
|
|
2133
2123
|
});
|
|
2134
2124
|
cmd
|
|
2135
2125
|
.command('export [bundle]')
|
|
2136
|
-
.description('
|
|
2137
|
-
|
|
2126
|
+
.description('Move a bundle without exposing it: push to remote machine(s) over SSH with --device, to a 1Password vault with --to-1password, or to an encrypted file with --to-file.')
|
|
2127
|
+
// Both transport flags are hidden, not documented: the ONLY caller is the
|
|
2128
|
+
// machine-to-machine SSH resolve (remoteResolveEnv / verifyRemoteKeychainPush),
|
|
2129
|
+
// which sets AGENTS_SECRETS_REMOTE_TRANSPORT=1 on the remote invocation. The
|
|
2130
|
+
// old public shell-eval mode (`eval "$(agents secrets export … --plaintext)"`)
|
|
2131
|
+
// was the top agent-exfiltration path and is removed (RUSH-2774).
|
|
2132
|
+
.addOption(new Option('--plaintext', 'internal remote-resolve transport').hideHelp())
|
|
2133
|
+
.addOption(new Option('--format <json>', 'internal remote-resolve transport').hideHelp())
|
|
2138
2134
|
.option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
|
|
2139
2135
|
.option('--vault <name>', '1Password vault name (used with --to-1password)')
|
|
2140
2136
|
.option('--device <target...>', 'Push the bundle over SSH to this device (host alias or user@host); repeatable for multiple machines')
|
|
2141
2137
|
.option('--remote-backend <backend>', 'Backend for the bundle on the remote device (with --device): keychain (default) or file. file is headless-readable via the remote\'s machine-local key; it forwards AGENTS_SECRETS_PASSPHRASE over stdin only if set (opt-in).', 'keychain')
|
|
2142
2138
|
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --device)')
|
|
2143
|
-
.option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
|
|
2144
2139
|
.option('--to-file <path>', `Write the bundle as an AES-256-GCM encrypted offline file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of import --from-file)`)
|
|
2145
2140
|
.action(async (bundleName, opts) => {
|
|
2146
2141
|
try {
|
|
2147
|
-
const { readAndResolveBundleEnv
|
|
2142
|
+
const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
|
|
2148
2143
|
const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
|
|
2149
2144
|
if (opts.toFile) {
|
|
2150
2145
|
// Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
|
|
@@ -2251,41 +2246,36 @@ Examples:
|
|
|
2251
2246
|
console.log(chalk.green(`Exported to 1Password vault '${vault}': ${parts.join(', ')}.`));
|
|
2252
2247
|
return;
|
|
2253
2248
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
2249
|
+
// The shell-eval print mode (`eval "$(agents secrets export … --plaintext)"`)
|
|
2250
|
+
// is REMOVED (RUSH-2774): a whole bundle printed to stdout lands in a
|
|
2251
|
+
// coding agent's context and session transcript, and it was the single
|
|
2252
|
+
// most-copied exfiltration one-liner on the fleet. The sole surviving
|
|
2253
|
+
// stdout emitter is the machine-to-machine SSH resolve transport, which
|
|
2254
|
+
// requires the remote-invocation marker AND a non-agent context — both
|
|
2255
|
+
// set only by remoteResolveEnv / verifyRemoteKeychainPush building the
|
|
2256
|
+
// remote command. Everything else gets the refusal with the paved paths.
|
|
2257
|
+
const isTransportCall = process.env.AGENTS_SECRETS_REMOTE_TRANSPORT === '1' &&
|
|
2258
|
+
Boolean(opts.plaintext) &&
|
|
2259
|
+
opts.format === 'json' &&
|
|
2260
|
+
!isAgentInvocationContext();
|
|
2261
|
+
if (!isTransportCall) {
|
|
2262
|
+
console.error(chalk.red('export no longer prints values. Pick a destination:'));
|
|
2263
|
+
console.error(chalk.red(' --device <host> | --to-1password | --to-file <path>'));
|
|
2264
|
+
console.error(chalk.dim(`Run a command with the values injected instead: agents secrets exec ${resolvedBundleName} -- <cmd>`));
|
|
2265
|
+
console.error(chalk.dim(`Reveal one value at your terminal: agents secrets view ${resolvedBundleName} --reveal`));
|
|
2256
2266
|
process.exit(1);
|
|
2257
2267
|
}
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
//
|
|
2263
|
-
// Every caller is broker-only, including a plain shell, so export never
|
|
2264
|
-
// raises Touch ID on the interactive user's screen.
|
|
2268
|
+
// Machine-readable form consumed by `remoteResolveEnv` over SSH: single
|
|
2269
|
+
// object of injected env KEY -> value; values verbatim. Broker-only, so a
|
|
2270
|
+
// headless remote read fails fast rather than raising Touch ID. The
|
|
2271
|
+
// resolve emits its own `secrets.get` audit on the remote; no export
|
|
2272
|
+
// event here — that would tally every peer pull as an export.
|
|
2265
2273
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, {
|
|
2266
|
-
caller:
|
|
2274
|
+
caller: 'remote resolve transport',
|
|
2267
2275
|
keyMode: 'process',
|
|
2268
2276
|
agentOnly: true,
|
|
2269
2277
|
});
|
|
2270
|
-
|
|
2271
|
-
// Machine-readable form consumed by `remoteResolveEnv` over SSH.
|
|
2272
|
-
// Single object of injected env KEY -> value; values verbatim.
|
|
2273
|
-
process.stdout.write(JSON.stringify(env));
|
|
2274
|
-
return;
|
|
2275
|
-
}
|
|
2276
|
-
const prefix = bundleToEnvPrefix(resolvedBundleName);
|
|
2277
|
-
for (const [k, v] of Object.entries(env)) {
|
|
2278
|
-
const exportKey = isReservedEnvName(k) ? `${prefix}_${k}` : k;
|
|
2279
|
-
const needsQuotes = /[\s$`"'\\|&;<>(){}[\]!#~]/.test(v);
|
|
2280
|
-
const output = needsQuotes ? `'${v.replace(/'/g, `'\\''`)}'` : v;
|
|
2281
|
-
process.stdout.write(`export ${exportKey}=${output}\n`);
|
|
2282
|
-
}
|
|
2283
|
-
// Record the shell export (the `eval "$(...)"` path). The `--format json`
|
|
2284
|
-
// branch above is the machine-to-machine remote-resolve transport (already
|
|
2285
|
-
// counted as an access on this machine by the resolve above), so it is
|
|
2286
|
-
// deliberately not counted here as an export — that would tally every peer
|
|
2287
|
-
// pull as an export.
|
|
2288
|
-
emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --plaintext', source: 'shell', status: 'success', keyCount: Object.keys(env).length });
|
|
2278
|
+
process.stdout.write(JSON.stringify(env));
|
|
2289
2279
|
}
|
|
2290
2280
|
catch (err) {
|
|
2291
2281
|
if (isPromptCancelled(err))
|
|
@@ -119,5 +119,6 @@ export declare function buildSessionPreviewDigest(events: SessionEvent[], sessio
|
|
|
119
119
|
export declare function directoriesTouched(session: SessionMeta, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
|
|
120
120
|
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
121
121
|
export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
|
|
122
|
+
export declare function renderLastResponse(content: string, maxLines?: number, width?: number): string[];
|
|
122
123
|
/** Show an interactive session picker and return the selected session with its action (resume or view). */
|
|
123
124
|
export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
|
|
@@ -17,6 +17,8 @@ import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/r
|
|
|
17
17
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
18
18
|
import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
|
|
19
19
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
20
|
+
import { wrapToWidth } from '../lib/wrap.js';
|
|
21
|
+
import { terminalWidth, stringWidth } from '../lib/session/width.js';
|
|
20
22
|
import { itemPicker } from '../lib/picker.js';
|
|
21
23
|
import { createMemoryCache } from '../lib/memory-cache.js';
|
|
22
24
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
@@ -435,8 +437,6 @@ function formatHeader(session, events) {
|
|
|
435
437
|
if (totalTokens !== undefined) {
|
|
436
438
|
line3.push(chalk.bold.white(formatTokens(totalTokens)) + chalk.gray(' tokens'));
|
|
437
439
|
}
|
|
438
|
-
if (session.label)
|
|
439
|
-
line3.push(chalk.white(session.label));
|
|
440
440
|
if (session.filePath)
|
|
441
441
|
line3.push(chalk.gray(linkPath(session.filePath, session.id)));
|
|
442
442
|
else
|
|
@@ -452,7 +452,19 @@ function formatHeader(session, events) {
|
|
|
452
452
|
const label = session.prNumber ? `PR#${session.prNumber}` : 'PR';
|
|
453
453
|
line4.push(chalk.blue(linkUrl(session.prUrl, label)));
|
|
454
454
|
}
|
|
455
|
+
// Lead with the session's human title: `session.label` — an agent-generated
|
|
456
|
+
// name / `/rename`, else the `--name` launch handle. NOT `session.topic`: the
|
|
457
|
+
// topic is the derived first-prompt, already shown on the `Prompt:` line, so
|
|
458
|
+
// using it here too would print the same text twice. Unlabelled sessions keep
|
|
459
|
+
// that `Prompt:` line as their topic indicator and simply lead with the agent
|
|
460
|
+
// line. Wrapped to the pane (the header sits at column 0, full terminal width);
|
|
461
|
+
// nothing renders when there is no label.
|
|
462
|
+
const title = (session.label || '').trim();
|
|
463
|
+
const titleLines = title
|
|
464
|
+
? wrapToWidth(title, terminalWidth()).map(l => chalk.bold.white(l))
|
|
465
|
+
: [];
|
|
455
466
|
return [
|
|
467
|
+
...titleLines,
|
|
456
468
|
line1.join(DOT),
|
|
457
469
|
line2.join(DOT),
|
|
458
470
|
line3.join(DOT),
|
|
@@ -821,7 +833,9 @@ function formatCompactPreview(digest, session) {
|
|
|
821
833
|
}
|
|
822
834
|
if (lastAssistant) {
|
|
823
835
|
const maxLines = todosRendered.length > 0 || compact ? LAST_RESPONSE_MAX_LINES_WITH_TODOS : LAST_RESPONSE_MAX_LINES;
|
|
824
|
-
|
|
836
|
+
// Content sits at column 4 (outer body indent + the ' ' prefix below), so
|
|
837
|
+
// wrap it to the pane minus that indent.
|
|
838
|
+
const rendered = renderLastResponse(lastAssistant, maxLines, terminalWidth() - 4);
|
|
825
839
|
if (rendered.length > 0) {
|
|
826
840
|
lines.push('');
|
|
827
841
|
lines.push(chalk.cyan('Last response:'));
|
|
@@ -999,7 +1013,7 @@ export function relativizeDir(filePath, cwd) {
|
|
|
999
1013
|
dir = parts.slice(-3).join('/');
|
|
1000
1014
|
return dir || undefined;
|
|
1001
1015
|
}
|
|
1002
|
-
function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES) {
|
|
1016
|
+
export function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES, width = terminalWidth()) {
|
|
1003
1017
|
const cleaned = stripTags(content).trim();
|
|
1004
1018
|
if (!cleaned)
|
|
1005
1019
|
return [];
|
|
@@ -1010,7 +1024,15 @@ function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES) {
|
|
|
1010
1024
|
catch {
|
|
1011
1025
|
rendered = cleaned;
|
|
1012
1026
|
}
|
|
1013
|
-
|
|
1027
|
+
// `marked-terminal` runs with `reflowText` off, so a paragraph with no hard
|
|
1028
|
+
// breaks renders as one long line that overflows the pane. Wrap any line whose
|
|
1029
|
+
// VISIBLE width exceeds the budget; leave lines that already fit untouched so
|
|
1030
|
+
// rendered-markdown indentation (lists, code blocks) is preserved. The text is
|
|
1031
|
+
// ANSI-coloured, so width is measured with `stringWidth`, never `String.length`.
|
|
1032
|
+
const all = rendered
|
|
1033
|
+
.replace(/\s+$/, '')
|
|
1034
|
+
.split('\n')
|
|
1035
|
+
.flatMap(line => (stringWidth(line) <= width ? [line] : wrapToWidth(line, width)));
|
|
1014
1036
|
// Drop leading/trailing empty lines
|
|
1015
1037
|
while (all.length && !all[0].trim())
|
|
1016
1038
|
all.shift();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
3
|
+
import { type PublishOptions } from '../lib/share/publish.js';
|
|
4
|
+
interface ShareOptions {
|
|
5
|
+
public?: boolean;
|
|
6
|
+
slug?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
expire?: string;
|
|
9
|
+
reasoning: string;
|
|
10
|
+
force?: boolean;
|
|
11
|
+
cover?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** `session-<shortId>` — stable per session, so re-sharing updates the same URL. */
|
|
14
|
+
export declare function defaultSessionSlug(session: SessionMeta): string;
|
|
15
|
+
/**
|
|
16
|
+
* Map the command's flags onto {@link PublishOptions}.
|
|
17
|
+
*
|
|
18
|
+
* Extracted so the defaults are testable directly. `unlisted` in particular is the
|
|
19
|
+
* security-relevant one — it inverts the parent command's public-by-default
|
|
20
|
+
* behavior, and a test that re-implements this mapping would still pass with it
|
|
21
|
+
* flipped.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildSharePublishOptions(session: SessionMeta, options: Pick<ShareOptions, 'public' | 'slug' | 'label' | 'expire' | 'force' | 'cover'>): PublishOptions;
|
|
24
|
+
export declare function registerSessionsShareCommand(sessionsCmd: Command): void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions share <id>` — publish one session transcript as a link.
|
|
3
|
+
*
|
|
4
|
+
* Composes three pieces that already existed but were never wired together:
|
|
5
|
+
* `renderSessionMarkdownDocument()` (redacted transcript),
|
|
6
|
+
* `renderSessionHtmlDocument()` (self-contained branded page), and
|
|
7
|
+
* `publishFile()` (the R2-backed share Worker). Before this, sharing a session
|
|
8
|
+
* meant three commands and a detour through the external artifacts-cli.
|
|
9
|
+
*
|
|
10
|
+
* Unlisted by default, unlike `agents artifacts share`. A transcript carries file
|
|
11
|
+
* paths, command output, error text, and whatever a tool printed — strictly more
|
|
12
|
+
* than a plan does — so it does not belong in the public `/<user>` gallery unless
|
|
13
|
+
* the operator asks for it with `--public`. The URL itself stays world-readable:
|
|
14
|
+
* unlisted is a capability URL, not a secret.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
import { setHelpSections } from '../lib/help.js';
|
|
21
|
+
import { knownSecretValuesFromEnv } from '../lib/redact.js';
|
|
22
|
+
import { discoverSessions } from '../lib/session/discover.js';
|
|
23
|
+
import { renderSessionHtmlDocument } from '../lib/session/share-html.js';
|
|
24
|
+
import { publishFile, redactEmails } from '../lib/share/publish.js';
|
|
25
|
+
import { renderSessionMarkdownDocument } from './sessions-render.js';
|
|
26
|
+
import { selectSessions } from './sessions-export.js';
|
|
27
|
+
import { parseAgentFilter } from './sessions.js';
|
|
28
|
+
function parseReasoning(value) {
|
|
29
|
+
if (value === 'omit' || value === 'fold' || value === 'include')
|
|
30
|
+
return value;
|
|
31
|
+
throw new Error(`Unknown reasoning mode "${value}". Expected omit, fold, or include.`);
|
|
32
|
+
}
|
|
33
|
+
/** `session-<shortId>` — stable per session, so re-sharing updates the same URL. */
|
|
34
|
+
export function defaultSessionSlug(session) {
|
|
35
|
+
return `session-${session.shortId || session.id}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Map the command's flags onto {@link PublishOptions}.
|
|
39
|
+
*
|
|
40
|
+
* Extracted so the defaults are testable directly. `unlisted` in particular is the
|
|
41
|
+
* security-relevant one — it inverts the parent command's public-by-default
|
|
42
|
+
* behavior, and a test that re-implements this mapping would still pass with it
|
|
43
|
+
* flipped.
|
|
44
|
+
*/
|
|
45
|
+
export function buildSharePublishOptions(session, options) {
|
|
46
|
+
return {
|
|
47
|
+
slug: options.slug ?? defaultSessionSlug(session),
|
|
48
|
+
unlisted: options.public !== true,
|
|
49
|
+
expire: options.expire,
|
|
50
|
+
force: options.force === true,
|
|
51
|
+
cover: options.cover !== false,
|
|
52
|
+
label: options.label,
|
|
53
|
+
meta: { kind: 'session' },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function registerSessionsShareCommand(sessionsCmd) {
|
|
57
|
+
const cmd = sessionsCmd
|
|
58
|
+
.command('share <session>')
|
|
59
|
+
.description('Publish one session as a redacted, self-contained web page and print the link.')
|
|
60
|
+
.option('--public', 'List the page in your public share gallery (default: unlisted capability URL)')
|
|
61
|
+
.option('--slug <slug>', 'URL slug under your namespace (default: session-<shortId>)')
|
|
62
|
+
.option('--label <text>', 'Display title in the gallery and `agents artifacts share list`')
|
|
63
|
+
.option('--expire <spec>', 'Auto-expire window: 30d, 12h, a date, or never (default: 30d)')
|
|
64
|
+
.option('--reasoning <mode>', 'Reasoning visibility: omit, fold, or include', 'omit')
|
|
65
|
+
.option('--force', 'Publish despite the sensitive-content scan flagging the transcript')
|
|
66
|
+
.option('--no-cover', 'Skip generating the Open Graph preview image');
|
|
67
|
+
setHelpSections(cmd, {
|
|
68
|
+
examples: `# Share a session — prints an unlisted, redacted link
|
|
69
|
+
agents sessions share a1b2c3d4
|
|
70
|
+
|
|
71
|
+
# Put it in your public gallery at share.agents-cli.sh/<you>
|
|
72
|
+
agents sessions share a1b2c3d4 --public --label "How the retry bug got fixed"
|
|
73
|
+
|
|
74
|
+
# Keep the model's reasoning in collapsible sections
|
|
75
|
+
agents sessions share a1b2c3d4 --reasoning fold
|
|
76
|
+
|
|
77
|
+
# A link that does not decay
|
|
78
|
+
agents sessions share a1b2c3d4 --expire never`,
|
|
79
|
+
notes: `Requires a share endpoint: 'agents artifacts share status' shows it, 'agents artifacts setup'
|
|
80
|
+
provisions one, 'agents artifacts share join <baseUrl>' uses an existing one.
|
|
81
|
+
|
|
82
|
+
Unlisted by default — the URL is world-readable but the page stays out of your public
|
|
83
|
+
gallery and out of 'agents artifacts share list'. Pass --public to list it.
|
|
84
|
+
|
|
85
|
+
Secrets are redacted and the same pre-publish scan as 'agents artifacts share' runs on the
|
|
86
|
+
page, so a transcript carrying emails or credential-shaped strings is refused
|
|
87
|
+
unless you pass --force. --no-redact (the sessions-level flag) disables redaction
|
|
88
|
+
and is a bad idea for anything you publish.
|
|
89
|
+
|
|
90
|
+
Re-running with the same session updates the same URL and keeps the prior version
|
|
91
|
+
as a revision ('agents artifacts share revisions <slug>').
|
|
92
|
+
|
|
93
|
+
Manage published sessions with 'agents artifacts share list' and
|
|
94
|
+
'agents artifacts share delete <slug>'.`,
|
|
95
|
+
});
|
|
96
|
+
cmd.action(async (selector, options, command) => {
|
|
97
|
+
const globals = command.optsWithGlobals();
|
|
98
|
+
const reasoning = parseReasoning(options.reasoning);
|
|
99
|
+
const limit = Math.max(1, Number.parseInt(globals.limit || '100', 10) || 100);
|
|
100
|
+
const agent = parseAgentFilter(globals.agent).agent;
|
|
101
|
+
const sessions = selectSessions(await discoverSessions({
|
|
102
|
+
all: globals.all !== false,
|
|
103
|
+
since: globals.since,
|
|
104
|
+
limit,
|
|
105
|
+
agent: agent ?? undefined,
|
|
106
|
+
}), [selector]);
|
|
107
|
+
if (sessions.length === 0) {
|
|
108
|
+
process.stderr.write(chalk.yellow(`No session matched "${selector}".\n`));
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// One link per share: several sessions in one page would give the reader no
|
|
113
|
+
// way to reference just the one that matters, and the slug could only name one.
|
|
114
|
+
if (sessions.length > 1) {
|
|
115
|
+
process.stderr.write(chalk.yellow(`"${selector}" matched ${sessions.length} sessions. Share one at a time — pass a full or unique session id.\n`));
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const session = sessions[0];
|
|
120
|
+
const redact = globals.redact !== false;
|
|
121
|
+
const markdown = renderSessionMarkdownDocument(session, {
|
|
122
|
+
redact,
|
|
123
|
+
reasoning,
|
|
124
|
+
knownSecrets: redact ? knownSecretValuesFromEnv() : undefined,
|
|
125
|
+
});
|
|
126
|
+
// Emails on top of what the renderer masks. Almost every real transcript
|
|
127
|
+
// carries a few — git author addresses, `gh api user`, a pasted log — and
|
|
128
|
+
// publishToEndpoint refuses a body containing any (RUSH-2428). Masking them
|
|
129
|
+
// means the published page genuinely does not carry them; the alternative,
|
|
130
|
+
// telling people to pass --force, would train everyone to bypass the gate
|
|
131
|
+
// that also catches real credentials.
|
|
132
|
+
//
|
|
133
|
+
// Applied to the RENDERED PAGE, not the Markdown, so the text that is masked
|
|
134
|
+
// is exactly the text `scanShareContent` will scan. Markdown escaping stands
|
|
135
|
+
// between the two: `foo\@example.com` hides from the pattern in the Markdown
|
|
136
|
+
// and reappears as a live address once marked drops the backslash.
|
|
137
|
+
const page = renderSessionHtmlDocument(session, markdown, { redacted: redact });
|
|
138
|
+
const html = redact ? redactEmails(page) : page;
|
|
139
|
+
// A real file on disk is what publishFile() takes, and the OG capturer opens
|
|
140
|
+
// it in a browser. 0600 + a per-run directory keeps the intermediate off a
|
|
141
|
+
// world-readable /tmp path while it exists.
|
|
142
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-session-share-'));
|
|
143
|
+
const file = path.join(dir, `${defaultSessionSlug(session)}.html`);
|
|
144
|
+
try {
|
|
145
|
+
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
146
|
+
const result = await publishFile(file, buildSharePublishOptions(session, options));
|
|
147
|
+
if (globals.json) {
|
|
148
|
+
process.stdout.write(JSON.stringify({
|
|
149
|
+
session: session.id,
|
|
150
|
+
agent: session.agent,
|
|
151
|
+
redacted: redact,
|
|
152
|
+
...result,
|
|
153
|
+
}, null, 2) + '\n');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(`${result.url}\n`);
|
|
157
|
+
const bits = [result.unlisted ? 'unlisted' : 'public', redact ? 'redacted' : chalk.red('NOT redacted')];
|
|
158
|
+
if (result.expiresAt)
|
|
159
|
+
bits.push(`expires ${result.expiresAt.slice(0, 10)}`);
|
|
160
|
+
process.stderr.write(chalk.dim(`${bits.join(' · ')}\n`));
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -70,6 +70,7 @@ import { registerAttachCommand } from './attach.js';
|
|
|
70
70
|
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
71
71
|
import { registerSessionsExportCommand } from './sessions-export.js';
|
|
72
72
|
import { registerSessionsRenderCommand } from './sessions-render.js';
|
|
73
|
+
import { registerSessionsShareCommand } from './sessions-share.js';
|
|
73
74
|
import { registerSessionsImportCommand } from './sessions-import.js';
|
|
74
75
|
import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
|
|
75
76
|
import { registerSessionsBackfillCommand } from './sessions-backfill.js';
|
|
@@ -5173,6 +5174,7 @@ export function registerSessionsCommands(program) {
|
|
|
5173
5174
|
registerSessionsInjectCommand(sessionsCmd);
|
|
5174
5175
|
registerSessionsExportCommand(sessionsCmd);
|
|
5175
5176
|
registerSessionsRenderCommand(sessionsCmd);
|
|
5177
|
+
registerSessionsShareCommand(sessionsCmd);
|
|
5176
5178
|
registerSessionsImportCommand(sessionsCmd);
|
|
5177
5179
|
registerSessionsMigrateCommand(sessionsCmd);
|
|
5178
5180
|
registerSessionsMigrationsCommand(sessionsCmd);
|
|
@@ -14,7 +14,7 @@ import { DEFAULT_BROWSER_PROFILE_NAME, createProfile, findFreeProfilePort, getCo
|
|
|
14
14
|
import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
|
|
15
15
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
16
16
|
import { defaultBrowserChoice } from './setup-preferences.js';
|
|
17
|
-
const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chromium, or
|
|
17
|
+
const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chromium, Comet, or Arc, then re-run `agents setup browser`.\n' +
|
|
18
18
|
'(Safari and Firefox are not supported — agents browser drives over the Chrome DevTools Protocol.)';
|
|
19
19
|
/**
|
|
20
20
|
* Interactive browser setup. Returns true if a usable default profile exists
|
|
@@ -115,10 +115,10 @@ export async function maybePickBrowserProfile() {
|
|
|
115
115
|
viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
|
|
116
116
|
};
|
|
117
117
|
await createProfile(profile);
|
|
118
|
-
// The same key `agents browser
|
|
118
|
+
// The same key `agents browser use` writes (device-local).
|
|
119
119
|
setConfigValue('browser.profile', profile.name);
|
|
120
120
|
console.log(chalk.green(`Browser: '${chosen.browserType}'`) +
|
|
121
|
-
chalk.dim(` — profile "${profile.name}" is this machine's default (agents browser
|
|
121
|
+
chalk.dim(` — profile "${profile.name}" is this machine's default (agents browser use to change).`));
|
|
122
122
|
return true;
|
|
123
123
|
}
|
|
124
124
|
/**
|