@phnx-labs/agents-cli 1.22.53 → 1.22.55
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 +208 -0
- package/README.md +59 -9
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +219 -173
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +10 -8
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +2 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +7 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/routines.js +31 -2
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +64 -20
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +79 -3
- package/dist/commands/share.js +347 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +18 -2
- package/dist/commands/status.js +14 -0
- package/dist/commands/view.d.ts +11 -1
- package/dist/commands/view.js +35 -7
- package/dist/lib/account-registry.js +15 -3
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +68 -1
- package/dist/lib/accounting/usage.js +116 -10
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/cdp.d.ts +1 -1
- package/dist/lib/browser/cdp.js +1 -1
- package/dist/lib/browser/ffmpeg.d.ts +12 -0
- package/dist/lib/browser/ffmpeg.js +184 -0
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/browser/service.js +119 -25
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
- package/dist/lib/daemon/browser-task-reap-service.js +26 -0
- package/dist/lib/daemon/daemon.js +87 -176
- package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
- package/dist/lib/daemon/heartbeat-service.js +26 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
- package/dist/lib/daemon/monitor-engine-service.js +15 -7
- package/dist/lib/daemon/runner.d.ts +15 -0
- package/dist/lib/daemon/runner.js +23 -0
- package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
- package/dist/lib/daemon/secrets-broker-service.js +17 -32
- package/dist/lib/daemon/service.d.ts +2 -2
- package/dist/lib/daemon/service.js +1 -1
- package/dist/lib/daemon/session-state-service.d.ts +21 -0
- package/dist/lib/daemon/session-state-service.js +34 -0
- package/dist/lib/daemon/supervisor.d.ts +17 -7
- package/dist/lib/daemon/supervisor.js +87 -14
- package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
- package/dist/lib/daemon/tmux-reap-service.js +28 -0
- package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
- package/dist/lib/daemon/webhook-receiver-service.js +17 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +25 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +5 -5
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -1
- package/dist/lib/devices/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/devices/harness-inventory.js +5 -2
- package/dist/lib/exec.d.ts +28 -0
- package/dist/lib/exec.js +73 -7
- package/dist/lib/feed/feed.d.ts +1 -1
- package/dist/lib/feed/feed.js +23 -1
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- package/dist/lib/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.d.ts +5 -1
- package/dist/lib/monitors/engine.js +13 -4
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/scheduling/routines.d.ts +47 -0
- package/dist/lib/scheduling/routines.js +70 -1
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.js +1 -4
- package/dist/lib/session/db.d.ts +41 -5
- package/dist/lib/session/db.js +132 -30
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +119 -25
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/share/capture.js +11 -2
- package/dist/lib/share/publish.d.ts +56 -5
- package/dist/lib/share/publish.js +126 -18
- package/dist/lib/share/worker-template.d.ts +3 -12
- package/dist/lib/share/worker-template.js +860 -59
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +16 -0
- package/dist/lib/state.js +178 -46
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/traces/classify.js +24 -19
- package/dist/lib/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +7 -0
- package/package.json +9 -2
|
@@ -86,6 +86,43 @@ export function wrapRemoteCommand(device, cmd) {
|
|
|
86
86
|
}
|
|
87
87
|
return joined;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
|
|
91
|
+
* the standalone `browser` binary (`cli/package.json` `bin.browser` →
|
|
92
|
+
* `src/browser.ts`), including the quoted single-string form
|
|
93
|
+
* `agents ssh box 'browser …'`. The `--device` passthrough stamps
|
|
94
|
+
* {@link markFleetRemote} on this shape so the far-side consent gate can fire;
|
|
95
|
+
* `agents ssh` must too (PHNX-3065).
|
|
96
|
+
*/
|
|
97
|
+
export function isAgentsBrowserDrive(cmd) {
|
|
98
|
+
if (cmd.length === 0)
|
|
99
|
+
return false;
|
|
100
|
+
const tokens = cmd.length === 1 && /\s/.test(cmd[0]) ? cmd[0].trim().split(/\s+/) : cmd;
|
|
101
|
+
const bin = tokens[0];
|
|
102
|
+
if (bin === 'browser')
|
|
103
|
+
return true;
|
|
104
|
+
return (bin === 'agents' || bin === 'ag') && tokens[1] === 'browser';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
|
|
108
|
+
* marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
|
|
109
|
+
* with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
110
|
+
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
111
|
+
* `$env:VAR='1'; …` on PowerShell.
|
|
112
|
+
*
|
|
113
|
+
* Already-marked argv (the `--device` fan-out stamps this before
|
|
114
|
+
* {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
|
|
115
|
+
*/
|
|
116
|
+
export function markFleetRemote(cmd, device) {
|
|
117
|
+
if (device.shell === 'powershell') {
|
|
118
|
+
return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`
|
|
119
|
+
? cmd
|
|
120
|
+
: [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd];
|
|
121
|
+
}
|
|
122
|
+
return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1'
|
|
123
|
+
? cmd
|
|
124
|
+
: ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
|
|
125
|
+
}
|
|
89
126
|
/**
|
|
90
127
|
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
91
128
|
* mirrored project directory, falling back to the remote home when that
|
|
@@ -156,6 +193,12 @@ export function deviceIdentityArgs(device) {
|
|
|
156
193
|
* to the remote home), matching `agents run --device`. It is ignored when a `cmd`
|
|
157
194
|
* is given: an explicit command keeps its current cwd (the remote home) and its
|
|
158
195
|
* behavior unchanged (RUSH-2412).
|
|
196
|
+
*
|
|
197
|
+
* An `agents browser …` / `ag browser …` / standalone `browser …` command is
|
|
198
|
+
* prefixed with {@link markFleetRemote} so the far-side consent gate sees
|
|
199
|
+
* `AGENTS_FLEET_REMOTE=1` the same way `browser --device` does. The local `env`
|
|
200
|
+
* overlay here is for the ssh *client* (askpass); it cannot carry the marker —
|
|
201
|
+
* OpenSSH does not forward arbitrary env (PHNX-3065).
|
|
159
202
|
*/
|
|
160
203
|
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
|
|
161
204
|
// The effective profile: central config (ssh.*/platform/user) overlaid on
|
|
@@ -166,9 +209,13 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
|
|
|
166
209
|
// wrapper (interactiveCwd), which is an interactive login too and still needs
|
|
167
210
|
// a real tty below.
|
|
168
211
|
const interactive = cmd.length === 0;
|
|
212
|
+
// Stamp the consent marker on the REMOTE command, not the local ssh env:
|
|
213
|
+
// SSH_ASKPASS lives on this side; AGENTS_FLEET_REMOTE must be visible to the
|
|
214
|
+
// process that runs on the peer.
|
|
215
|
+
const remoteCmd = !interactive && isAgentsBrowserDrive(cmd) ? markFleetRemote(cmd, device) : cmd;
|
|
169
216
|
const remote = interactive
|
|
170
217
|
? buildInteractiveShellCommand(device, opts.interactiveCwd)
|
|
171
|
-
: wrapRemoteCommand(device,
|
|
218
|
+
: wrapRemoteCommand(device, remoteCmd);
|
|
172
219
|
const env = {};
|
|
173
220
|
const args = [
|
|
174
221
|
...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
|
|
@@ -13,7 +13,7 @@ export type FindingSeverity = 'critical' | 'warning';
|
|
|
13
13
|
* the JSON consumer group by kind. */
|
|
14
14
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
15
15
|
* below owns it, and a second copy in these comments is a fourth place to drift. */
|
|
16
|
-
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable"];
|
|
16
|
+
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "auth-bundle-wrong-backend", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable"];
|
|
17
17
|
/**
|
|
18
18
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
19
19
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -100,6 +100,10 @@ export interface LocalFindingInputs {
|
|
|
100
100
|
* scan reports clean while the leak is still in flight (RUSH-1968). Never the
|
|
101
101
|
* value — only whether it is set. */
|
|
102
102
|
masterPassphraseInEnv?: boolean;
|
|
103
|
+
/** True when the reserved `auth` bundle exists on a non-file backend
|
|
104
|
+
* (SEC-GAP-3): usage/probe ignores the setup-tokens and falls through to
|
|
105
|
+
* Touch ID. Collected by `inspectReservedAuthBundle`. */
|
|
106
|
+
authBundleWrongBackend?: boolean;
|
|
103
107
|
/** The effective PowerShell execution policy and the platform it was read on.
|
|
104
108
|
* Only `win32` yields a finding — the `agents.ps1` launcher is Windows-only. */
|
|
105
109
|
execPolicy?: {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* never-synced · stale · repo-behind · repo-drift · version-skew ·
|
|
27
27
|
* fleet-resource-gap · hook-runtime-visibility-unavailable · orphan · duplicate-hook ·
|
|
28
28
|
* duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
|
|
29
|
-
* rc-secret-export · env-secret-export · exec-policy · stale-cli ·
|
|
29
|
+
* rc-secret-export · env-secret-export · auth-bundle-wrong-backend · exec-policy · stale-cli ·
|
|
30
30
|
* binary-shadow.
|
|
31
31
|
* (RUSH-2162 moved never-synced and duplicate-hook-drift to WARNING: both are
|
|
32
32
|
* stale-sync states one `agents sync` resolves, not "needs you now".)
|
|
@@ -102,6 +102,7 @@ export const ALL_FINDING_KINDS = [
|
|
|
102
102
|
'duplicate-hook-drift', // …with differing content, so a stale copy can disagree
|
|
103
103
|
'rc-secret-export', // credential-shaped export in a shell rc file
|
|
104
104
|
'env-secret-export', // the file-store master key live in THIS process's env
|
|
105
|
+
'auth-bundle-wrong-backend', // reserved `auth` bundle exists but is not file-backed
|
|
105
106
|
'exec-policy', // Windows execution policy blocks agents.ps1
|
|
106
107
|
'ssh-key-enrollment', // Windows OpenSSH public-key path/content/ACL is invalid
|
|
107
108
|
'stale-cli',
|
|
@@ -150,6 +151,7 @@ export const FINDING_SEVERITY = {
|
|
|
150
151
|
'host-cli-invalid': 'warning',
|
|
151
152
|
'rc-secret-export': 'warning',
|
|
152
153
|
'env-secret-export': 'warning',
|
|
154
|
+
'auth-bundle-wrong-backend': 'warning',
|
|
153
155
|
'exec-policy': 'warning',
|
|
154
156
|
'ssh-key-enrollment': 'critical',
|
|
155
157
|
'stale-cli': 'warning',
|
|
@@ -269,6 +271,8 @@ export function remediationFor(finding) {
|
|
|
269
271
|
// that inherited it — an editor, a tmux server, the agents daemon — and
|
|
270
272
|
// each keeps handing it to new children until IT restarts.
|
|
271
273
|
return 'unset at the source, then restart every process that inherited it (shells, editor, tmux, agents daemon)';
|
|
274
|
+
case 'auth-bundle-wrong-backend':
|
|
275
|
+
return 'agents secrets delete auth --yes && agents secrets create auth --backend file';
|
|
272
276
|
case 'exec-policy':
|
|
273
277
|
return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
|
|
274
278
|
case 'ssh-key-enrollment':
|
|
@@ -519,6 +523,9 @@ export function buildLocalFindings(input) {
|
|
|
519
523
|
const envFinding = envSecretFinding(device, input.masterPassphraseInEnv ?? false);
|
|
520
524
|
if (envFinding)
|
|
521
525
|
out.push(envFinding);
|
|
526
|
+
const authFinding = authBundleWrongBackendFinding(device, input.authBundleWrongBackend ?? false);
|
|
527
|
+
if (authFinding)
|
|
528
|
+
out.push(authFinding);
|
|
522
529
|
// Windows execution policy blocking the generated agents.ps1 launcher.
|
|
523
530
|
const policyFinding = execPolicyFinding(device, input.execPolicy);
|
|
524
531
|
if (policyFinding)
|
|
@@ -688,6 +695,15 @@ function envSecretFinding(device, present) {
|
|
|
688
695
|
+ '(Expected inside a release sign context, which sets it deliberately.)',
|
|
689
696
|
});
|
|
690
697
|
}
|
|
698
|
+
function authBundleWrongBackendFinding(device, present) {
|
|
699
|
+
if (!present)
|
|
700
|
+
return null;
|
|
701
|
+
return finding({
|
|
702
|
+
severity: FINDING_SEVERITY['auth-bundle-wrong-backend'], kind: 'auth-bundle-wrong-backend', device,
|
|
703
|
+
message: "reserved secrets bundle 'auth' exists but is not file-backed — "
|
|
704
|
+
+ 'usage/probe ignores the setup-tokens and falls through to the interactive login',
|
|
705
|
+
});
|
|
706
|
+
}
|
|
691
707
|
function execPolicyFinding(device, execPolicy) {
|
|
692
708
|
if (!execPolicy || execPolicy.platform !== 'win32')
|
|
693
709
|
return null;
|
|
@@ -1038,6 +1054,8 @@ function warningSubject(f) {
|
|
|
1038
1054
|
return 'shell rc';
|
|
1039
1055
|
if (f.kind === 'env-secret-export')
|
|
1040
1056
|
return 'environment';
|
|
1057
|
+
if (f.kind === 'auth-bundle-wrong-backend')
|
|
1058
|
+
return 'auth bundle';
|
|
1041
1059
|
if (f.kind === 'exec-policy')
|
|
1042
1060
|
return 'PowerShell';
|
|
1043
1061
|
if (f.kind === 'fleet-resource-gap')
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import { ALL_AGENT_IDS, accountDisplayLabel, getAccountInfo, } from '../agents.js';
|
|
18
|
-
import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, } from '../accounting/usage.js';
|
|
18
|
+
import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, usageErrorForDisplay, } from '../accounting/usage.js';
|
|
19
19
|
import { getVersionHomePath, listInstalledVersions } from '../installations/store.js';
|
|
20
20
|
import { readMeta } from '../state.js';
|
|
21
21
|
import { listNativeAccounts } from '../account-registry.js';
|
|
@@ -123,7 +123,10 @@ export async function collectLocalHarnessInventory(opts) {
|
|
|
123
123
|
const key = getUsageLookupKey(info);
|
|
124
124
|
const usage = key ? usageByKey.get(key) : undefined;
|
|
125
125
|
const snapshot = usage?.snapshot ?? null;
|
|
126
|
-
|
|
126
|
+
// Normalize the internal 'stale' not-collected sentinel out before it reaches
|
|
127
|
+
// the JSON `quota.unavailableReason` — same leak PHNX-3348 fixed for
|
|
128
|
+
// `agents view --json`, here for `agents devices harnesses/accounts --json`.
|
|
129
|
+
const quota = summarizeQuota(snapshot, usageErrorForDisplay(usage?.error), info?.usageStatus ?? null);
|
|
127
130
|
const signedIn = !!info?.signedIn;
|
|
128
131
|
const { ready, reason } = computeReady(signedIn, quota);
|
|
129
132
|
const display = info ? accountDisplayLabel(info) || null : null;
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentId, Mode } from './types.js';
|
|
2
|
+
import { type UsernsStatus } from './linux-userns.js';
|
|
2
3
|
/**
|
|
3
4
|
* Agent execution modes. Canonical name `skip` (dangerously skip permissions);
|
|
4
5
|
* `full` is accepted as a permanent silent alias via normalizeMode().
|
|
@@ -88,6 +89,33 @@ export interface ModeWarningState {
|
|
|
88
89
|
export declare function defaultModeFor(agent: AgentId): Mode;
|
|
89
90
|
/** Safe mode used when the user did not provide --mode or a configured default. */
|
|
90
91
|
export declare function implicitModeFor(agent: AgentId): ExecMode;
|
|
92
|
+
/**
|
|
93
|
+
* Preflight for Codex's Linux sandbox. Codex ≥0.146 sandboxes `read-only` and
|
|
94
|
+
* `workspace-write` runs with a bundled bubblewrap that needs an unprivileged
|
|
95
|
+
* user namespace; on a box that restricts it (Ubuntu 24.04
|
|
96
|
+
* `apparmor_restrict_unprivileged_userns=1`) bwrap dies with "setting up uid map:
|
|
97
|
+
* Permission denied" and a HEADLESS codex run lands zero tools — no file writes,
|
|
98
|
+
* no shell — while still reporting a completed turn. That silent under-delivery
|
|
99
|
+
* is what breaks `agents teams` codex teammates (always headless + workspace-write)
|
|
100
|
+
* and headless `agents run codex` alike on the fleet. Returns a loud, actionable
|
|
101
|
+
* message to fail the launch with instead of spawning that doomed run; returns
|
|
102
|
+
* null when the run is fine to proceed.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately scoped: only `codex`, only Linux, only a HEADLESS run (an
|
|
105
|
+
* interactive TUI surfaces the bwrap error to the operator itself), and only a
|
|
106
|
+
* SANDBOXED mode — `skip` is codex `--dangerously-bypass-approvals-and-sandbox`,
|
|
107
|
+
* which uses no bwrap and is unaffected. The intended auto=workspace-write config
|
|
108
|
+
* is never weakened here; the run fails loud rather than silently downgrading.
|
|
109
|
+
* PHNX-3285.
|
|
110
|
+
*/
|
|
111
|
+
export declare function codexSandboxPreflight(args: {
|
|
112
|
+
agent: AgentId;
|
|
113
|
+
platform: NodeJS.Platform;
|
|
114
|
+
interactive: boolean;
|
|
115
|
+
mode: Mode;
|
|
116
|
+
userns: UsernsStatus;
|
|
117
|
+
machine: string;
|
|
118
|
+
}): string | null;
|
|
91
119
|
/** Reasoning effort levels passed to agents that support them. 'auto' defers to the agent's default. */
|
|
92
120
|
export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
|
|
93
121
|
/** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
|
package/dist/lib/exec.js
CHANGED
|
@@ -32,6 +32,7 @@ import { isTmuxEnabled, selfConfiguredDeviceRole } from './device-config.js';
|
|
|
32
32
|
import { machineId } from './machine-id.js';
|
|
33
33
|
import { shellQuote } from './ssh-exec.js';
|
|
34
34
|
import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
|
|
35
|
+
import { probeUnprivilegedUserns } from './linux-userns.js';
|
|
35
36
|
import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
36
37
|
import { applyAddDirs } from './add-dir.js';
|
|
37
38
|
import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
|
|
@@ -181,6 +182,49 @@ export function defaultModeFor(agent) {
|
|
|
181
182
|
export function implicitModeFor(agent) {
|
|
182
183
|
return agent === 'codex' ? 'edit' : 'plan';
|
|
183
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Preflight for Codex's Linux sandbox. Codex ≥0.146 sandboxes `read-only` and
|
|
187
|
+
* `workspace-write` runs with a bundled bubblewrap that needs an unprivileged
|
|
188
|
+
* user namespace; on a box that restricts it (Ubuntu 24.04
|
|
189
|
+
* `apparmor_restrict_unprivileged_userns=1`) bwrap dies with "setting up uid map:
|
|
190
|
+
* Permission denied" and a HEADLESS codex run lands zero tools — no file writes,
|
|
191
|
+
* no shell — while still reporting a completed turn. That silent under-delivery
|
|
192
|
+
* is what breaks `agents teams` codex teammates (always headless + workspace-write)
|
|
193
|
+
* and headless `agents run codex` alike on the fleet. Returns a loud, actionable
|
|
194
|
+
* message to fail the launch with instead of spawning that doomed run; returns
|
|
195
|
+
* null when the run is fine to proceed.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately scoped: only `codex`, only Linux, only a HEADLESS run (an
|
|
198
|
+
* interactive TUI surfaces the bwrap error to the operator itself), and only a
|
|
199
|
+
* SANDBOXED mode — `skip` is codex `--dangerously-bypass-approvals-and-sandbox`,
|
|
200
|
+
* which uses no bwrap and is unaffected. The intended auto=workspace-write config
|
|
201
|
+
* is never weakened here; the run fails loud rather than silently downgrading.
|
|
202
|
+
* PHNX-3285.
|
|
203
|
+
*/
|
|
204
|
+
export function codexSandboxPreflight(args) {
|
|
205
|
+
if (args.agent !== 'codex')
|
|
206
|
+
return null;
|
|
207
|
+
if (args.platform !== 'linux')
|
|
208
|
+
return null;
|
|
209
|
+
if (args.interactive)
|
|
210
|
+
return null;
|
|
211
|
+
if (args.mode === 'skip')
|
|
212
|
+
return null;
|
|
213
|
+
if (args.userns.state !== 'blocked')
|
|
214
|
+
return null;
|
|
215
|
+
const reason = args.userns.reason ?? 'unprivileged user namespaces are restricted';
|
|
216
|
+
return (`codex's Linux sandbox can't start on ${args.machine}: ${reason}, so codex's ` +
|
|
217
|
+
`bundled bubblewrap fails with "bwrap: setting up uid map: Permission denied" ` +
|
|
218
|
+
`and a headless run lands zero tools. (PHNX-3285)\n` +
|
|
219
|
+
`\n` +
|
|
220
|
+
`Enable it once on this box — keeps codex's workspace-write sandbox intact:\n` +
|
|
221
|
+
` echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/60-codex-userns.conf\n` +
|
|
222
|
+
` sudo sysctl --system\n` +
|
|
223
|
+
` # verify: unshare --user --map-root-user true (exit 0 = fixed)\n` +
|
|
224
|
+
`(agents-cli ships cli/scripts/enable-codex-sandbox.sh to apply + verify this.)\n` +
|
|
225
|
+
`\n` +
|
|
226
|
+
`Or run codex WITHOUT a filesystem sandbox instead: add --mode skip.`);
|
|
227
|
+
}
|
|
184
228
|
/**
|
|
185
229
|
* Identity a custom-harness run stamps on env / pid-registry / sidecars.
|
|
186
230
|
* `agent` is the host CLI; `harnessName` is the profile the user launched.
|
|
@@ -797,8 +841,13 @@ export function buildExecCommand(options) {
|
|
|
797
841
|
// that maps to reasoning effort on a single-model harness (e.g. Grok, where the
|
|
798
842
|
// tier IS the effort dial). `modelVersion` is null when no version resolves;
|
|
799
843
|
// `tierModel` is the concrete model a tier resolved to (null => drop the flag).
|
|
844
|
+
// Prefer the user's explicit --model. Codex falls back to ~/.codex/config.toml
|
|
845
|
+
// (per-version CODEX_HOME may not carry that setting). OpenCode has no model
|
|
846
|
+
// env var — a custom-harness pin lives in OPENCODE_MODEL and must become
|
|
847
|
+
// `--model` here, or the host silently uses its configured default (PHNX-2577).
|
|
800
848
|
const effectiveModel = options.model
|
|
801
|
-
?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined)
|
|
849
|
+
?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined)
|
|
850
|
+
?? (options.agent === 'opencode' ? options.env?.OPENCODE_MODEL : undefined);
|
|
802
851
|
const modelVersion = effectiveModel && template.modelFlag
|
|
803
852
|
? (options.version || resolveVersion(options.agent, options.cwd || process.cwd()))
|
|
804
853
|
: null;
|
|
@@ -906,12 +955,8 @@ export function buildExecCommand(options) {
|
|
|
906
955
|
else if (options.sessionId && options.agent === 'claude') {
|
|
907
956
|
cmd.push('--session-id', options.sessionId);
|
|
908
957
|
}
|
|
909
|
-
// Add model.
|
|
910
|
-
//
|
|
911
|
-
// Codex runs under a per-version CODEX_HOME (see buildExecEnv) that may not
|
|
912
|
-
// carry that setting, so without this it silently defaults to gpt-5.3-codex,
|
|
913
|
-
// which a ChatGPT-tier account can't use (HTTP 400). Forwarding keeps the
|
|
914
|
-
// user's default model setup for both `agents run` and `agents teams`.
|
|
958
|
+
// Add model. `effectiveModel` already preferred --model, then Codex's
|
|
959
|
+
// configured default, then an OpenCode custom-harness pin (OPENCODE_MODEL).
|
|
915
960
|
if (effectiveModel && template.modelFlag) {
|
|
916
961
|
if (tierResolved) {
|
|
917
962
|
// Cost tier (cheap|default|best|ultra) -> a concrete model this harness+
|
|
@@ -1653,6 +1698,27 @@ async function spawnAgent(options) {
|
|
|
1653
1698
|
const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
|
|
1654
1699
|
const piped = !process.stdout.isTTY;
|
|
1655
1700
|
const interactive = resolveInteractive(options);
|
|
1701
|
+
// Fail loud before spawning a headless codex whose Linux sandbox can't start —
|
|
1702
|
+
// otherwise it burns a turn and lands zero tools (PHNX-3285). Cheap gate first
|
|
1703
|
+
// so the userns probe (one `unshare`) runs only for the exact case it applies to
|
|
1704
|
+
// (codex + Linux + headless + a sandboxed mode), never for every spawn.
|
|
1705
|
+
if (options.agent === 'codex' && process.platform === 'linux' && !interactive) {
|
|
1706
|
+
const codexMode = options.mode ? normalizeMode(options.mode) : defaultModeFor(options.agent);
|
|
1707
|
+
if (codexMode !== 'skip') {
|
|
1708
|
+
const message = codexSandboxPreflight({
|
|
1709
|
+
agent: options.agent,
|
|
1710
|
+
platform: process.platform,
|
|
1711
|
+
interactive,
|
|
1712
|
+
mode: codexMode,
|
|
1713
|
+
userns: probeUnprivilegedUserns(),
|
|
1714
|
+
machine: machineId(),
|
|
1715
|
+
});
|
|
1716
|
+
if (message) {
|
|
1717
|
+
process.stderr.write(`\x1b[31m${message}\x1b[0m\n`);
|
|
1718
|
+
return { exitCode: 1, stdout: '', stderr: message };
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1656
1722
|
// Budget live kill-switch (issue #346). For headless runs we incrementally
|
|
1657
1723
|
// parse stream-json usage off stdout, accumulate cost, and kill the child the
|
|
1658
1724
|
// moment a configured cap is crossed — exactly like the --timeout path, but
|
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -342,7 +342,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
342
342
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
343
343
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
344
344
|
*/
|
|
345
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so AGI EXT can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
345
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n # A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)\n # so a stale lifecycle re-read cannot resurrect this generation.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n now_iso = datetime.now(timezone.utc).isoformat()\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": now_iso,\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Tombstone first, then drop the open-block view. A missing/corrupt\n # block means there is nothing to resolve; fail open.\n existing = read_json(target)\n if isinstance(existing, dict):\n generation = existing.get(\"generation\") or existing.get(\"ts\")\n if generation:\n tombstone = {\n \"blockId\": block_id,\n \"generation\": generation,\n \"resolvedAt\": now_iso,\n \"reason\": \"answered\",\n }\n source_cursor = existing.get(\"sourceCursor\")\n if source_cursor:\n tombstone[\"sourceCursor\"] = source_cursor\n write_json(\n os.path.join(feed_dir, \"resolutions\", f\"{block_id}.json\"),\n tombstone,\n )\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so AGI EXT can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
346
346
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
347
347
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
348
348
|
name: string;
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -666,13 +666,16 @@ def main():
|
|
|
666
666
|
# Terminal answers (human typed in the TUI) record an answered marker and
|
|
667
667
|
# remove the block file so the feed stops showing it within one poll cycle.
|
|
668
668
|
# The marker stays behind so a concurrent surface cannot double-answer.
|
|
669
|
+
# A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)
|
|
670
|
+
# so a stale lifecycle re-read cannot resurrect this generation.
|
|
669
671
|
if hook_event == "UserPromptSubmit":
|
|
670
672
|
os.makedirs(answered_dir, exist_ok=True)
|
|
671
673
|
marker = os.path.join(answered_dir, f"{block_id}.json")
|
|
674
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
672
675
|
try:
|
|
673
676
|
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
674
677
|
record = {
|
|
675
|
-
"answeredAt":
|
|
678
|
+
"answeredAt": now_iso,
|
|
676
679
|
"answeredFrom": "terminal",
|
|
677
680
|
}
|
|
678
681
|
with os.fdopen(fd, "w") as f:
|
|
@@ -681,6 +684,25 @@ def main():
|
|
|
681
684
|
pass
|
|
682
685
|
except Exception:
|
|
683
686
|
pass
|
|
687
|
+
# Tombstone first, then drop the open-block view. A missing/corrupt
|
|
688
|
+
# block means there is nothing to resolve; fail open.
|
|
689
|
+
existing = read_json(target)
|
|
690
|
+
if isinstance(existing, dict):
|
|
691
|
+
generation = existing.get("generation") or existing.get("ts")
|
|
692
|
+
if generation:
|
|
693
|
+
tombstone = {
|
|
694
|
+
"blockId": block_id,
|
|
695
|
+
"generation": generation,
|
|
696
|
+
"resolvedAt": now_iso,
|
|
697
|
+
"reason": "answered",
|
|
698
|
+
}
|
|
699
|
+
source_cursor = existing.get("sourceCursor")
|
|
700
|
+
if source_cursor:
|
|
701
|
+
tombstone["sourceCursor"] = source_cursor
|
|
702
|
+
write_json(
|
|
703
|
+
os.path.join(feed_dir, "resolutions", f"{block_id}.json"),
|
|
704
|
+
tombstone,
|
|
705
|
+
)
|
|
684
706
|
# Remove the visible block so the feed drops the answered question.
|
|
685
707
|
try:
|
|
686
708
|
os.unlink(target)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* a GitHub issue) hears nothing at all. So a post can also be mirrored outward.
|
|
8
8
|
*
|
|
9
9
|
* Sinks are **argv templates from config**, never hardcoded integrations. This
|
|
10
|
-
* CLI ships Apache-2.0 and must not depend on one person's tracker or messaging
|
|
10
|
+
* CLI ships under FSL-1.1-Apache-2.0 and must not depend on one person's tracker or messaging
|
|
11
11
|
* stack; declaring `[linear, update, "{ticket}", --comment, "{text}"]` in
|
|
12
12
|
* `agents.yaml` keeps the coupling in the operator's config where it belongs,
|
|
13
13
|
* and lets someone else point the same mechanism at `jira`, `gh issue comment`,
|
|
@@ -45,6 +45,13 @@ export interface SourceAuth {
|
|
|
45
45
|
/** The captured file payloads, keyed by agent. */
|
|
46
46
|
filesByAgent: Map<string, AuthFilePayload[]>;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Bundles `fleet apply` considers for push. The reserved file-backed `auth`
|
|
50
|
+
* bundle is always included when it exists locally — it is the fleet-shared
|
|
51
|
+
* setup-token store and must not wait on `--provision-secrets` / a manifest
|
|
52
|
+
* list (PHNX-2371).
|
|
53
|
+
*/
|
|
54
|
+
export declare function fleetSecretsBundles(declared: string[] | undefined): string[];
|
|
48
55
|
export interface DiffContext {
|
|
49
56
|
/** agents-cli version the source is on — the fleet target version. */
|
|
50
57
|
targetCliVersion: string;
|
|
@@ -95,6 +102,10 @@ export interface SecretPushDecision {
|
|
|
95
102
|
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
96
103
|
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
97
104
|
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
105
|
+
*
|
|
106
|
+
* The reserved `auth` bundle is the exception: it is always file-backed
|
|
107
|
+
* (SEC-GAP-3) and is pushed even without `--provision-secrets`, because it is
|
|
108
|
+
* the one fleet-shared setup-token store (PHNX-2371).
|
|
98
109
|
*/
|
|
99
110
|
export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
|
|
100
111
|
export interface ProbeOptions {
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import { pushBundleToHost } from '../secrets/push.js';
|
|
12
|
+
import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle, isReservedBundleName } from '../secrets/bundles.js';
|
|
12
13
|
import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
|
|
13
14
|
import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
|
|
14
15
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
@@ -88,6 +89,20 @@ export function parseInstalledVersions(stdout) {
|
|
|
88
89
|
return undefined;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Bundles `fleet apply` considers for push. The reserved file-backed `auth`
|
|
94
|
+
* bundle is always included when it exists locally — it is the fleet-shared
|
|
95
|
+
* setup-token store and must not wait on `--provision-secrets` / a manifest
|
|
96
|
+
* list (PHNX-2371).
|
|
97
|
+
*/
|
|
98
|
+
export function fleetSecretsBundles(declared) {
|
|
99
|
+
const out = [...(declared ?? [])];
|
|
100
|
+
const auth = inspectReservedAuthBundle();
|
|
101
|
+
if (auth.exists && auth.ok && !out.includes(AUTH_BUNDLE_NAME)) {
|
|
102
|
+
out.push(AUTH_BUNDLE_NAME);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
91
106
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
92
107
|
export function diffFleet(desired, probes, ctx) {
|
|
93
108
|
const devices = [];
|
|
@@ -208,12 +223,17 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
208
223
|
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
209
224
|
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
210
225
|
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
226
|
+
*
|
|
227
|
+
* The reserved `auth` bundle is the exception: it is always file-backed
|
|
228
|
+
* (SEC-GAP-3) and is pushed even without `--provision-secrets`, because it is
|
|
229
|
+
* the one fleet-shared setup-token store (PHNX-2371).
|
|
211
230
|
*/
|
|
212
231
|
export function decideSecretPush(bundle, desired, probe, ctx) {
|
|
213
232
|
const device = desired.device;
|
|
214
|
-
const
|
|
233
|
+
const reserved = isReservedBundleName(bundle);
|
|
234
|
+
const backend = reserved || probe.platform === 'linux' ? 'file' : 'keychain';
|
|
215
235
|
const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
|
|
216
|
-
if (!ctx.provisionSecrets) {
|
|
236
|
+
if (!ctx.provisionSecrets && !reserved) {
|
|
217
237
|
return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
|
|
218
238
|
}
|
|
219
239
|
if (!probe.reachable) {
|
|
@@ -416,7 +436,7 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
416
436
|
ok = false;
|
|
417
437
|
continue;
|
|
418
438
|
}
|
|
419
|
-
const backend = device.platform === 'linux' ? 'file' : 'keychain';
|
|
439
|
+
const backend = isReservedBundleName(bundle) || device.platform === 'linux' ? 'file' : 'keychain';
|
|
420
440
|
try {
|
|
421
441
|
const out = pushBundleToHost(bundle, target, {
|
|
422
442
|
remoteBackend: backend,
|
|
@@ -136,9 +136,11 @@ export const FLEET_LOGIN_FLOWS = {
|
|
|
136
136
|
successFile: successFileFor('opencode'),
|
|
137
137
|
},
|
|
138
138
|
// claude: interactive `/login` inside the TUI is a loopback browser flow, and
|
|
139
|
-
// the token is keychain-bound on macOS.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// the token is keychain-bound on macOS. That native login stays per-box and
|
|
140
|
+
// is never copied. The shareable `claude setup-token` mint is a different
|
|
141
|
+
// credential class — wired as `agents accounts mint claude` / `agents auth mint`
|
|
142
|
+
// (`lib/auth-mint.ts`), not as a fleet-login device-code flow (a setup-token
|
|
143
|
+
// is account-scoped, not per-machine).
|
|
142
144
|
claude: {
|
|
143
145
|
loginCommand: 'claude',
|
|
144
146
|
flowType: 'loopback',
|
package/dist/lib/help.d.ts
CHANGED
|
@@ -18,6 +18,15 @@ export interface CommandGroup {
|
|
|
18
18
|
* any group fall back to a plain "Commands:" section below the groups.
|
|
19
19
|
*/
|
|
20
20
|
export declare function registerCommandGroups(parent: Command, groups: readonly CommandGroup[]): void;
|
|
21
|
+
/**
|
|
22
|
+
* Front-door command groups shown on `agents --help`. Derived from measured
|
|
23
|
+
* reach: tier 1 = setup/run/sessions/view, tier 2 = teams/browser/secrets/
|
|
24
|
+
* devices/accounts/add. The remaining groups stay discoverable through the
|
|
25
|
+
* pointer rendered below these groups.
|
|
26
|
+
*/
|
|
27
|
+
export declare const FRONT_DOOR_COMMAND_GROUPS: readonly CommandGroup[];
|
|
28
|
+
/** Mark the root program so its help only renders front-door groups + a pointer. */
|
|
29
|
+
export declare function setCompactRootHelp(program: Command): void;
|
|
21
30
|
/** Examples + Notes blocks attached to a command via setHelpSections. */
|
|
22
31
|
export interface HelpSections {
|
|
23
32
|
examples?: string;
|