@phnx-labs/agents-cli 1.22.107 → 1.22.109
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 +52 -0
- package/dist/commands/daemon.js +45 -40
- package/dist/commands/doctor.js +7 -0
- package/dist/commands/exec.js +4 -2
- package/dist/commands/monitors.js +15 -2
- package/dist/commands/routines.js +46 -6
- package/dist/commands/sessions-backup-setup.d.ts +12 -0
- package/dist/commands/sessions-backup-setup.js +65 -0
- package/dist/commands/sessions-resume.d.ts +14 -1
- package/dist/commands/sessions-resume.js +56 -9
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/share.js +15 -2
- package/dist/commands/sync.js +30 -1
- package/dist/lib/accounts/slots.js +7 -0
- package/dist/lib/agent-spec/agents.d.ts +60 -8
- package/dist/lib/agent-spec/agents.js +118 -45
- package/dist/lib/daemon/daemon.d.ts +34 -1
- package/dist/lib/daemon/daemon.js +63 -9
- package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
- package/dist/lib/daemon/leaked-daemons.js +180 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +32 -1
- package/dist/lib/hooks/install.js +70 -63
- package/dist/lib/models.js +76 -5
- package/dist/lib/session/cloud.js +3 -1
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +136 -2
- package/dist/lib/session/recovery.d.ts +9 -1
- package/dist/lib/session/recovery.js +14 -4
- package/dist/lib/session/tool-calls.d.ts +1 -1
- package/dist/lib/session/tool-calls.js +40 -5
- package/dist/lib/share/backend.d.ts +23 -0
- package/dist/lib/share/backend.js +24 -0
- package/dist/lib/share/provision.d.ts +12 -0
- package/dist/lib/share/provision.js +30 -0
- package/dist/lib/share/worker-template.js +273 -0
- package/dist/lib/terminal/engine.js +13 -1
- package/package.json +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon state dir for an ARBITRARY home — the same layout state.ts's
|
|
3
|
+
* DAEMON_DIR chain produces (`<home>/.agents/.cache/helpers/daemon`). A caller
|
|
4
|
+
* under a redirected HOME (a test/e2e harness) uses this to find the REAL
|
|
5
|
+
* install's daemon records. Kept here rather than exported from state.ts: any
|
|
6
|
+
* edit to state.ts selects its entire static-import closure into the required
|
|
7
|
+
* impact gate (186 files, 295s against the 240s budget, measured on this
|
|
8
|
+
* change's first CI run), so the helper cannot live there without a
|
|
9
|
+
* budget-policy change. If state.ts's layout ever moves, this must move with
|
|
10
|
+
* it — the two are the same address.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getDaemonDirForHome(home: string): string;
|
|
13
|
+
/** One live `__daemon-run` process from the box-wide `ps` scan. */
|
|
14
|
+
export interface DaemonRunProcess {
|
|
15
|
+
pid: number;
|
|
16
|
+
/** Owning uid from `ps`, or null when unavailable. */
|
|
17
|
+
uid: number | null;
|
|
18
|
+
/** Whitespace-tokenized argv (`ps` renders it unquoted). */
|
|
19
|
+
tokens: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Every live `__daemon-run` process on this box, regardless of which install
|
|
23
|
+
* launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
|
|
24
|
+
* Windows.
|
|
25
|
+
*
|
|
26
|
+
* `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
|
|
27
|
+
* after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
|
|
28
|
+
* A substring/regex test anywhere in the full command line is not enough: an
|
|
29
|
+
* `agents run claude "<prompt>"` invocation whose prompt happens to quote the
|
|
30
|
+
* literal text `__daemon-run` (this ticket's own brief does) matches that test
|
|
31
|
+
* too, and was observed producing false "duplicate daemon" rows. Requiring it
|
|
32
|
+
* to be the LAST whitespace-delimited token is the actual invariant.
|
|
33
|
+
*
|
|
34
|
+
* This raw box-wide scan is deliberately NOT the duplicate-detection scope
|
|
35
|
+
* (RUSH-2368): a `__daemon-run` under a different HOME serves a different
|
|
36
|
+
* `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
|
|
37
|
+
* `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
|
|
38
|
+
* this scan and was reported as a stray to `kill`. Callers attach their own
|
|
39
|
+
* scope: `agents daemon status` gates on the instance registry,
|
|
40
|
+
* {@link findLeakedDaemons} on the owner records above.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listDaemonRunProcesses(): DaemonRunProcess[];
|
|
43
|
+
/** A `__daemon-run` process no owner record names. */
|
|
44
|
+
export interface LeakedDaemon {
|
|
45
|
+
pid: number;
|
|
46
|
+
/** The HOME the process runs under, or null when it cannot be read. */
|
|
47
|
+
home: string | null;
|
|
48
|
+
/** The process's start time as `ps lstart` renders it, or null when unavailable. */
|
|
49
|
+
startedAt: string | null;
|
|
50
|
+
/** The launch entry (the argv token before `__daemon-run`), best-effort. */
|
|
51
|
+
entry: string | null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every `__daemon-run` process running as THIS uid that no owner record names:
|
|
55
|
+
* neither the service manager's unit main PID nor the recorded
|
|
56
|
+
* `<daemonDir>/daemon.pid`. Each result carries the process's HOME and start
|
|
57
|
+
* time so the report can show them. An empty list means no leak — including
|
|
58
|
+
* the common stopped-daemon case, where there is simply nothing running.
|
|
59
|
+
*/
|
|
60
|
+
export declare function findLeakedDaemons(): LeakedDaemon[];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaked-daemon detection (W4, PHNX-3736).
|
|
3
|
+
*
|
|
4
|
+
* One daemon per device is the contract: the always-on process behind
|
|
5
|
+
* `agents __daemon-run` is either the service manager's unit main PID or the
|
|
6
|
+
* pid recorded in `<daemonDir>/daemon.pid`. Anything else running
|
|
7
|
+
* `__daemon-run` as this uid is a LEAK — a daemon nothing owns.
|
|
8
|
+
*
|
|
9
|
+
* The motivating incident ran 4+ days on yosemite-s1: a headless e2e session
|
|
10
|
+
* launched a daemon under `HOME=/tmp/pin-e2e-<pid>` and never stopped it. The
|
|
11
|
+
* pid-file takeover in `daemon.ts` could not see it because it keeps its own
|
|
12
|
+
* pid file under the temp home, and `agents daemon status`'s duplicate scope
|
|
13
|
+
* (`findSurvivingStateDirDaemons`, RUSH-2368) deliberately covers only this
|
|
14
|
+
* install's state dir — so the leak was invisible to every existing surface.
|
|
15
|
+
*
|
|
16
|
+
* Why flagging a different-HOME daemon here is not the RUSH-2368 harm: that
|
|
17
|
+
* incident taught that a `__daemon-run` under another HOME is not a DUPLICATE
|
|
18
|
+
* of this device's daemon and must never be told to `kill` from the duplicate
|
|
19
|
+
* path. This module does not accuse it of being a duplicate — it reports that
|
|
20
|
+
* NO owner record (unit main PID, recorded daemon.pid) names the pid at all,
|
|
21
|
+
* and shows the process's own HOME and start time so the operator can judge
|
|
22
|
+
* before acting. A different uid is never named: we cannot inspect its
|
|
23
|
+
* environment or signal it.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from 'child_process';
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
import * as os from 'os';
|
|
28
|
+
import * as path from 'path';
|
|
29
|
+
import { productionDaemonServiceNames, readDaemonPid, readServiceManagerPid } from './daemon.js';
|
|
30
|
+
import { getDaemonDir } from '../state.js';
|
|
31
|
+
/**
|
|
32
|
+
* The daemon state dir for an ARBITRARY home — the same layout state.ts's
|
|
33
|
+
* DAEMON_DIR chain produces (`<home>/.agents/.cache/helpers/daemon`). A caller
|
|
34
|
+
* under a redirected HOME (a test/e2e harness) uses this to find the REAL
|
|
35
|
+
* install's daemon records. Kept here rather than exported from state.ts: any
|
|
36
|
+
* edit to state.ts selects its entire static-import closure into the required
|
|
37
|
+
* impact gate (186 files, 295s against the 240s budget, measured on this
|
|
38
|
+
* change's first CI run), so the helper cannot live there without a
|
|
39
|
+
* budget-policy change. If state.ts's layout ever moves, this must move with
|
|
40
|
+
* it — the two are the same address.
|
|
41
|
+
*/
|
|
42
|
+
export function getDaemonDirForHome(home) {
|
|
43
|
+
return path.join(home, '.agents', '.cache', 'helpers', 'daemon');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Every live `__daemon-run` process on this box, regardless of which install
|
|
47
|
+
* launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
|
|
48
|
+
* Windows.
|
|
49
|
+
*
|
|
50
|
+
* `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
|
|
51
|
+
* after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
|
|
52
|
+
* A substring/regex test anywhere in the full command line is not enough: an
|
|
53
|
+
* `agents run claude "<prompt>"` invocation whose prompt happens to quote the
|
|
54
|
+
* literal text `__daemon-run` (this ticket's own brief does) matches that test
|
|
55
|
+
* too, and was observed producing false "duplicate daemon" rows. Requiring it
|
|
56
|
+
* to be the LAST whitespace-delimited token is the actual invariant.
|
|
57
|
+
*
|
|
58
|
+
* This raw box-wide scan is deliberately NOT the duplicate-detection scope
|
|
59
|
+
* (RUSH-2368): a `__daemon-run` under a different HOME serves a different
|
|
60
|
+
* `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
|
|
61
|
+
* `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
|
|
62
|
+
* this scan and was reported as a stray to `kill`. Callers attach their own
|
|
63
|
+
* scope: `agents daemon status` gates on the instance registry,
|
|
64
|
+
* {@link findLeakedDaemons} on the owner records above.
|
|
65
|
+
*/
|
|
66
|
+
export function listDaemonRunProcesses() {
|
|
67
|
+
if (process.platform === 'win32')
|
|
68
|
+
return [];
|
|
69
|
+
let out;
|
|
70
|
+
try {
|
|
71
|
+
out = execFileSync('ps', ['-eo', 'pid=,uid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const line of out.split('\n')) {
|
|
78
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
79
|
+
if (!m)
|
|
80
|
+
continue;
|
|
81
|
+
const tokens = m[3].trim().split(/\s+/);
|
|
82
|
+
if (tokens.length === 0 || tokens[tokens.length - 1] !== '__daemon-run')
|
|
83
|
+
continue;
|
|
84
|
+
const pid = parseInt(m[1], 10);
|
|
85
|
+
if (isNaN(pid))
|
|
86
|
+
continue;
|
|
87
|
+
const uidParsed = parseInt(m[2], 10);
|
|
88
|
+
found.push({ pid, uid: isNaN(uidParsed) ? null : uidParsed, tokens });
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The HOME of a live process, or null when unreadable. Linux reads
|
|
94
|
+
* `/proc/<pid>/environ`; macOS has no equivalent zero-dependency primitive, so
|
|
95
|
+
* it falls back to `ps -E`, which appends the environment to the command
|
|
96
|
+
* column. Both are best-effort: a null HOME must never turn into an accusation
|
|
97
|
+
* beyond "unknown".
|
|
98
|
+
*/
|
|
99
|
+
function processHome(pid) {
|
|
100
|
+
if (process.platform === 'linux') {
|
|
101
|
+
try {
|
|
102
|
+
const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf-8');
|
|
103
|
+
for (const entry of env.split('\0')) {
|
|
104
|
+
if (entry.startsWith('HOME='))
|
|
105
|
+
return entry.slice('HOME='.length) || null;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const out = execFileSync('ps', ['-E', '-o', 'command=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
115
|
+
const m = out.match(/(?:^|\s)HOME=(\S+)/);
|
|
116
|
+
return m ? m[1] : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The start time of a live process as `ps lstart` renders it, or null (best-effort, POSIX only). */
|
|
123
|
+
function processStartTime(pid) {
|
|
124
|
+
if (process.platform === 'win32')
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
return out || null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Every `__daemon-run` process running as THIS uid that no owner record names:
|
|
136
|
+
* neither the service manager's unit main PID nor the recorded
|
|
137
|
+
* `<daemonDir>/daemon.pid`. Each result carries the process's HOME and start
|
|
138
|
+
* time so the report can show them. An empty list means no leak — including
|
|
139
|
+
* the common stopped-daemon case, where there is simply nothing running.
|
|
140
|
+
*/
|
|
141
|
+
export function findLeakedDaemons() {
|
|
142
|
+
const owned = new Set();
|
|
143
|
+
const recorded = readDaemonPid();
|
|
144
|
+
if (recorded)
|
|
145
|
+
owned.add(recorded);
|
|
146
|
+
const unitPid = readServiceManagerPid();
|
|
147
|
+
if (unitPid)
|
|
148
|
+
owned.add(unitPid);
|
|
149
|
+
// A caller under a redirected HOME (a test/e2e harness) must still recognize
|
|
150
|
+
// the REAL install's daemon as owned: its records live under the account
|
|
151
|
+
// home (`os.userInfo().homedir` reads the passwd record, ignoring $HOME),
|
|
152
|
+
// not under this process's HOME. Flagging the box's healthy production
|
|
153
|
+
// daemon as a leak would be the RUSH-2368 harm through a new door.
|
|
154
|
+
const realDaemonDir = getDaemonDirForHome(os.userInfo().homedir);
|
|
155
|
+
if (realDaemonDir !== getDaemonDir()) {
|
|
156
|
+
const realRecorded = readDaemonPid(realDaemonDir);
|
|
157
|
+
if (realRecorded)
|
|
158
|
+
owned.add(realRecorded);
|
|
159
|
+
const realUnitPid = readServiceManagerPid(os.platform(), productionDaemonServiceNames());
|
|
160
|
+
if (realUnitPid)
|
|
161
|
+
owned.add(realUnitPid);
|
|
162
|
+
}
|
|
163
|
+
const myUid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
164
|
+
const leaked = [];
|
|
165
|
+
for (const p of listDaemonRunProcesses()) {
|
|
166
|
+
if (owned.has(p.pid))
|
|
167
|
+
continue;
|
|
168
|
+
// Another uid's daemon is never named: we cannot read its environment
|
|
169
|
+
// reliably and could not signal it if we tried.
|
|
170
|
+
if (myUid !== null && p.uid !== null && p.uid !== myUid)
|
|
171
|
+
continue;
|
|
172
|
+
leaked.push({
|
|
173
|
+
pid: p.pid,
|
|
174
|
+
home: processHome(p.pid),
|
|
175
|
+
startedAt: processStartTime(p.pid),
|
|
176
|
+
entry: p.tokens.length >= 2 ? p.tokens[p.tokens.length - 2] : null,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return leaked;
|
|
180
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentId } from '../types.js';
|
|
2
2
|
import type { DuplicateVersionHook } from '../hooks/install.js';
|
|
3
3
|
import type { AgentsBinaryShadow } from '../binary-shadow.js';
|
|
4
|
+
import type { LeakedDaemon } from '../daemon/leaked-daemons.js';
|
|
4
5
|
import type { RcSecretFinding } from '../secrets-types.js';
|
|
5
6
|
import type { OwnerSinkStatus } from '../channels/owner-sink.js';
|
|
6
7
|
import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
|
|
@@ -13,7 +14,7 @@ export type FindingSeverity = 'critical' | 'warning';
|
|
|
13
14
|
* the JSON consumer group by kind. */
|
|
14
15
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
15
16
|
* 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", "auth-bundle-wrong-backend", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable"];
|
|
17
|
+
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", "leaked-daemon"];
|
|
17
18
|
/**
|
|
18
19
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
19
20
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -124,6 +125,11 @@ export interface LocalFindingInputs {
|
|
|
124
125
|
ownerSink?: OwnerSinkStatus;
|
|
125
126
|
/** `agents` binaries that shadow the currently running CLI (RUSH-2431). */
|
|
126
127
|
binaryShadows?: AgentsBinaryShadow[];
|
|
128
|
+
/** `__daemon-run` processes no owner record names — neither the service
|
|
129
|
+
* manager's unit main PID nor the recorded daemon.pid (W4, PHNX-3736).
|
|
130
|
+
* Collected by `findLeakedDaemons` in the command (it runs `ps` and reads
|
|
131
|
+
* /proc, so it stays out of this pure module). */
|
|
132
|
+
leakedDaemons?: LeakedDaemon[];
|
|
127
133
|
}
|
|
128
134
|
/**
|
|
129
135
|
* Fold this machine's signals into findings. Missing hooks/plugins and unwired
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* fleet-resource-gap · hook-runtime-visibility-unavailable · orphan · duplicate-hook ·
|
|
28
28
|
* duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
|
|
29
29
|
* rc-secret-export · env-secret-export · auth-bundle-wrong-backend · exec-policy · stale-cli ·
|
|
30
|
-
* binary-shadow.
|
|
30
|
+
* binary-shadow · leaked-daemon.
|
|
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".)
|
|
33
33
|
*
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* format, so the layout is unit-tested against fixtures with no live fleet.
|
|
39
39
|
*/
|
|
40
40
|
import chalk from 'chalk';
|
|
41
|
+
import * as path from 'path';
|
|
41
42
|
import { AGENTS, ALL_AGENT_IDS, supportsAccountInspection } from '../agents.js';
|
|
42
43
|
import { blocksLocalScripts } from '../platform/winpath.js';
|
|
43
44
|
import { loginHint } from '../signin-badge.js';
|
|
@@ -126,6 +127,7 @@ export const ALL_FINDING_KINDS = [
|
|
|
126
127
|
'stale-cli',
|
|
127
128
|
'binary-shadow', // another agents binary on PATH or in a well-known dir shadows the running copy
|
|
128
129
|
'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
|
|
130
|
+
'leaked-daemon', // a `__daemon-run` process no owner record names (not the unit main PID, not daemon.pid)
|
|
129
131
|
];
|
|
130
132
|
/**
|
|
131
133
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
@@ -174,6 +176,12 @@ export const FINDING_SEVERITY = {
|
|
|
174
176
|
'ssh-key-enrollment': 'critical',
|
|
175
177
|
'stale-cli': 'warning',
|
|
176
178
|
'binary-shadow': 'warning',
|
|
179
|
+
// A stray `__daemon-run` under its own (usually temp) HOME: it shares nothing
|
|
180
|
+
// with this install's state, so it cannot double-fire the real box's work —
|
|
181
|
+
// but it is a leak nothing owns and it outlives whatever launched it (W4,
|
|
182
|
+
// PHNX-3736). A human reads its HOME/start time and kills it; nothing is
|
|
183
|
+
// blocked right now.
|
|
184
|
+
'leaked-daemon': 'warning',
|
|
177
185
|
};
|
|
178
186
|
function agentName(agent) {
|
|
179
187
|
return AGENT_NAMES[agent] || agent;
|
|
@@ -305,6 +313,11 @@ export function remediationFor(finding) {
|
|
|
305
313
|
return 'upgrade';
|
|
306
314
|
case 'binary-shadow':
|
|
307
315
|
return 'remove or repoint the shadowing agents install(s)';
|
|
316
|
+
case 'leaked-daemon':
|
|
317
|
+
// The builder emits the concrete `kill <pid>` — the pid is a property of
|
|
318
|
+
// the row it builds, not of anything remediationFor can see. This is the
|
|
319
|
+
// shape only, for a kind constructed through finding() by a future caller.
|
|
320
|
+
return 'kill <pid>';
|
|
308
321
|
case 'owner-sink-unreachable':
|
|
309
322
|
// The lane delivers over the rush-backed owner channel, which needs rush on
|
|
310
323
|
// PATH AND a usable session in THIS context. Non-interactive shells miss a
|
|
@@ -579,6 +592,24 @@ export function buildLocalFindings(input) {
|
|
|
579
592
|
: `${shadows.length} agents binaries may shadow the running copy (incl. ${examples})`,
|
|
580
593
|
}));
|
|
581
594
|
}
|
|
595
|
+
// Leaked daemons (warning) — a `__daemon-run` no owner record names. One row
|
|
596
|
+
// per pid: the remediation is pid-specific, and the HOME + start time are
|
|
597
|
+
// what let the operator tell a leaked test fixture from something they meant
|
|
598
|
+
// to run before they kill it (W4, PHNX-3736). Built directly, not through
|
|
599
|
+
// finding(): the remediation carries this row's own pid.
|
|
600
|
+
for (const d of input.leakedDaemons ?? []) {
|
|
601
|
+
const home = d.home ?? 'unknown HOME';
|
|
602
|
+
const started = d.startedAt ? `, started ${d.startedAt}` : '';
|
|
603
|
+
// The launch entry names WHICH install leaked the daemon — part of what
|
|
604
|
+
// the operator judges before killing. Only a real path: for a stand-in
|
|
605
|
+
// (`node -e '<code>' __daemon-run`) the token is the code blob.
|
|
606
|
+
const entry = d.entry && path.isAbsolute(d.entry) ? ` · ${d.entry}` : '';
|
|
607
|
+
out.push({
|
|
608
|
+
severity: FINDING_SEVERITY['leaked-daemon'], kind: 'leaked-daemon', device,
|
|
609
|
+
message: `stray agents daemon (pid ${d.pid}) no unit or pid file owns — HOME=${home}${started}${entry}`,
|
|
610
|
+
remediation: `kill ${d.pid}`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
582
613
|
return collapseAcrossVersions(out, new Set(input.isolatedVersions ?? []));
|
|
583
614
|
}
|
|
584
615
|
/**
|
|
@@ -2256,12 +2256,72 @@ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, rem
|
|
|
2256
2256
|
}
|
|
2257
2257
|
return removed;
|
|
2258
2258
|
}
|
|
2259
|
+
function trustCodexHooks(hooksPath) {
|
|
2260
|
+
const configPath = path.join(path.dirname(hooksPath), 'config.toml');
|
|
2261
|
+
const hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
2262
|
+
let tomlConfig = {};
|
|
2263
|
+
if (fs.existsSync(configPath)) {
|
|
2264
|
+
tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2265
|
+
}
|
|
2266
|
+
if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
|
|
2267
|
+
tomlConfig.features = {};
|
|
2268
|
+
}
|
|
2269
|
+
// Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
|
|
2270
|
+
// an unrecognized key that triggers a deprecation error and is ignored).
|
|
2271
|
+
const features = tomlConfig.features;
|
|
2272
|
+
delete features.codex_hooks;
|
|
2273
|
+
features.hooks = true;
|
|
2274
|
+
// Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
|
|
2275
|
+
// exactly as Codex resolves it (the absolute CODEX_HOME path), the
|
|
2276
|
+
// snake_case event label, and the per-event group/handler indices — which
|
|
2277
|
+
// must match Codex's parse order, so we iterate the just-written
|
|
2278
|
+
// hooksFile structure in array order.
|
|
2279
|
+
if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
|
|
2280
|
+
tomlConfig.hooks = {};
|
|
2281
|
+
}
|
|
2282
|
+
const hooksTable = tomlConfig.hooks;
|
|
2283
|
+
const existingState = hooksTable.state && typeof hooksTable.state === 'object'
|
|
2284
|
+
? hooksTable.state
|
|
2285
|
+
: {};
|
|
2286
|
+
const hookState = {};
|
|
2287
|
+
for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
|
|
2288
|
+
const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
|
|
2289
|
+
if (!eventKeyLabel)
|
|
2290
|
+
continue;
|
|
2291
|
+
eventGroups.forEach((group, groupIdx) => {
|
|
2292
|
+
if (!group.hooks)
|
|
2293
|
+
return;
|
|
2294
|
+
group.hooks.forEach((handler, handlerIdx) => {
|
|
2295
|
+
if (handler.type !== 'command')
|
|
2296
|
+
return;
|
|
2297
|
+
const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
|
|
2298
|
+
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
2299
|
+
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
2300
|
+
// only (re)write the trust hash.
|
|
2301
|
+
const prior = existingState[key];
|
|
2302
|
+
const entry = { trusted_hash: trustedHash };
|
|
2303
|
+
if (prior && prior.enabled === false) {
|
|
2304
|
+
entry.enabled = false;
|
|
2305
|
+
}
|
|
2306
|
+
hookState[key] = entry;
|
|
2307
|
+
});
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
// Carry forward trust state for any hooks we did not (re)register this
|
|
2311
|
+
// pass — e.g. user-added hooks under a different command path.
|
|
2312
|
+
for (const [key, entry] of Object.entries(existingState)) {
|
|
2313
|
+
if (!(key in hookState)) {
|
|
2314
|
+
hookState[key] = entry;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
hooksTable.state = hookState;
|
|
2318
|
+
fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
|
|
2319
|
+
}
|
|
2259
2320
|
function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
2260
2321
|
const registered = [];
|
|
2261
2322
|
const errors = [];
|
|
2262
2323
|
const configDir = path.join(versionHome, '.codex');
|
|
2263
2324
|
const hooksPath = path.join(configDir, 'hooks.json');
|
|
2264
|
-
const configPath = path.join(configDir, 'config.toml');
|
|
2265
2325
|
// Read existing hooks.json — must have top-level "hooks" wrapper key
|
|
2266
2326
|
let hooksFile = { hooks: {} };
|
|
2267
2327
|
if (fs.existsSync(hooksPath)) {
|
|
@@ -2373,66 +2433,7 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
|
|
|
2373
2433
|
// them, so an untrusted hook is silently dropped. We compute the same
|
|
2374
2434
|
// trust hash Codex would and persist it under [hooks.state].
|
|
2375
2435
|
try {
|
|
2376
|
-
|
|
2377
|
-
if (fs.existsSync(configPath)) {
|
|
2378
|
-
try {
|
|
2379
|
-
tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2380
|
-
}
|
|
2381
|
-
catch { /* start fresh if corrupt */ }
|
|
2382
|
-
}
|
|
2383
|
-
if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
|
|
2384
|
-
tomlConfig.features = {};
|
|
2385
|
-
}
|
|
2386
|
-
// Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
|
|
2387
|
-
// an unrecognized key that triggers a deprecation error and is ignored).
|
|
2388
|
-
const features = tomlConfig.features;
|
|
2389
|
-
delete features.codex_hooks;
|
|
2390
|
-
features.hooks = true;
|
|
2391
|
-
// Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
|
|
2392
|
-
// exactly as Codex resolves it (the absolute CODEX_HOME path), the
|
|
2393
|
-
// snake_case event label, and the per-event group/handler indices — which
|
|
2394
|
-
// must match Codex's parse order, so we iterate the just-written
|
|
2395
|
-
// hooksFile structure in array order.
|
|
2396
|
-
if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
|
|
2397
|
-
tomlConfig.hooks = {};
|
|
2398
|
-
}
|
|
2399
|
-
const hooksTable = tomlConfig.hooks;
|
|
2400
|
-
const existingState = hooksTable.state && typeof hooksTable.state === 'object'
|
|
2401
|
-
? hooksTable.state
|
|
2402
|
-
: {};
|
|
2403
|
-
const hookState = {};
|
|
2404
|
-
for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
|
|
2405
|
-
const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
|
|
2406
|
-
if (!eventKeyLabel)
|
|
2407
|
-
continue;
|
|
2408
|
-
eventGroups.forEach((group, groupIdx) => {
|
|
2409
|
-
if (!group.hooks)
|
|
2410
|
-
return;
|
|
2411
|
-
group.hooks.forEach((handler, handlerIdx) => {
|
|
2412
|
-
if (handler.type !== 'command')
|
|
2413
|
-
return;
|
|
2414
|
-
const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
|
|
2415
|
-
const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
|
|
2416
|
-
// Preserve a user's explicit `enabled = false` for this exact hook;
|
|
2417
|
-
// only (re)write the trust hash.
|
|
2418
|
-
const prior = existingState[key];
|
|
2419
|
-
const entry = { trusted_hash: trustedHash };
|
|
2420
|
-
if (prior && prior.enabled === false) {
|
|
2421
|
-
entry.enabled = false;
|
|
2422
|
-
}
|
|
2423
|
-
hookState[key] = entry;
|
|
2424
|
-
});
|
|
2425
|
-
});
|
|
2426
|
-
}
|
|
2427
|
-
// Carry forward trust state for any hooks we did not (re)register this
|
|
2428
|
-
// pass — e.g. user-added hooks under a different command path.
|
|
2429
|
-
for (const [key, entry] of Object.entries(existingState)) {
|
|
2430
|
-
if (!(key in hookState)) {
|
|
2431
|
-
hookState[key] = entry;
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
hooksTable.state = hookState;
|
|
2435
|
-
fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
|
|
2436
|
+
trustCodexHooks(hooksPath);
|
|
2436
2437
|
}
|
|
2437
2438
|
catch (err) {
|
|
2438
2439
|
errors.push(`Failed to update config.toml: ${err.message}`);
|
|
@@ -3282,11 +3283,14 @@ export async function installSessionTrackerHook(agent, version, home) {
|
|
|
3282
3283
|
if (!invocation) {
|
|
3283
3284
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3284
3285
|
}
|
|
3286
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3285
3287
|
try {
|
|
3286
3288
|
await execFileAsync(invocation.command, invocation.args, {
|
|
3287
|
-
env
|
|
3289
|
+
env,
|
|
3288
3290
|
encoding: 'utf8',
|
|
3289
3291
|
});
|
|
3292
|
+
if (agent === 'codex')
|
|
3293
|
+
trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
|
|
3290
3294
|
return { installed: true };
|
|
3291
3295
|
}
|
|
3292
3296
|
catch (err) {
|
|
@@ -3324,12 +3328,15 @@ export function installSessionTrackerHookSync(agent, version, home) {
|
|
|
3324
3328
|
if (!invocation) {
|
|
3325
3329
|
return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
|
|
3326
3330
|
}
|
|
3331
|
+
const env = sessionTrackerInstallEnv(agent, version, home);
|
|
3327
3332
|
try {
|
|
3328
3333
|
execFileSync(invocation.command, invocation.args, {
|
|
3329
|
-
env
|
|
3334
|
+
env,
|
|
3330
3335
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3331
3336
|
encoding: 'utf8',
|
|
3332
3337
|
});
|
|
3338
|
+
if (agent === 'codex')
|
|
3339
|
+
trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
|
|
3333
3340
|
return { installed: true };
|
|
3334
3341
|
}
|
|
3335
3342
|
catch (err) {
|
package/dist/lib/models.js
CHANGED
|
@@ -13,7 +13,8 @@ import { execFileSync } from 'child_process';
|
|
|
13
13
|
import chalk from 'chalk';
|
|
14
14
|
import { getVersionDir, getVersionHomePath, getBinaryPath } from './installations/versions.js';
|
|
15
15
|
import { getModelsCachePath } from './state.js';
|
|
16
|
-
import { agentConfigDirName } from './agents.js';
|
|
16
|
+
import { agentConfigDirName, resolveOpenCodeXdgPath } from './agents.js';
|
|
17
|
+
import { stripJsonComments } from './permissions-registry.js';
|
|
17
18
|
import { resolveRunDefaults } from './run-defaults.js';
|
|
18
19
|
import { getModelPricing } from './pricing/index.js';
|
|
19
20
|
const CACHE_PATH = getModelsCachePath();
|
|
@@ -1014,6 +1015,12 @@ export function resolveConfiguredModel(agent, version, home) {
|
|
|
1014
1015
|
const nativeModel = readNativeConfigModel(agent, version, home);
|
|
1015
1016
|
if (nativeModel)
|
|
1016
1017
|
return { model: nativeModel, source: 'config' };
|
|
1018
|
+
// The agent's own persisted selection, for a runtime that stores one instead
|
|
1019
|
+
// of flagging a catalog default. Ranks below `config` (an explicit setting
|
|
1020
|
+
// wins) and above the catalog, whose `isDefault` OpenCode never sets.
|
|
1021
|
+
const selected = readNativeSelectedModel(agent, version);
|
|
1022
|
+
if (selected)
|
|
1023
|
+
return { model: selected, source: 'cli-default' };
|
|
1017
1024
|
const catalog = getModelCatalog(agent, version);
|
|
1018
1025
|
if (catalog) {
|
|
1019
1026
|
const flagged = catalog.models.find((m) => m.isDefault);
|
|
@@ -1022,15 +1029,79 @@ export function resolveConfiguredModel(agent, version, home) {
|
|
|
1022
1029
|
return null;
|
|
1023
1030
|
}
|
|
1024
1031
|
/**
|
|
1025
|
-
*
|
|
1032
|
+
* Where each agent keeps its OWN `model` setting, when that is not the
|
|
1033
|
+
* `<configDir>/settings.json` every Claude-shaped harness uses.
|
|
1034
|
+
*
|
|
1035
|
+
* OpenCode reads `~/.config/opencode/opencode.{jsonc,json}` and puts `model` at
|
|
1036
|
+
* its top level as `"<provider>/<model-id>"`. Its `~/.opencode/settings.json`
|
|
1037
|
+
* DOES exist, but that is agents-cli's own plugin-enablement file, which
|
|
1038
|
+
* OpenCode never reads a model from — so the default path found nothing and
|
|
1039
|
+
* every OpenCode row rendered the placeholder `default`. Comments are stripped
|
|
1040
|
+
* for BOTH spellings because OpenCode's loader accepts them in both, which is
|
|
1041
|
+
* also how the MCP writer treats the same file (`format: 'opencode-jsonc'`).
|
|
1042
|
+
*/
|
|
1043
|
+
const NATIVE_MODEL_CONFIGS = {
|
|
1044
|
+
opencode: {
|
|
1045
|
+
paths: (home) => [
|
|
1046
|
+
path.join(home, '.config', 'opencode', 'opencode.jsonc'),
|
|
1047
|
+
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
1048
|
+
],
|
|
1049
|
+
jsonc: true,
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
/**
|
|
1053
|
+
* Best-effort read of the agent's own `model` from its native config
|
|
1026
1054
|
* (e.g. `~/.agents/.history/versions/claude/<ver>/home/.claude/settings.json`).
|
|
1027
1055
|
* A missing/malformed file is a fall-through, not an error.
|
|
1028
1056
|
*/
|
|
1029
1057
|
function readNativeConfigModel(agent, version, home) {
|
|
1058
|
+
const resolvedHome = home ?? getVersionHomePath(agent, version);
|
|
1059
|
+
const native = NATIVE_MODEL_CONFIGS[agent];
|
|
1060
|
+
const candidates = native?.paths(resolvedHome)
|
|
1061
|
+
?? [path.join(resolvedHome, agentConfigDirName(agent), 'settings.json')];
|
|
1062
|
+
for (const configPath of candidates) {
|
|
1063
|
+
try {
|
|
1064
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
1065
|
+
const parsed = JSON.parse(native?.jsonc ? stripJsonComments(raw) : raw);
|
|
1066
|
+
if (typeof parsed.model === 'string' && parsed.model.trim() !== '')
|
|
1067
|
+
return parsed.model;
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
/* absent or malformed — try the next spelling */
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* The model the agent's OWN runtime will start with, for a harness that persists
|
|
1077
|
+
* its selection rather than flagging a catalog default.
|
|
1078
|
+
*
|
|
1079
|
+
* OpenCode is the case that needs it: it ships no default model, so its catalog
|
|
1080
|
+
* has no `isDefault` and `agents view` fell through to the literal `default` —
|
|
1081
|
+
* a placeholder, not something the user could act on. OpenCode instead records
|
|
1082
|
+
* the model picked in its TUI to `$XDG_STATE_HOME/opencode/model.json` as
|
|
1083
|
+
* `{ recent: [{ providerID, modelID }, …] }`, newest first, and reuses
|
|
1084
|
+
* `recent[0]` for the next session. That entry is the real answer to "what model
|
|
1085
|
+
* is this install on", rendered the same `<provider>/<model-id>` way OpenCode's
|
|
1086
|
+
* own config spells it.
|
|
1087
|
+
*
|
|
1088
|
+
* Returns null for every other agent, and for OpenCode when nothing has been
|
|
1089
|
+
* selected yet — the catalog fallback still applies.
|
|
1090
|
+
*/
|
|
1091
|
+
function readNativeSelectedModel(agent, version) {
|
|
1092
|
+
if (agent !== 'opencode')
|
|
1093
|
+
return null;
|
|
1094
|
+
const statePath = resolveOpenCodeXdgPath(getVersionHomePath(agent, version), 'state', 'model.json');
|
|
1095
|
+
if (!statePath)
|
|
1096
|
+
return null;
|
|
1030
1097
|
try {
|
|
1031
|
-
const
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1098
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
1099
|
+
const current = parsed.recent?.[0];
|
|
1100
|
+
const provider = typeof current?.providerID === 'string' ? current.providerID : '';
|
|
1101
|
+
const model = typeof current?.modelID === 'string' ? current.modelID : '';
|
|
1102
|
+
if (!model)
|
|
1103
|
+
return null;
|
|
1104
|
+
return provider ? `${provider}/${model}` : model;
|
|
1034
1105
|
}
|
|
1035
1106
|
catch {
|
|
1036
1107
|
return null;
|
|
@@ -53,6 +53,8 @@ function agentToFormat(agent) {
|
|
|
53
53
|
return 'rush';
|
|
54
54
|
if (agent === 'droid')
|
|
55
55
|
return 'droid';
|
|
56
|
+
if (agent === 'opencode')
|
|
57
|
+
return 'opencode';
|
|
56
58
|
return null;
|
|
57
59
|
}
|
|
58
60
|
export function assertContained(candidate, rootDir) {
|
|
@@ -128,7 +130,7 @@ export async function ensureCloudSessionCached(executionId, destPath) {
|
|
|
128
130
|
throw new Error(`session.jsonl fetch ${res.status}: ${body.slice(0, 200)}`);
|
|
129
131
|
}
|
|
130
132
|
const format = (res.headers.get('X-Session-Format') || '').toLowerCase();
|
|
131
|
-
if (!['claude', 'codex', 'rush'].includes(format)) {
|
|
133
|
+
if (!['claude', 'codex', 'rush', 'opencode'].includes(format)) {
|
|
132
134
|
throw new Error(`Unknown X-Session-Format on cloud response: "${format}"`);
|
|
133
135
|
}
|
|
134
136
|
const finalPath = callerPath ?? cachePathForExecution(id, format);
|
|
@@ -229,6 +229,7 @@ export declare function parseGrok(filePath: string): SessionEvent[];
|
|
|
229
229
|
*/
|
|
230
230
|
export declare const OPENCODE_TRANSCRIPT_QUERY: string;
|
|
231
231
|
export declare function parseOpenCode(filePath: string): SessionEvent[];
|
|
232
|
+
export declare function parseOpencodeCloud(filePath: string): SessionEvent[];
|
|
232
233
|
/** Parse a Rush JSONL session file into normalized events. */
|
|
233
234
|
export declare function parseRush(filePath: string): SessionEvent[];
|
|
234
235
|
/**
|