@phnx-labs/agents-cli 1.22.69 → 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 +34 -0
- package/README.md +34 -9
- package/dist/bootstrap.js +4 -4
- package/dist/commands/accounts.js +23 -7
- package/dist/commands/exec.js +2 -2
- package/dist/commands/import.js +2 -2
- package/dist/commands/models.js +2 -2
- package/dist/commands/permissions.js +2 -2
- package/dist/commands/repo.js +2 -2
- package/dist/commands/rules.js +1 -1
- package/dist/commands/send.js +9 -3
- 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/commands/traces.js +1 -1
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-registry.d.ts +3 -3
- package/dist/lib/account-registry.js +25 -7
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/acp/client.d.ts +1 -1
- package/dist/lib/acp/client.js +12 -1
- package/dist/lib/acp/harnesses.js +1 -1
- package/dist/lib/add-dir.js +0 -2
- package/dist/lib/agent-cli-commands.js +0 -2
- package/dist/lib/agent-spec/agents.d.ts +1 -1
- package/dist/lib/agent-spec/agents.js +2 -83
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +149 -20
- package/dist/lib/browser/remote-control.d.ts +6 -3
- package/dist/lib/browser/remote-control.js +6 -3
- package/dist/lib/browser/service.d.ts +4 -1
- package/dist/lib/browser/service.js +7 -1
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/channels/registry.d.ts +6 -0
- package/dist/lib/channels/send.d.ts +11 -2
- package/dist/lib/channels/send.js +11 -2
- package/dist/lib/cloud/rush.d.ts +10 -2
- package/dist/lib/cloud/rush.js +15 -9
- 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 +41 -11
- 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 +237 -80
- 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/exec.d.ts +2 -2
- package/dist/lib/exec.js +3 -34
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/feed-broadcast.d.ts +12 -29
- package/dist/lib/feed-broadcast.js +28 -27
- 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/hooks/install.js +0 -87
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/installations/strategies.js +1 -1
- package/dist/lib/mcp-registry.js +0 -13
- package/dist/lib/mcp.js +2 -2
- package/dist/lib/model-tiers.js +1 -1
- package/dist/lib/models.js +0 -63
- package/dist/lib/notify.d.ts +11 -0
- package/dist/lib/notify.js +17 -4
- package/dist/lib/owner-message.d.ts +46 -3
- package/dist/lib/owner-message.js +26 -6
- package/dist/lib/permissions-registry.d.ts +0 -2
- package/dist/lib/permissions-registry.js +3 -50
- package/dist/lib/permissions.d.ts +3 -17
- package/dist/lib/permissions.js +4 -73
- 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/rush-session.d.ts +19 -0
- package/dist/lib/rush-session.js +24 -0
- package/dist/lib/secrets/drivers/rush.js +2 -1
- 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/cloud.js +2 -1
- 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/sink-format.d.ts +34 -0
- package/dist/lib/sink-format.js +17 -0
- package/dist/lib/staleness/detectors/permissions.js +0 -20
- package/dist/lib/staleness/writers/commands.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +2 -2
- package/dist/lib/subagents-registry.js +2 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -37
- 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/dist/lib/types.d.ts +1 -1
- 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);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rush session freshness — the ONE judgment shared by every consumer of
|
|
3
|
+
* ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
|
|
4
|
+
*
|
|
5
|
+
* A Rush session stores `expires_at` as Unix seconds. Two values mean "never
|
|
6
|
+
* expires" and MUST NOT be read as an absolute timestamp:
|
|
7
|
+
* - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
|
|
8
|
+
* `0` = non-expiring by contract (mirrors rush/cli's isFresh,
|
|
9
|
+
* the same fix RUSH-1310 landed for the daemon-less freshness
|
|
10
|
+
* check and PHNX-3637 landed for the release endpoints).
|
|
11
|
+
* - `undefined` — a token written without an expiry.
|
|
12
|
+
*
|
|
13
|
+
* Reading `expires_at: 0` as epoch-1970 was PHNX-3645: every Phoenix-authed box
|
|
14
|
+
* had its valid, non-expiring session rejected as "Rush session expired at
|
|
15
|
+
* 1970-01-01", so `agents run --cloud`, `agents secrets push/pull` over Rush,
|
|
16
|
+
* and cloud session discovery all refused to run. Every consumer that parses
|
|
17
|
+
* freshness routes through this predicate so the special-case lives in one place.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isRushSessionExpired(expiresAt: number | undefined): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rush session freshness — the ONE judgment shared by every consumer of
|
|
3
|
+
* ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
|
|
4
|
+
*
|
|
5
|
+
* A Rush session stores `expires_at` as Unix seconds. Two values mean "never
|
|
6
|
+
* expires" and MUST NOT be read as an absolute timestamp:
|
|
7
|
+
* - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
|
|
8
|
+
* `0` = non-expiring by contract (mirrors rush/cli's isFresh,
|
|
9
|
+
* the same fix RUSH-1310 landed for the daemon-less freshness
|
|
10
|
+
* check and PHNX-3637 landed for the release endpoints).
|
|
11
|
+
* - `undefined` — a token written without an expiry.
|
|
12
|
+
*
|
|
13
|
+
* Reading `expires_at: 0` as epoch-1970 was PHNX-3645: every Phoenix-authed box
|
|
14
|
+
* had its valid, non-expiring session rejected as "Rush session expired at
|
|
15
|
+
* 1970-01-01", so `agents run --cloud`, `agents secrets push/pull` over Rush,
|
|
16
|
+
* and cloud session discovery all refused to run. Every consumer that parses
|
|
17
|
+
* freshness routes through this predicate so the special-case lives in one place.
|
|
18
|
+
*/
|
|
19
|
+
export function isRushSessionExpired(expiresAt) {
|
|
20
|
+
// `0` (non-expiring pid_ bearer) and a missing value are never expired.
|
|
21
|
+
if (typeof expiresAt !== 'number' || expiresAt === 0)
|
|
22
|
+
return false;
|
|
23
|
+
return expiresAt <= Date.now() / 1000;
|
|
24
|
+
}
|
|
@@ -13,6 +13,7 @@ import * as fs from 'fs';
|
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as yaml from 'yaml';
|
|
16
|
+
import { isRushSessionExpired } from '../../rush-session.js';
|
|
16
17
|
const PROXY_BASE = 'https://api.prix.dev';
|
|
17
18
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
18
19
|
const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
|
|
@@ -27,7 +28,7 @@ function readRushToken() {
|
|
|
27
28
|
throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
|
|
28
29
|
}
|
|
29
30
|
const expiresAt = data.session?.expires_at;
|
|
30
|
-
if (
|
|
31
|
+
if (isRushSessionExpired(expiresAt)) {
|
|
31
32
|
const expiredAt = new Date(expiresAt * 1000).toISOString();
|
|
32
33
|
throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
|
|
33
34
|
}
|
|
@@ -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;
|