@phnx-labs/agents-cli 1.22.70 → 1.22.71
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 +18 -0
- package/README.md +31 -1
- package/dist/bootstrap.js +4 -4
- package/dist/commands/repo.js +2 -2
- package/dist/commands/sessions-export.d.ts +5 -1
- package/dist/commands/sessions-export.js +100 -24
- package/dist/commands/sessions-import.d.ts +2 -1
- package/dist/commands/sessions-import.js +85 -21
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +140 -19
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/daemon/auth-sync-service.js +1 -1
- package/dist/lib/daemon/browser-task-reap-service.js +1 -1
- package/dist/lib/daemon/daemon.js +13 -3
- package/dist/lib/daemon/heartbeat-service.js +3 -3
- package/dist/lib/daemon/keychain-reap-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +231 -78
- package/dist/lib/daemon/self-heal-service.js +13 -3
- package/dist/lib/daemon/self-update-service.d.ts +174 -0
- package/dist/lib/daemon/self-update-service.js +353 -0
- package/dist/lib/daemon/state-dir-check-service.js +3 -3
- package/dist/lib/daemon/usage-sync-service.js +1 -1
- package/dist/lib/daemon/watchdog-service.js +4 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +12 -1
- package/dist/lib/device-config.js +63 -13
- package/dist/lib/exec-bounded.d.ts +52 -0
- package/dist/lib/exec-bounded.js +113 -0
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/fleet-shared-state.d.ts +12 -5
- package/dist/lib/fleet-shared-state.js +50 -20
- package/dist/lib/fs-atomic.d.ts +11 -0
- package/dist/lib/fs-atomic.js +60 -0
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/project-resources.d.ts +12 -0
- package/dist/lib/project-resources.js +129 -0
- package/dist/lib/routine-process-cleanup.d.ts +2 -2
- package/dist/lib/routine-process-cleanup.js +45 -34
- package/dist/lib/secrets/reaper.d.ts +2 -2
- package/dist/lib/secrets/reaper.js +13 -10
- package/dist/lib/secrets/reserved-sync.d.ts +1 -1
- package/dist/lib/secrets/reserved-sync.js +4 -4
- package/dist/lib/self-update.d.ts +21 -8
- package/dist/lib/self-update.js +54 -31
- package/dist/lib/session/sync/backend.d.ts +61 -0
- package/dist/lib/session/sync/backend.js +89 -0
- package/dist/lib/session/sync/managed-config.d.ts +29 -0
- package/dist/lib/session/sync/managed-config.js +23 -0
- package/dist/lib/session/sync/managed-key.d.ts +45 -0
- package/dist/lib/session/sync/managed-key.js +128 -0
- package/dist/lib/session/sync/net-client.d.ts +65 -0
- package/dist/lib/session/sync/net-client.js +117 -0
- package/dist/lib/session/sync/provision.d.ts +19 -0
- package/dist/lib/session/sync/provision.js +38 -0
- package/dist/lib/session/sync/r2.d.ts +5 -2
- package/dist/lib/session/sync/r2.js +5 -2
- package/dist/lib/session/sync/worker-template.d.ts +6 -0
- package/dist/lib/session/sync/worker-template.js +847 -0
- package/dist/lib/tmux/orphan-reap.js +6 -4
- package/dist/lib/tmux/session.js +4 -1
- package/dist/lib/traces/classify.d.ts +8 -1
- package/dist/lib/traces/insights.d.ts +13 -1
- package/dist/lib/traces/insights.js +78 -3
- package/dist/lib/traces/sync.js +8 -3
- package/dist/lib/traces/worker-template.js +9 -5
- package/package.json +1 -1
|
@@ -34,6 +34,15 @@ export function syncProjectResourcesToAgent(agent, version, projectAgentsDir) {
|
|
|
34
34
|
syncProjectWorkflows(agent, version, projectAgentsDir, projectRoot, agentRoot, result, next);
|
|
35
35
|
if (next.size > 0 || manifest) {
|
|
36
36
|
writeProjectManifest(agentRoot, Array.from(next).sort());
|
|
37
|
+
// The sync is a code generator: the per-harness dir it writes into the
|
|
38
|
+
// project tree (.factory/, .opencode/, …) is a regenerable copy of
|
|
39
|
+
// .agents/{commands,skills,…}, refreshed on every launch. Left untracked it
|
|
40
|
+
// dirties `git status` and can block `git merge`/`checkout` when a stray
|
|
41
|
+
// commit of the same path collides. So the generator owns its ignore rule:
|
|
42
|
+
// reconcile a per-agent marker block in <projectRoot>/.gitignore listing
|
|
43
|
+
// exactly the paths it manages. Passing the manifest set (empty when a sync
|
|
44
|
+
// clears a harness) also prunes the block. See PHNX-3717.
|
|
45
|
+
reconcileProjectGitignore(projectRoot, agent, agentRoot, Array.from(next).sort());
|
|
37
46
|
}
|
|
38
47
|
return result;
|
|
39
48
|
}
|
|
@@ -57,6 +66,126 @@ function writeProjectManifest(agentRoot, paths) {
|
|
|
57
66
|
fs.writeFileSync(tmp, JSON.stringify({ v: MANIFEST_VERSION, paths }, null, 2));
|
|
58
67
|
fs.renameSync(tmp, p);
|
|
59
68
|
}
|
|
69
|
+
const GITIGNORE_MARKER = 'agents-cli project resources';
|
|
70
|
+
function gitignoreMarkers(agent) {
|
|
71
|
+
return {
|
|
72
|
+
begin: `# >>> ${GITIGNORE_MARKER}: ${agent} (generated on launch — do not edit) >>>`,
|
|
73
|
+
end: `# <<< ${GITIGNORE_MARKER}: ${agent} <<<`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Turn the manifest's managed paths (relative to agentRoot) into anchored,
|
|
78
|
+
* POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
|
|
79
|
+
* - drop any path that escapes the harness config dir (e.g. grok writes
|
|
80
|
+
* commands back into the tracked `.agents/` tree via a `../` subdir —
|
|
81
|
+
* ignoring that would hide tracked source; separate bug, PHNX-3718);
|
|
82
|
+
* - the manifest only ever holds paths the sync itself generated (pre-existing
|
|
83
|
+
* user/committed files are skipped and never recorded), so ignoring exactly
|
|
84
|
+
* these never masks a hand-authored or committed file (e.g. a repo that
|
|
85
|
+
* commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
|
|
86
|
+
*/
|
|
87
|
+
export function managedGitignoreEntries(agentRoot, projectRoot, managed) {
|
|
88
|
+
const root = path.resolve(agentRoot);
|
|
89
|
+
const entries = new Set();
|
|
90
|
+
for (const rel of managed) {
|
|
91
|
+
if (path.isAbsolute(rel))
|
|
92
|
+
continue;
|
|
93
|
+
const abs = path.resolve(agentRoot, rel);
|
|
94
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
95
|
+
continue;
|
|
96
|
+
const fromProject = toPosixRel(path.relative(projectRoot, abs));
|
|
97
|
+
if (!fromProject || fromProject === '..' || fromProject.startsWith('../'))
|
|
98
|
+
continue;
|
|
99
|
+
entries.add('/' + fromProject);
|
|
100
|
+
}
|
|
101
|
+
return Array.from(entries).sort();
|
|
102
|
+
}
|
|
103
|
+
/** True when `dir` is inside a git working tree — walks up to the filesystem
|
|
104
|
+
* root looking for a `.git` entry. `projectRoot` (the parent of the resolved
|
|
105
|
+
* `.agents/` dir) is not guaranteed to be the git root: a monorepo subdir can
|
|
106
|
+
* carry its own `.agents/` while `.git` lives several levels up. A root-only
|
|
107
|
+
* check would silently no-op the whole feature there. */
|
|
108
|
+
function isInsideGitRepo(dir) {
|
|
109
|
+
let cur = path.resolve(dir);
|
|
110
|
+
for (;;) {
|
|
111
|
+
if (pathExists(path.join(cur, '.git')))
|
|
112
|
+
return true;
|
|
113
|
+
const parent = path.dirname(cur);
|
|
114
|
+
if (parent === cur)
|
|
115
|
+
return false;
|
|
116
|
+
cur = parent;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Apply this agent's managed block to `.gitignore` content, IN PLACE.
|
|
121
|
+
*
|
|
122
|
+
* In-place replacement (not strip-then-append) is load-bearing: appending would
|
|
123
|
+
* move this agent's block behind every other agent's block on each resync, so in
|
|
124
|
+
* a repo synced by 2+ harnesses whichever one launched last would get bumped to
|
|
125
|
+
* the end — rewriting `.gitignore` on every launch forever. Replacing the block
|
|
126
|
+
* where it already sits keeps the file byte-stable once written.
|
|
127
|
+
*
|
|
128
|
+
* Returns the new content, `content` unchanged when there is nothing to do, or
|
|
129
|
+
* `null` when the block is unparseable (a begin marker with no matching end —
|
|
130
|
+
* hand-truncated or a botched merge). In that case we refuse to edit rather than
|
|
131
|
+
* treat everything to EOF as the block and silently delete the user's rules
|
|
132
|
+
* below the orphaned marker.
|
|
133
|
+
*/
|
|
134
|
+
function applyManagedBlock(content, begin, end, entries) {
|
|
135
|
+
const lines = content.split('\n');
|
|
136
|
+
const bi = lines.indexOf(begin);
|
|
137
|
+
if (bi !== -1) {
|
|
138
|
+
const ei = lines.indexOf(end, bi + 1);
|
|
139
|
+
if (ei === -1)
|
|
140
|
+
return null; // orphaned begin marker — never truncate to EOF
|
|
141
|
+
if (entries.length > 0) {
|
|
142
|
+
return [...lines.slice(0, bi), begin, ...entries, end, ...lines.slice(ei + 1)].join('\n');
|
|
143
|
+
}
|
|
144
|
+
// Prune the block, tidying the blank lines that hugged it.
|
|
145
|
+
const before = lines.slice(0, bi);
|
|
146
|
+
const after = lines.slice(ei + 1);
|
|
147
|
+
while (before.length && before[before.length - 1].trim() === '')
|
|
148
|
+
before.pop();
|
|
149
|
+
while (after.length && after[0].trim() === '')
|
|
150
|
+
after.shift();
|
|
151
|
+
const rest = [...before, ...after].join('\n').replace(/\n+$/, '');
|
|
152
|
+
return rest.length > 0 ? `${rest}\n` : '';
|
|
153
|
+
}
|
|
154
|
+
if (entries.length === 0)
|
|
155
|
+
return content; // no block, nothing to add
|
|
156
|
+
const body = content.replace(/\n+$/, '');
|
|
157
|
+
const block = [begin, ...entries, end].join('\n');
|
|
158
|
+
return body.length > 0 ? `${body}\n\n${block}\n` : `${block}\n`;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Reconcile a per-agent managed block in `<projectRoot>/.gitignore` so the
|
|
162
|
+
* generated per-harness resource dir never shows as untracked dirt. Idempotent
|
|
163
|
+
* and convergent: replaces the block in place and writes only when the content
|
|
164
|
+
* actually changes, so the launch hot path does not churn the file (or its
|
|
165
|
+
* watchers) every run — even in a project synced by several harnesses. Called
|
|
166
|
+
* with an empty `managed` set when a sync clears a harness, which prunes the
|
|
167
|
+
* block. Never creates a `.gitignore` outside a git working tree.
|
|
168
|
+
*/
|
|
169
|
+
function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
|
|
170
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
171
|
+
if (!isInsideGitRepo(projectRoot) && !pathExists(gitignorePath))
|
|
172
|
+
return;
|
|
173
|
+
const { begin, end } = gitignoreMarkers(agent);
|
|
174
|
+
const entries = managedGitignoreEntries(agentRoot, projectRoot, managed);
|
|
175
|
+
let original = '';
|
|
176
|
+
try {
|
|
177
|
+
original = fs.readFileSync(gitignorePath, 'utf-8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
original = '';
|
|
181
|
+
}
|
|
182
|
+
const next = applyManagedBlock(original, begin, end, entries);
|
|
183
|
+
if (next === null || next === original)
|
|
184
|
+
return;
|
|
185
|
+
const tmp = gitignorePath + '.tmp';
|
|
186
|
+
fs.writeFileSync(tmp, next);
|
|
187
|
+
fs.renameSync(tmp, gitignorePath);
|
|
188
|
+
}
|
|
60
189
|
function removeManagedPath(agentRoot, rel) {
|
|
61
190
|
if (path.isAbsolute(rel) || rel.includes('..'))
|
|
62
191
|
return;
|
|
@@ -2,8 +2,8 @@ import type { RunMeta } from './scheduling/routines.js';
|
|
|
2
2
|
export interface RoutineProcessCleanupOptions {
|
|
3
3
|
runsDir?: string;
|
|
4
4
|
alive?: (pid: number) => boolean;
|
|
5
|
-
owns?: (meta: RunMeta) => boolean;
|
|
5
|
+
owns?: (meta: RunMeta) => Promise<boolean> | boolean;
|
|
6
6
|
terminate?: (pid: number) => void;
|
|
7
7
|
}
|
|
8
8
|
/** Reap process groups whose durable run record is already terminal. */
|
|
9
|
-
export declare function reapTerminalRoutineProcesses(opts?: RoutineProcessCleanupOptions): number[]
|
|
9
|
+
export declare function reapTerminalRoutineProcesses(opts?: RoutineProcessCleanupOptions): Promise<number[]>;
|
|
@@ -1,57 +1,68 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as fsp from 'fs/promises';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { execFileBounded } from './exec-bounded.js';
|
|
4
4
|
import { isAlive, killTree } from './platform/index.js';
|
|
5
5
|
import { getRunsDir } from './state.js';
|
|
6
|
-
|
|
6
|
+
/** Bound the identity probe: a `ps`/`powershell` spawn on the heartbeat tick must never freeze the daemon's event loop. */
|
|
7
|
+
const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
|
|
8
|
+
async function processMatchesRun(meta) {
|
|
7
9
|
if (!meta.pid || !meta.spawnedAt)
|
|
8
10
|
return false;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const processStart = Date.parse(startedAt);
|
|
18
|
-
return Number.isFinite(processStart) && Math.abs(processStart - meta.spawnedAt) < 30_000;
|
|
19
|
-
}
|
|
20
|
-
const elapsed = execFileSync('ps', ['-p', String(meta.pid), '-o', 'etime='], {
|
|
21
|
-
encoding: 'utf-8',
|
|
22
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
23
|
-
}).trim();
|
|
24
|
-
if (!elapsed)
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
const res = await execFileBounded('powershell.exe', [
|
|
13
|
+
'-NoProfile',
|
|
14
|
+
'-NonInteractive',
|
|
15
|
+
'-Command',
|
|
16
|
+
`(Get-Process -Id ${meta.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString("o")`,
|
|
17
|
+
], { timeoutMs: IDENTITY_PROBE_TIMEOUT_MS });
|
|
18
|
+
if (res.code !== 0)
|
|
25
19
|
return false;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
+ Number(fields[1] ?? 0) * 60
|
|
29
|
-
+ Number(fields[2] ?? 0) * 3600
|
|
30
|
-
+ Number(fields[3] ?? 0) * 86400;
|
|
31
|
-
return Math.abs((Date.now() - seconds * 1000) - meta.spawnedAt) < 30_000;
|
|
20
|
+
const processStart = Date.parse(res.stdout.trim());
|
|
21
|
+
return Number.isFinite(processStart) && Math.abs(processStart - meta.spawnedAt) < 30_000;
|
|
32
22
|
}
|
|
33
|
-
|
|
23
|
+
const res = await execFileBounded('ps', ['-p', String(meta.pid), '-o', 'etime='], { timeoutMs: IDENTITY_PROBE_TIMEOUT_MS });
|
|
24
|
+
if (res.code !== 0)
|
|
34
25
|
return false;
|
|
35
|
-
|
|
26
|
+
const elapsed = res.stdout.trim();
|
|
27
|
+
if (!elapsed)
|
|
28
|
+
return false;
|
|
29
|
+
const fields = elapsed.replace(/-/g, ':').split(':').reverse();
|
|
30
|
+
const seconds = Number(fields[0] ?? 0)
|
|
31
|
+
+ Number(fields[1] ?? 0) * 60
|
|
32
|
+
+ Number(fields[2] ?? 0) * 3600
|
|
33
|
+
+ Number(fields[3] ?? 0) * 86400;
|
|
34
|
+
return Math.abs((Date.now() - seconds * 1000) - meta.spawnedAt) < 30_000;
|
|
36
35
|
}
|
|
37
36
|
/** Reap process groups whose durable run record is already terminal. */
|
|
38
|
-
export function reapTerminalRoutineProcesses(opts = {}) {
|
|
37
|
+
export async function reapTerminalRoutineProcesses(opts = {}) {
|
|
39
38
|
const runsDir = opts.runsDir ?? getRunsDir();
|
|
40
39
|
const alive = opts.alive ?? isAlive;
|
|
41
40
|
const owns = opts.owns ?? processMatchesRun;
|
|
42
41
|
const terminate = opts.terminate ?? ((pid) => killTree(process.platform === 'win32' ? pid : -pid));
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
let jobs;
|
|
43
|
+
try {
|
|
44
|
+
jobs = await fsp.readdir(runsDir, { withFileTypes: true });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return []; // runs dir absent — nothing to reap
|
|
48
|
+
}
|
|
45
49
|
const reaped = [];
|
|
46
|
-
for (const job of
|
|
50
|
+
for (const job of jobs) {
|
|
47
51
|
if (!job.isDirectory())
|
|
48
52
|
continue;
|
|
49
53
|
const jobDir = path.join(runsDir, job.name);
|
|
50
|
-
|
|
54
|
+
let runs;
|
|
55
|
+
try {
|
|
56
|
+
runs = await fsp.readdir(jobDir, { withFileTypes: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const run of runs) {
|
|
51
62
|
if (!run.isDirectory())
|
|
52
63
|
continue;
|
|
53
64
|
try {
|
|
54
|
-
const meta = JSON.parse(
|
|
65
|
+
const meta = JSON.parse(await fsp.readFile(path.join(jobDir, run.name, 'meta.json'), 'utf-8'));
|
|
55
66
|
if (!['failed', 'timeout'].includes(meta.status) || !meta.pid || meta.hostTaskId || meta.cloudTaskId)
|
|
56
67
|
continue;
|
|
57
68
|
const completedAt = Date.parse(meta.completedAt ?? '');
|
|
@@ -59,7 +70,7 @@ export function reapTerminalRoutineProcesses(opts = {}) {
|
|
|
59
70
|
continue;
|
|
60
71
|
if (!alive(meta.pid))
|
|
61
72
|
continue;
|
|
62
|
-
if (!owns(meta))
|
|
73
|
+
if (!(await owns(meta)))
|
|
63
74
|
continue;
|
|
64
75
|
terminate(meta.pid);
|
|
65
76
|
reaped.push(meta.pid);
|
|
@@ -83,8 +83,8 @@ export declare function resetKeychainReaperCandidatesForTest(): void;
|
|
|
83
83
|
* Path-matches the full helper path so an unrelated binary named "Agents CLI" is
|
|
84
84
|
* never targeted. Returns on non-darwin without shelling anything.
|
|
85
85
|
*/
|
|
86
|
-
export declare function reapOrphanedKeychainProcesses(): {
|
|
86
|
+
export declare function reapOrphanedKeychainProcesses(): Promise<{
|
|
87
87
|
reaped: number;
|
|
88
88
|
details: string[];
|
|
89
89
|
plan: ReapPlan;
|
|
90
|
-
}
|
|
90
|
+
}>;
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* without a real `ps`; the driver ({@link reapOrphanedKeychainProcesses}) shells
|
|
7
7
|
* `ps` once per tick and kills through {@link killTree}.
|
|
8
8
|
*/
|
|
9
|
-
import { execFileSync } from 'child_process';
|
|
10
9
|
import { killTree, captureProcessStartTime } from '../platform/process.js';
|
|
10
|
+
import { execFileBounded } from '../exec-bounded.js';
|
|
11
|
+
/** Deadline for the whole-process-table `ps` on the keychain-reap tick. */
|
|
12
|
+
const KEYCHAIN_PS_TIMEOUT_MS = 5_000;
|
|
11
13
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
12
14
|
/** Grace before a helper whose parent exited is considered an orphan. */
|
|
13
15
|
export const ORPHAN_GRACE_SEC = 30;
|
|
@@ -169,7 +171,7 @@ export function resetKeychainReaperCandidatesForTest() {
|
|
|
169
171
|
* Path-matches the full helper path so an unrelated binary named "Agents CLI" is
|
|
170
172
|
* never targeted. Returns on non-darwin without shelling anything.
|
|
171
173
|
*/
|
|
172
|
-
export function reapOrphanedKeychainProcesses() {
|
|
174
|
+
export async function reapOrphanedKeychainProcesses() {
|
|
173
175
|
const details = [];
|
|
174
176
|
if (process.platform !== 'darwin') {
|
|
175
177
|
return { reaped: 0, details, plan: { kill: [], nextCandidates: new Map() } };
|
|
@@ -182,14 +184,15 @@ export function reapOrphanedKeychainProcesses() {
|
|
|
182
184
|
return { reaped: 0, details: [`helper path resolution failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
183
185
|
}
|
|
184
186
|
let out;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
187
|
+
{
|
|
188
|
+
// Async, deadline-bounded: this whole-process-table `ps` runs on the daemon's
|
|
189
|
+
// shared event loop every keychain-reap tick, so a synchronous `execFileSync`
|
|
190
|
+
// (unbounded) would freeze it (PHNX-3695).
|
|
191
|
+
const res = await execFileBounded('ps', ['-ax', '-o', 'pid=,ppid=,etime=,command='], { timeoutMs: KEYCHAIN_PS_TIMEOUT_MS });
|
|
192
|
+
if (res.code !== 0) {
|
|
193
|
+
return { reaped: 0, details: [`ps failed${res.timedOut ? ' (timed out)' : ''}: ${res.stderr.trim() || `exit ${res.code}`}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
194
|
+
}
|
|
195
|
+
out = res.stdout;
|
|
193
196
|
}
|
|
194
197
|
const rows = [];
|
|
195
198
|
for (const line of out.split('\n')) {
|
|
@@ -58,7 +58,7 @@ export interface PublishAuthVerdictResult {
|
|
|
58
58
|
error: string | null;
|
|
59
59
|
}
|
|
60
60
|
/** Publish only safe readiness metadata; useful immediately before a repo push. */
|
|
61
|
-
export declare function publishReservedAuthVerdict(options?: PublishAuthVerdictOptions): PublishAuthVerdictResult
|
|
61
|
+
export declare function publishReservedAuthVerdict(options?: PublishAuthVerdictOptions): Promise<PublishAuthVerdictResult>;
|
|
62
62
|
/**
|
|
63
63
|
* Publish local readiness, elect one ready source, then asynchronously provision
|
|
64
64
|
* only peers whose shared verdict says the bundle is missing.
|
|
@@ -13,7 +13,7 @@ import { isDialableDevice, loadDevicesSync } from '../devices/registry.js';
|
|
|
13
13
|
import { sshTargetFor } from '../devices/connect.js';
|
|
14
14
|
import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../devices/known-hosts.js';
|
|
15
15
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
16
|
-
import { readFleetSharedDeviceStates,
|
|
16
|
+
import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
|
|
17
17
|
import { getUserAgentsDir } from '../state.js';
|
|
18
18
|
/** Each import/read-back SSH operation gets this deadline plus the SSH hard-kill grace. */
|
|
19
19
|
export const AUTH_SYNC_PUSH_DEADLINE_MS = 20_000;
|
|
@@ -49,12 +49,12 @@ function authStatus(local) {
|
|
|
49
49
|
return local.ok ? 'ready' : 'invalid';
|
|
50
50
|
}
|
|
51
51
|
/** Publish only safe readiness metadata; useful immediately before a repo push. */
|
|
52
|
-
export function publishReservedAuthVerdict(options = {}) {
|
|
52
|
+
export async function publishReservedAuthVerdict(options = {}) {
|
|
53
53
|
const local = (options.inspectLocal ?? inspectReservedAuthBundle)();
|
|
54
54
|
const status = authStatus(local);
|
|
55
55
|
const device = options.localName ?? machineId();
|
|
56
56
|
try {
|
|
57
|
-
const write =
|
|
57
|
+
const write = await updateFleetSharedDeviceStateAsync(device, { auth: { status } }, options.userAgentsDir ?? getUserAgentsDir());
|
|
58
58
|
return { device, status, changed: write.changed, error: null };
|
|
59
59
|
}
|
|
60
60
|
catch (err) {
|
|
@@ -73,7 +73,7 @@ export async function syncReservedAuthBundle(deps = {}) {
|
|
|
73
73
|
skipped: [],
|
|
74
74
|
errors: [],
|
|
75
75
|
};
|
|
76
|
-
const published = publishReservedAuthVerdict(deps);
|
|
76
|
+
const published = await publishReservedAuthVerdict(deps);
|
|
77
77
|
const localStatus = published.status;
|
|
78
78
|
const localName = published.device;
|
|
79
79
|
const localNorm = normalizeHost(localName);
|
|
@@ -152,14 +152,21 @@ export declare function deriveGlobalPrefix(packageRoot: string): string;
|
|
|
152
152
|
* basename), so an unrelated dotfile in the same directory is left alone.
|
|
153
153
|
* Best-effort per entry: one unremovable sibling must not block the rest.
|
|
154
154
|
*/
|
|
155
|
-
export declare function sweepStaleInstallStaging(packageRoot: string): string[]
|
|
155
|
+
export declare function sweepStaleInstallStaging(packageRoot: string): Promise<string[]>;
|
|
156
156
|
/**
|
|
157
157
|
* Install `spec` into an explicit global prefix. `--prefix` pins the
|
|
158
158
|
* destination no matter which npm binary PATH resolves. `--ignore-scripts`
|
|
159
159
|
* skips lifecycle scripts; the caller refreshes alias shims afterwards via
|
|
160
160
|
* refreshAliasShims().
|
|
161
|
+
*
|
|
162
|
+
* `signal`, when passed, is wired into `execFile`'s own `signal` option —
|
|
163
|
+
* Node kills the child process on abort and the returned promise rejects,
|
|
164
|
+
* rather than the caller merely giving up on awaiting an orphaned process
|
|
165
|
+
* (self-update-service.ts's daemon tick needs a real kill here: on a
|
|
166
|
+
* deadline abort an un-killed `npm install -g` keeps writing into the same
|
|
167
|
+
* global prefix a subsequent retry then installs into concurrently).
|
|
161
168
|
*/
|
|
162
|
-
export declare function installPackageIntoPrefix(spec: string, prefix: string): Promise<void>;
|
|
169
|
+
export declare function installPackageIntoPrefix(spec: string, prefix: string, signal?: AbortSignal): Promise<void>;
|
|
163
170
|
/**
|
|
164
171
|
* Install `spec` into bun's global store with `bun add -g`. bun writes to
|
|
165
172
|
* `<bunGlobalDir>/node_modules/<pkg>`, which is exactly the running package
|
|
@@ -167,8 +174,10 @@ export declare function installPackageIntoPrefix(spec: string, prefix: string):
|
|
|
167
174
|
* in place. bun skips untrusted lifecycle scripts, so the caller refreshes
|
|
168
175
|
* alias shims afterwards via refreshAliasShims() rather than relying on the
|
|
169
176
|
* package's postinstall hook.
|
|
177
|
+
*
|
|
178
|
+
* `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
|
|
170
179
|
*/
|
|
171
|
-
export declare function installPackageWithBun(spec: string): Promise<void>;
|
|
180
|
+
export declare function installPackageWithBun(spec: string, signal?: AbortSignal): Promise<void>;
|
|
172
181
|
/**
|
|
173
182
|
* Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
|
|
174
183
|
* string of the form `sha512-<base64>` — npm's `dist.integrity`. Recomputes the
|
|
@@ -187,15 +196,19 @@ export declare function verifyTarballIntegrity(tarball: Buffer, integrity: strin
|
|
|
187
196
|
* against the registry attestation. Fails closed: a non-200, a download error,
|
|
188
197
|
* or a hash mismatch throws and no file path is returned, so the caller never
|
|
189
198
|
* installs an unverified artifact.
|
|
199
|
+
*
|
|
200
|
+
* `signal`, when passed, aborts the fetch alongside the own `timeoutMs` timer
|
|
201
|
+
* (whichever fires first) — a real cancellation of the in-flight request, not
|
|
202
|
+
* just an abandoned await.
|
|
190
203
|
*/
|
|
191
|
-
export declare function downloadVerifiedTarball(tarballUrl: string, integrity: string, timeoutMs?: number): Promise<string>;
|
|
204
|
+
export declare function downloadVerifiedTarball(tarballUrl: string, integrity: string, timeoutMs?: number, signal?: AbortSignal): Promise<string>;
|
|
192
205
|
/** Read the version field of the package.json at `packageRoot`, fresh from disk. */
|
|
193
|
-
export declare function readInstalledVersion(packageRoot: string): string
|
|
206
|
+
export declare function readInstalledVersion(packageRoot: string): Promise<string>;
|
|
194
207
|
/**
|
|
195
208
|
* Assert that the install at `packageRoot` now carries `expectedVersion`.
|
|
196
209
|
* npm exiting 0 only proves it wrote *somewhere*; this proves it wrote *here*.
|
|
197
210
|
*/
|
|
198
|
-
export declare function verifyInstalledVersion(packageRoot: string, expectedVersion: string): void
|
|
211
|
+
export declare function verifyInstalledVersion(packageRoot: string, expectedVersion: string): Promise<void>;
|
|
199
212
|
/**
|
|
200
213
|
* Re-run the freshly installed copy's postinstall in shims-only mode so the
|
|
201
214
|
* bare-command aliases (secrets, sessions, ...) pick up the new entrypoint
|
|
@@ -203,7 +216,7 @@ export declare function verifyInstalledVersion(packageRoot: string, expectedVers
|
|
|
203
216
|
* leaves the previous shims in place, which still point at the (now
|
|
204
217
|
* upgraded) package root.
|
|
205
218
|
*/
|
|
206
|
-
export declare function refreshAliasShims(packageRoot: string): void
|
|
219
|
+
export declare function refreshAliasShims(packageRoot: string, signal?: AbortSignal): Promise<void>;
|
|
207
220
|
/** One global bin link the upgrade reconciled: what it is and what happened. */
|
|
208
221
|
export interface BinLinkRepair {
|
|
209
222
|
/** The `package.json#bin` key (`agents`, `ag`, `browser`, `computer`). */
|
|
@@ -245,7 +258,7 @@ export interface BinLinkRepair {
|
|
|
245
258
|
* npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
|
|
246
259
|
* bin layout and is out of scope.
|
|
247
260
|
*/
|
|
248
|
-
export declare function ensureGlobalBinLinks(packageRoot: string, prefix: string): BinLinkRepair[]
|
|
261
|
+
export declare function ensureGlobalBinLinks(packageRoot: string, prefix: string): Promise<BinLinkRepair[]>;
|
|
249
262
|
export interface AgentsCliInstall {
|
|
250
263
|
/** The PATH entry (`<dir>/agents`) that resolves to this install, when found through PATH. */
|
|
251
264
|
binPath?: string;
|
package/dist/lib/self-update.js
CHANGED
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* to PATH resolution.
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from 'fs';
|
|
15
|
+
import * as fsp from 'fs/promises';
|
|
15
16
|
import * as os from 'os';
|
|
16
17
|
import * as path from 'path';
|
|
17
18
|
import { createHash, timingSafeEqual } from 'crypto';
|
|
18
|
-
import {
|
|
19
|
+
import { execFile } from 'child_process';
|
|
20
|
+
import { promisify } from 'util';
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
19
22
|
// Leaf comparator only — do not pull the full versions.ts graph into every
|
|
20
23
|
// bootstrap that imports self-update (RUSH-2331).
|
|
21
24
|
import { compareVersions } from './agent-spec/primitives.js';
|
|
@@ -314,7 +317,7 @@ export function deriveGlobalPrefix(packageRoot) {
|
|
|
314
317
|
* basename), so an unrelated dotfile in the same directory is left alone.
|
|
315
318
|
* Best-effort per entry: one unremovable sibling must not block the rest.
|
|
316
319
|
*/
|
|
317
|
-
export function sweepStaleInstallStaging(packageRoot) {
|
|
320
|
+
export async function sweepStaleInstallStaging(packageRoot) {
|
|
318
321
|
const resolved = path.resolve(packageRoot);
|
|
319
322
|
const dir = path.dirname(resolved);
|
|
320
323
|
const base = path.basename(resolved);
|
|
@@ -322,7 +325,7 @@ export function sweepStaleInstallStaging(packageRoot) {
|
|
|
322
325
|
const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
|
|
323
326
|
let entries;
|
|
324
327
|
try {
|
|
325
|
-
entries =
|
|
328
|
+
entries = await fsp.readdir(dir);
|
|
326
329
|
}
|
|
327
330
|
catch {
|
|
328
331
|
return [];
|
|
@@ -333,7 +336,7 @@ export function sweepStaleInstallStaging(packageRoot) {
|
|
|
333
336
|
continue;
|
|
334
337
|
const full = path.join(dir, entry);
|
|
335
338
|
try {
|
|
336
|
-
|
|
339
|
+
await fsp.rm(full, { recursive: true, force: true });
|
|
337
340
|
swept.push(full);
|
|
338
341
|
}
|
|
339
342
|
catch {
|
|
@@ -347,14 +350,22 @@ export function sweepStaleInstallStaging(packageRoot) {
|
|
|
347
350
|
* destination no matter which npm binary PATH resolves. `--ignore-scripts`
|
|
348
351
|
* skips lifecycle scripts; the caller refreshes alias shims afterwards via
|
|
349
352
|
* refreshAliasShims().
|
|
353
|
+
*
|
|
354
|
+
* `signal`, when passed, is wired into `execFile`'s own `signal` option —
|
|
355
|
+
* Node kills the child process on abort and the returned promise rejects,
|
|
356
|
+
* rather than the caller merely giving up on awaiting an orphaned process
|
|
357
|
+
* (self-update-service.ts's daemon tick needs a real kill here: on a
|
|
358
|
+
* deadline abort an un-killed `npm install -g` keeps writing into the same
|
|
359
|
+
* global prefix a subsequent retry then installs into concurrently).
|
|
350
360
|
*/
|
|
351
|
-
export async function installPackageIntoPrefix(spec, prefix) {
|
|
361
|
+
export async function installPackageIntoPrefix(spec, prefix, signal) {
|
|
352
362
|
const { execFile } = await import('child_process');
|
|
353
363
|
const { promisify } = await import('util');
|
|
354
364
|
const execFileAsync = promisify(execFile);
|
|
355
365
|
// On Windows `npm` is `npm.cmd`; execFile cannot run it without a shell (ENOENT).
|
|
356
366
|
await execFileAsync('npm', ['install', '-g', '--prefix', prefix, spec, '--ignore-scripts'], {
|
|
357
367
|
shell: needsWindowsShell('npm'),
|
|
368
|
+
signal,
|
|
358
369
|
});
|
|
359
370
|
}
|
|
360
371
|
/**
|
|
@@ -364,8 +375,10 @@ export async function installPackageIntoPrefix(spec, prefix) {
|
|
|
364
375
|
* in place. bun skips untrusted lifecycle scripts, so the caller refreshes
|
|
365
376
|
* alias shims afterwards via refreshAliasShims() rather than relying on the
|
|
366
377
|
* package's postinstall hook.
|
|
378
|
+
*
|
|
379
|
+
* `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
|
|
367
380
|
*/
|
|
368
|
-
export async function installPackageWithBun(spec) {
|
|
381
|
+
export async function installPackageWithBun(spec, signal) {
|
|
369
382
|
const { execFile } = await import('child_process');
|
|
370
383
|
const { promisify } = await import('util');
|
|
371
384
|
const execFileAsync = promisify(execFile);
|
|
@@ -373,7 +386,7 @@ export async function installPackageWithBun(spec) {
|
|
|
373
386
|
// --ignore-scripts: the tarball has already been integrity-verified, but its
|
|
374
387
|
// lifecycle scripts must not run at install time (the caller refreshes shims
|
|
375
388
|
// explicitly via refreshAliasShims()) — same fail-closed posture as the npm path.
|
|
376
|
-
await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun') });
|
|
389
|
+
await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun'), signal });
|
|
377
390
|
}
|
|
378
391
|
/**
|
|
379
392
|
* Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
|
|
@@ -411,9 +424,14 @@ export function verifyTarballIntegrity(tarball, integrity) {
|
|
|
411
424
|
* against the registry attestation. Fails closed: a non-200, a download error,
|
|
412
425
|
* or a hash mismatch throws and no file path is returned, so the caller never
|
|
413
426
|
* installs an unverified artifact.
|
|
427
|
+
*
|
|
428
|
+
* `signal`, when passed, aborts the fetch alongside the own `timeoutMs` timer
|
|
429
|
+
* (whichever fires first) — a real cancellation of the in-flight request, not
|
|
430
|
+
* just an abandoned await.
|
|
414
431
|
*/
|
|
415
|
-
export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000) {
|
|
416
|
-
const
|
|
432
|
+
export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000, signal) {
|
|
433
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
434
|
+
const response = await fetch(tarballUrl, { signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal });
|
|
417
435
|
if (!response.ok) {
|
|
418
436
|
throw new Error(`could not download tarball from ${tarballUrl} (HTTP ${response.status})`);
|
|
419
437
|
}
|
|
@@ -425,15 +443,15 @@ export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs =
|
|
|
425
443
|
return file;
|
|
426
444
|
}
|
|
427
445
|
/** Read the version field of the package.json at `packageRoot`, fresh from disk. */
|
|
428
|
-
export function readInstalledVersion(packageRoot) {
|
|
429
|
-
return JSON.parse(
|
|
446
|
+
export async function readInstalledVersion(packageRoot) {
|
|
447
|
+
return JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf-8')).version;
|
|
430
448
|
}
|
|
431
449
|
/**
|
|
432
450
|
* Assert that the install at `packageRoot` now carries `expectedVersion`.
|
|
433
451
|
* npm exiting 0 only proves it wrote *somewhere*; this proves it wrote *here*.
|
|
434
452
|
*/
|
|
435
|
-
export function verifyInstalledVersion(packageRoot, expectedVersion) {
|
|
436
|
-
const actual = readInstalledVersion(packageRoot);
|
|
453
|
+
export async function verifyInstalledVersion(packageRoot, expectedVersion) {
|
|
454
|
+
const actual = await readInstalledVersion(packageRoot);
|
|
437
455
|
if (actual !== expectedVersion) {
|
|
438
456
|
const manager = detectPackageManager(packageRoot);
|
|
439
457
|
const hint = manualInstallHint(manager, packageRoot, `${NPM_PACKAGE_NAME}@${expectedVersion}`);
|
|
@@ -448,16 +466,21 @@ export function verifyInstalledVersion(packageRoot, expectedVersion) {
|
|
|
448
466
|
* leaves the previous shims in place, which still point at the (now
|
|
449
467
|
* upgraded) package root.
|
|
450
468
|
*/
|
|
451
|
-
export function refreshAliasShims(packageRoot) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
469
|
+
export async function refreshAliasShims(packageRoot, signal) {
|
|
470
|
+
try {
|
|
471
|
+
await execFileAsync(process.execPath, [path.join(packageRoot, 'scripts', 'postinstall.js')], {
|
|
472
|
+
env: { ...process.env, AGENTS_POSTINSTALL_SHIMS_ONLY: '1' },
|
|
473
|
+
signal,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
/* best-effort — a failure here leaves the previous shims in place, same as the prior spawnSync (which ignored its exit code/stdio too) */
|
|
478
|
+
}
|
|
456
479
|
}
|
|
457
480
|
/** Resolve `p` through symlinks, or null when it does not resolve (missing/dangling). */
|
|
458
|
-
function realpathOrNull(p) {
|
|
481
|
+
async function realpathOrNull(p) {
|
|
459
482
|
try {
|
|
460
|
-
return
|
|
483
|
+
return await fsp.realpath(p);
|
|
461
484
|
}
|
|
462
485
|
catch {
|
|
463
486
|
return null;
|
|
@@ -472,18 +495,18 @@ function realpathOrNull(p) {
|
|
|
472
495
|
* repair that still does not resolve (target missing, unwritable bin dir) is
|
|
473
496
|
* reported `failed` with the reason rather than silently swallowed.
|
|
474
497
|
*/
|
|
475
|
-
function reconcileBinLink(name, linkPath, target) {
|
|
476
|
-
const wanted = realpathOrNull(target);
|
|
477
|
-
if (wanted !== null && realpathOrNull(linkPath) === wanted) {
|
|
498
|
+
async function reconcileBinLink(name, linkPath, target) {
|
|
499
|
+
const wanted = await realpathOrNull(target);
|
|
500
|
+
if (wanted !== null && (await realpathOrNull(linkPath)) === wanted) {
|
|
478
501
|
return { name, linkPath, target, action: 'ok' };
|
|
479
502
|
}
|
|
480
503
|
try {
|
|
481
|
-
|
|
504
|
+
await fsp.mkdir(path.dirname(linkPath), { recursive: true });
|
|
482
505
|
// Replace whatever is there (a dangling link, a stale link, or nothing).
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const resolved = realpathOrNull(linkPath);
|
|
486
|
-
if (resolved !== null && resolved === realpathOrNull(target)) {
|
|
506
|
+
await fsp.rm(linkPath, { force: true });
|
|
507
|
+
await fsp.symlink(path.relative(path.dirname(linkPath), target), linkPath);
|
|
508
|
+
const resolved = await realpathOrNull(linkPath);
|
|
509
|
+
if (resolved !== null && resolved === (await realpathOrNull(target))) {
|
|
487
510
|
return { name, linkPath, target, action: 'repaired' };
|
|
488
511
|
}
|
|
489
512
|
return {
|
|
@@ -524,10 +547,10 @@ function reconcileBinLink(name, linkPath, target) {
|
|
|
524
547
|
* npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
|
|
525
548
|
* bin layout and is out of scope.
|
|
526
549
|
*/
|
|
527
|
-
export function ensureGlobalBinLinks(packageRoot, prefix) {
|
|
550
|
+
export async function ensureGlobalBinLinks(packageRoot, prefix) {
|
|
528
551
|
let bin;
|
|
529
552
|
try {
|
|
530
|
-
const pkg = JSON.parse(
|
|
553
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
|
|
531
554
|
bin = pkg && typeof pkg.bin === 'object' && pkg.bin !== null ? pkg.bin : {};
|
|
532
555
|
}
|
|
533
556
|
catch (err) {
|
|
@@ -538,7 +561,7 @@ export function ensureGlobalBinLinks(packageRoot, prefix) {
|
|
|
538
561
|
for (const [name, rel] of Object.entries(bin)) {
|
|
539
562
|
if (typeof rel !== 'string' || !rel)
|
|
540
563
|
continue;
|
|
541
|
-
repairs.push(reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
|
|
564
|
+
repairs.push(await reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
|
|
542
565
|
}
|
|
543
566
|
return repairs;
|
|
544
567
|
}
|