@phnx-labs/agents-cli 1.22.78 → 1.22.79
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 +11 -0
- package/README.md +28 -1
- package/dist/bootstrap.js +40 -12
- package/dist/commands/accounts.d.ts +22 -0
- package/dist/commands/accounts.js +158 -35
- package/dist/commands/config.js +37 -0
- package/dist/commands/exec.js +21 -10
- package/dist/commands/update.js +169 -18
- package/dist/commands/versions.d.ts +11 -0
- package/dist/commands/versions.js +30 -4
- package/dist/commands/view.d.ts +4 -0
- package/dist/commands/view.js +114 -19
- package/dist/index.js +35 -2
- package/dist/lib/account-catalog.d.ts +97 -1
- package/dist/lib/account-catalog.js +134 -6
- package/dist/lib/account-registry.d.ts +43 -0
- package/dist/lib/account-registry.js +97 -2
- package/dist/lib/accounting/rotate.d.ts +2 -2
- package/dist/lib/accounting/rotate.js +5 -5
- package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
- package/dist/lib/accounts/auth-operation-lock.js +55 -0
- package/dist/lib/accounts/connect.d.ts +170 -0
- package/dist/lib/accounts/connect.js +383 -0
- package/dist/lib/capabilities.js +2 -0
- package/dist/lib/commands.js +2 -0
- package/dist/lib/config-keys.d.ts +11 -2
- package/dist/lib/config-keys.js +21 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/harness-update-service.d.ts +110 -0
- package/dist/lib/daemon/harness-update-service.js +216 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +0 -1
- package/dist/lib/device-config.js +50 -5
- package/dist/lib/exec.js +26 -2
- package/dist/lib/fs-atomic.d.ts +2 -0
- package/dist/lib/fs-atomic.js +2 -0
- package/dist/lib/hooks/install.js +7 -2
- package/dist/lib/installations/active-check.d.ts +48 -0
- package/dist/lib/installations/active-check.js +84 -0
- package/dist/lib/installations/index.d.ts +5 -1
- package/dist/lib/installations/index.js +4 -0
- package/dist/lib/installations/installation-lock.d.ts +6 -0
- package/dist/lib/installations/installation-lock.js +29 -0
- package/dist/lib/installations/launch-gate.d.ts +69 -0
- package/dist/lib/installations/launch-gate.js +133 -0
- package/dist/lib/installations/native-command.d.ts +5 -0
- package/dist/lib/installations/native-command.js +52 -0
- package/dist/lib/installations/shims.d.ts +8 -2
- package/dist/lib/installations/shims.js +105 -2
- package/dist/lib/installations/store.d.ts +5 -1
- package/dist/lib/installations/store.js +24 -3
- package/dist/lib/installations/strategies.js +55 -35
- package/dist/lib/installations/types.d.ts +17 -0
- package/dist/lib/installations/update-cancellation.d.ts +82 -0
- package/dist/lib/installations/update-cancellation.js +122 -0
- package/dist/lib/installations/update-policy.d.ts +69 -0
- package/dist/lib/installations/update-policy.js +114 -0
- package/dist/lib/installations/update-runtime.d.ts +118 -0
- package/dist/lib/installations/update-runtime.js +321 -0
- package/dist/lib/installations/update.d.ts +25 -0
- package/dist/lib/installations/update.js +141 -2
- package/dist/lib/installations/versions.d.ts +1 -0
- package/dist/lib/installations/versions.js +166 -131
- package/dist/lib/platform/process.d.ts +3 -1
- package/dist/lib/platform/process.js +2 -2
- package/dist/lib/staleness/detectors/commands.d.ts +1 -2
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
- package/dist/lib/staleness/detectors/rules.d.ts +1 -2
- package/dist/lib/staleness/detectors/skills.d.ts +1 -2
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
- package/dist/lib/staleness/writers/commands.d.ts +1 -2
- package/dist/lib/staleness/writers/hooks.d.ts +1 -2
- package/dist/lib/staleness/writers/mcp.d.ts +1 -2
- package/dist/lib/staleness/writers/permissions.d.ts +1 -12
- package/dist/lib/staleness/writers/plugins.d.ts +1 -6
- package/dist/lib/staleness/writers/rules.d.ts +1 -2
- package/dist/lib/staleness/writers/skills.d.ts +1 -2
- package/dist/lib/staleness/writers/subagents.d.ts +1 -2
- package/dist/lib/staleness/writers/workflows.d.ts +1 -8
- package/dist/lib/state.d.ts +3 -1
- package/dist/lib/state.js +38 -13
- package/dist/lib/types.d.ts +26 -1
- package/dist/lib/types.js +5 -0
- package/dist/lib/view-types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One lock for install, migration, launch, update, and policy changes.
|
|
3
|
+
* Keep its target OUTSIDE the installation directory: a fresh install must
|
|
4
|
+
* acquire exclusion before publishing a directory that readers can migrate.
|
|
5
|
+
* Every holder agrees on the stale threshold so none breaks a live npm install.
|
|
6
|
+
*/
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { ensureLockTarget } from '../fs-atomic.js';
|
|
9
|
+
import { getHistoryDir } from '../state.js';
|
|
10
|
+
import { VERSION_RE } from '../agent-spec/primitives.js';
|
|
11
|
+
import { isAgentId } from '../types.js';
|
|
12
|
+
export function installationLockTarget(agent, label) {
|
|
13
|
+
if (!isAgentId(agent) || !VERSION_RE.test(label))
|
|
14
|
+
throw new Error('Invalid managed installation.');
|
|
15
|
+
// Encode labels so a valid "foo.lock" cannot collide with the lock directory
|
|
16
|
+
// for "foo". Windows aliases must still contend for the same physical home.
|
|
17
|
+
const canonicalLabel = process.platform === 'win32' ? label.toLowerCase().replace(/\.+$/, '') : label;
|
|
18
|
+
const key = Buffer.from(canonicalLabel).toString('hex') || 'empty';
|
|
19
|
+
const target = path.join(getHistoryDir(), 'installation-locks', agent, key);
|
|
20
|
+
ensureLockTarget(target, '', 0o700);
|
|
21
|
+
return target;
|
|
22
|
+
}
|
|
23
|
+
export const INSTALLATION_LOCK_STALE_MS = 10 * 60_000;
|
|
24
|
+
export const INSTALLATION_LOCK_ACQUIRE_TIMEOUT_MS = 5 * 60_000;
|
|
25
|
+
export const INSTALLATION_LOCK_OPTIONS = {
|
|
26
|
+
staleMs: INSTALLATION_LOCK_STALE_MS,
|
|
27
|
+
acquireTimeoutMs: INSTALLATION_LOCK_ACQUIRE_TIMEOUT_MS,
|
|
28
|
+
realpath: false,
|
|
29
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launch side of the launch/update mutual exclusion (PHNX-3940).
|
|
3
|
+
*
|
|
4
|
+
* `updateInstallation` (`update.ts`) holds an exclusive lock on
|
|
5
|
+
* `installationLockTarget(agent, label)` for its ENTIRE stage->verify->
|
|
6
|
+
* commit->record transaction. This module makes every launch of that same
|
|
7
|
+
* installation take the SAME lock, briefly, before the real binary starts
|
|
8
|
+
* running:
|
|
9
|
+
*
|
|
10
|
+
* 1. Acquire the lock. If an automatic update of this exact installation is
|
|
11
|
+
* currently in flight, this BLOCKS here until it finishes (commits or
|
|
12
|
+
* rolls back) — the launch cannot proceed past this point while a swap
|
|
13
|
+
* could be underway. This is deliberately not a short, fail-open wait: a
|
|
14
|
+
* short timeout that gave up and launched anyway would reintroduce
|
|
15
|
+
* exactly the race this exists to close, on the one case where waiting
|
|
16
|
+
* actually matters (genuine contention).
|
|
17
|
+
* 2. While still holding the lock, record a launch lease for the caller's
|
|
18
|
+
* pid (`shims.ts`'s `recordLaunchLease`) — so if an update instead starts
|
|
19
|
+
* AFTER this launch already has the lock, it will see the lease as soon
|
|
20
|
+
* as it acquires the lock itself and defer (`active-check.ts`).
|
|
21
|
+
* 3. Release the lock. The actual exec/spawn happens immediately after,
|
|
22
|
+
* outside the lock — for an exec-replacing native shim (see
|
|
23
|
+
* `shims.ts`'s `generateShimScript`/`generateVersionedAliasScript`) that
|
|
24
|
+
* gap is a single shell statement; for a Node-spawned launch it is the
|
|
25
|
+
* `spawn()` call itself, whose pid is already the one just leased.
|
|
26
|
+
*
|
|
27
|
+
* Called from three places, so no launch surface is exempt: the native POSIX
|
|
28
|
+
* shim and versioned-alias scripts invoke the hidden `agents __launch-lease`
|
|
29
|
+
* verb (see `index.ts`) right before their final `exec` and FAIL CLOSED (a
|
|
30
|
+
* non-zero exit refuses the launch) rather than falling through on error —
|
|
31
|
+
* silently proceeding would mean "the lock was unavailable" and "no update is
|
|
32
|
+
* running" become indistinguishable to the one check that exists to tell them
|
|
33
|
+
* apart; `execShimPassthrough` and the main `agents run` spawn path
|
|
34
|
+
* (`exec.ts`) call {@link withLaunchGate} directly, in-process, wrapping the
|
|
35
|
+
* `spawn()` call itself.
|
|
36
|
+
*/
|
|
37
|
+
import type { AgentId } from '../types.js';
|
|
38
|
+
/**
|
|
39
|
+
* Acquire the per-installation update lock, run `fn` while holding it, then
|
|
40
|
+
* release. Use this to wrap the SPAWN itself (not just the lease write) so an
|
|
41
|
+
* in-flight update cannot be mid-commit while the new process is starting —
|
|
42
|
+
* see the module docblock. `fn` should be fast (a spawn call plus a lease
|
|
43
|
+
* write): this lock is held by every launch of this installation, so slow
|
|
44
|
+
* work here serializes launches against each other unnecessarily.
|
|
45
|
+
*/
|
|
46
|
+
export declare function withLaunchGate<T>(agent: AgentId, label: string, fn: () => T): Promise<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Acquire the per-installation update lock, register a launch lease for
|
|
49
|
+
* `pid`, then release. For a caller that already has its own pid before the
|
|
50
|
+
* risky operation (the native shim's `$$`, exec-replaced so the pid is
|
|
51
|
+
* final) — a Node caller that spawns a child should use {@link withLaunchGate}
|
|
52
|
+
* around the spawn itself instead, since here the process already exists by
|
|
53
|
+
* the time the lock is acquired.
|
|
54
|
+
*/
|
|
55
|
+
export declare function acquireLaunchGate(agent: AgentId, label: string, pid: number): Promise<() => void>;
|
|
56
|
+
/** Keep a live launcher's lease until its operation ends, without holding the lock. */
|
|
57
|
+
export declare function withInstallationLease<T>(agent: AgentId, label: string, fn: () => Promise<T>): Promise<T>;
|
|
58
|
+
/**
|
|
59
|
+
* Entry point for the hidden `agents __launch-lease <agent> <label> <pid>`
|
|
60
|
+
* verb the generated native shims call right before their final `exec`. A
|
|
61
|
+
* non-zero return makes the calling shim `exit 1` instead of proceeding to
|
|
62
|
+
* exec (see `shims.ts`) — the shim's other pre-launch step (`agents sync
|
|
63
|
+
* --launch`) is best-effort and swallows failure, but this one exists
|
|
64
|
+
* specifically to prevent a launch from racing an in-progress update, so a
|
|
65
|
+
* failure here (most commonly: the lock is genuinely held past
|
|
66
|
+
* `LAUNCH_GATE_ACQUIRE_TIMEOUT_MS`) must refuse the launch, not silently
|
|
67
|
+
* allow it. The printed message is what the operator sees.
|
|
68
|
+
*/
|
|
69
|
+
export declare function runLaunchLeaseCli(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launch side of the launch/update mutual exclusion (PHNX-3940).
|
|
3
|
+
*
|
|
4
|
+
* `updateInstallation` (`update.ts`) holds an exclusive lock on
|
|
5
|
+
* `installationLockTarget(agent, label)` for its ENTIRE stage->verify->
|
|
6
|
+
* commit->record transaction. This module makes every launch of that same
|
|
7
|
+
* installation take the SAME lock, briefly, before the real binary starts
|
|
8
|
+
* running:
|
|
9
|
+
*
|
|
10
|
+
* 1. Acquire the lock. If an automatic update of this exact installation is
|
|
11
|
+
* currently in flight, this BLOCKS here until it finishes (commits or
|
|
12
|
+
* rolls back) — the launch cannot proceed past this point while a swap
|
|
13
|
+
* could be underway. This is deliberately not a short, fail-open wait: a
|
|
14
|
+
* short timeout that gave up and launched anyway would reintroduce
|
|
15
|
+
* exactly the race this exists to close, on the one case where waiting
|
|
16
|
+
* actually matters (genuine contention).
|
|
17
|
+
* 2. While still holding the lock, record a launch lease for the caller's
|
|
18
|
+
* pid (`shims.ts`'s `recordLaunchLease`) — so if an update instead starts
|
|
19
|
+
* AFTER this launch already has the lock, it will see the lease as soon
|
|
20
|
+
* as it acquires the lock itself and defer (`active-check.ts`).
|
|
21
|
+
* 3. Release the lock. The actual exec/spawn happens immediately after,
|
|
22
|
+
* outside the lock — for an exec-replacing native shim (see
|
|
23
|
+
* `shims.ts`'s `generateShimScript`/`generateVersionedAliasScript`) that
|
|
24
|
+
* gap is a single shell statement; for a Node-spawned launch it is the
|
|
25
|
+
* `spawn()` call itself, whose pid is already the one just leased.
|
|
26
|
+
*
|
|
27
|
+
* Called from three places, so no launch surface is exempt: the native POSIX
|
|
28
|
+
* shim and versioned-alias scripts invoke the hidden `agents __launch-lease`
|
|
29
|
+
* verb (see `index.ts`) right before their final `exec` and FAIL CLOSED (a
|
|
30
|
+
* non-zero exit refuses the launch) rather than falling through on error —
|
|
31
|
+
* silently proceeding would mean "the lock was unavailable" and "no update is
|
|
32
|
+
* running" become indistinguishable to the one check that exists to tell them
|
|
33
|
+
* apart; `execShimPassthrough` and the main `agents run` spawn path
|
|
34
|
+
* (`exec.ts`) call {@link withLaunchGate} directly, in-process, wrapping the
|
|
35
|
+
* `spawn()` call itself.
|
|
36
|
+
*/
|
|
37
|
+
import { withFileLockAsync } from '../fs-atomic.js';
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import { ensureInstallationLocked, installationDir } from './store.js';
|
|
40
|
+
import { recordLaunchLease } from './shims.js';
|
|
41
|
+
import { AGENTS } from '../agents.js';
|
|
42
|
+
import { VERSION_RE } from '../agent-spec/primitives.js';
|
|
43
|
+
import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
|
|
44
|
+
/**
|
|
45
|
+
* Matches `update.ts`'s `UPDATE_LOCK_STALE_MS` — the same lock, so a stale
|
|
46
|
+
* threshold that doesn't match would let one side break a lock the other
|
|
47
|
+
* legitimately still holds mid-transaction.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* How long a launch will wait for an in-flight update of the SAME
|
|
51
|
+
* installation to finish. Bounded by the real worst case an update should
|
|
52
|
+
* ever take (a 120s npm install plus two launch probes), not a hot-path
|
|
53
|
+
* budget — see the module docblock for why this must not be a short,
|
|
54
|
+
* fail-open timeout.
|
|
55
|
+
*/
|
|
56
|
+
const LAUNCH_GATE_ACQUIRE_TIMEOUT_MS = 3 * 60_000;
|
|
57
|
+
/**
|
|
58
|
+
* Acquire the per-installation update lock, run `fn` while holding it, then
|
|
59
|
+
* release. Use this to wrap the SPAWN itself (not just the lease write) so an
|
|
60
|
+
* in-flight update cannot be mid-commit while the new process is starting —
|
|
61
|
+
* see the module docblock. `fn` should be fast (a spawn call plus a lease
|
|
62
|
+
* write): this lock is held by every launch of this installation, so slow
|
|
63
|
+
* work here serializes launches against each other unnecessarily.
|
|
64
|
+
*/
|
|
65
|
+
export async function withLaunchGate(agent, label, fn) {
|
|
66
|
+
if (!Object.hasOwn(AGENTS, agent) || !VERSION_RE.test(label))
|
|
67
|
+
throw new Error('Invalid managed installation.');
|
|
68
|
+
if (!fs.existsSync(installationDir(agent, label)))
|
|
69
|
+
throw new Error(`No installation directory for ${agent}@${label}.`);
|
|
70
|
+
// Guarantees `installation.json` exists and is VALID before locking on it —
|
|
71
|
+
// migrating a legacy pre-frozen version dir when needed, exactly like every
|
|
72
|
+
// other reader (`listInstallations`). Seeding an EMPTY file as a bare lock
|
|
73
|
+
// target (the earlier version of this code) would make a subsequent
|
|
74
|
+
// `readInstallation` see "corrupted, not valid JSON" instead of running that
|
|
75
|
+
// migration, permanently wedging a legacy installation the first time
|
|
76
|
+
// anything launched it.
|
|
77
|
+
const recordPath = installationLockTarget(agent, label);
|
|
78
|
+
return withFileLockAsync(recordPath, () => {
|
|
79
|
+
ensureInstallationLocked(agent, label);
|
|
80
|
+
return fn();
|
|
81
|
+
}, {
|
|
82
|
+
...INSTALLATION_LOCK_OPTIONS,
|
|
83
|
+
acquireTimeoutMs: LAUNCH_GATE_ACQUIRE_TIMEOUT_MS,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Acquire the per-installation update lock, register a launch lease for
|
|
88
|
+
* `pid`, then release. For a caller that already has its own pid before the
|
|
89
|
+
* risky operation (the native shim's `$$`, exec-replaced so the pid is
|
|
90
|
+
* final) — a Node caller that spawns a child should use {@link withLaunchGate}
|
|
91
|
+
* around the spawn itself instead, since here the process already exists by
|
|
92
|
+
* the time the lock is acquired.
|
|
93
|
+
*/
|
|
94
|
+
export async function acquireLaunchGate(agent, label, pid) {
|
|
95
|
+
return withLaunchGate(agent, label, () => recordLaunchLease(agent, label, pid));
|
|
96
|
+
}
|
|
97
|
+
/** Keep a live launcher's lease until its operation ends, without holding the lock. */
|
|
98
|
+
export async function withInstallationLease(agent, label, fn) {
|
|
99
|
+
const release = await acquireLaunchGate(agent, label, process.pid);
|
|
100
|
+
try {
|
|
101
|
+
return await fn();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
release();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Entry point for the hidden `agents __launch-lease <agent> <label> <pid>`
|
|
109
|
+
* verb the generated native shims call right before their final `exec`. A
|
|
110
|
+
* non-zero return makes the calling shim `exit 1` instead of proceeding to
|
|
111
|
+
* exec (see `shims.ts`) — the shim's other pre-launch step (`agents sync
|
|
112
|
+
* --launch`) is best-effort and swallows failure, but this one exists
|
|
113
|
+
* specifically to prevent a launch from racing an in-progress update, so a
|
|
114
|
+
* failure here (most commonly: the lock is genuinely held past
|
|
115
|
+
* `LAUNCH_GATE_ACQUIRE_TIMEOUT_MS`) must refuse the launch, not silently
|
|
116
|
+
* allow it. The printed message is what the operator sees.
|
|
117
|
+
*/
|
|
118
|
+
export async function runLaunchLeaseCli(argv) {
|
|
119
|
+
const [agentRaw, label, pidRaw] = argv;
|
|
120
|
+
const pid = Number(pidRaw);
|
|
121
|
+
if (!agentRaw || !label || !Number.isInteger(pid) || pid <= 0) {
|
|
122
|
+
process.stderr.write('usage: agents __launch-lease <agent> <label> <pid>\n');
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await acquireLaunchGate(agentRaw, label, pid);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
process.stderr.write(`agents __launch-lease: ${err.message}\n`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentId } from '../types.js';
|
|
2
|
+
/** Run a finite native login/logout command in an already-selected home. */
|
|
3
|
+
export declare function runNativeAccountCommand(agent: AgentId, label: string, args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<{
|
|
4
|
+
code: number | null;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { composeWin32CommandLine } from '../platform/index.js';
|
|
4
|
+
import { getBinaryPath } from './store.js';
|
|
5
|
+
import { withInstallationLease } from './launch-gate.js';
|
|
6
|
+
/** Run a finite native login/logout command in an already-selected home. */
|
|
7
|
+
export async function runNativeAccountCommand(agent, label, args, env, signal) {
|
|
8
|
+
return withInstallationLease(agent, label, () => new Promise((resolve, reject) => {
|
|
9
|
+
signal?.throwIfAborted();
|
|
10
|
+
let binary = getBinaryPath(agent, label);
|
|
11
|
+
if (process.platform === 'win32' && fs.existsSync(`${binary}.cmd`))
|
|
12
|
+
binary += '.cmd';
|
|
13
|
+
const shell = process.platform === 'win32' && binary.endsWith('.cmd');
|
|
14
|
+
const child = spawn(shell ? composeWin32CommandLine(binary, args) : binary, shell ? [] : args, {
|
|
15
|
+
env, stdio: 'inherit', shell,
|
|
16
|
+
});
|
|
17
|
+
let failure;
|
|
18
|
+
let termination;
|
|
19
|
+
const abort = () => {
|
|
20
|
+
failure = signal?.reason instanceof Error ? signal.reason : new Error('Authentication was cancelled.');
|
|
21
|
+
// A Windows .cmd wrapper is a process tree: killing only cmd.exe leaves
|
|
22
|
+
// the native login alive. Scope termination to this child that we own.
|
|
23
|
+
if (process.platform === 'win32' && child.pid) {
|
|
24
|
+
termination = new Promise((done) => {
|
|
25
|
+
execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, (error) => {
|
|
26
|
+
if (error)
|
|
27
|
+
failure = new Error(`Could not stop the native authentication process tree: ${error.message}`);
|
|
28
|
+
done();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
child.kill('SIGTERM');
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
37
|
+
if (signal?.aborted)
|
|
38
|
+
abort();
|
|
39
|
+
// An AbortError is not completion: keep the installation lease until the
|
|
40
|
+
// native process has actually closed.
|
|
41
|
+
child.once('error', (error) => { failure = error; });
|
|
42
|
+
child.once('close', async (code) => {
|
|
43
|
+
signal?.removeEventListener('abort', abort);
|
|
44
|
+
// cmd.exe closing is not proof that its native descendants are gone.
|
|
45
|
+
await termination;
|
|
46
|
+
if (failure)
|
|
47
|
+
reject(failure);
|
|
48
|
+
else
|
|
49
|
+
resolve({ code });
|
|
50
|
+
});
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { AgentId } from '../types.js';
|
|
2
2
|
import { getShimsDir } from '../state.js';
|
|
3
3
|
export { getShimsDir };
|
|
4
|
+
/** Record that `pid` is about to execute this installation's binary. Call right before handing off to it. */
|
|
5
|
+
export declare function recordLaunchLease(agent: AgentId, label: string, pid: number): () => void;
|
|
6
|
+
/** Read-only: stale leases cannot defer an update, and preview never deletes files. */
|
|
7
|
+
export declare function hasLiveLaunchLease(agent: AgentId, label: string): boolean;
|
|
4
8
|
export type ConflictStrategy = 'keep-dest' | 'overwrite' | 'ask-per-file';
|
|
5
9
|
export interface ConflictInfo {
|
|
6
10
|
agent: AgentId;
|
|
@@ -60,7 +64,7 @@ export interface ConflictInfo {
|
|
|
60
64
|
* top-level entry add/remove — deep edits to plugin contents won't
|
|
61
65
|
* trigger auto-resync, run `agents sync` for that.
|
|
62
66
|
*/
|
|
63
|
-
export declare const SHIM_SCHEMA_VERSION =
|
|
67
|
+
export declare const SHIM_SCHEMA_VERSION = 31;
|
|
64
68
|
/**
|
|
65
69
|
* Generate the full bash shim script for the given agent. The returned string
|
|
66
70
|
* is written to ~/.agents/shims/{cliCommand} and made executable.
|
|
@@ -175,7 +179,7 @@ export declare function removeShim(agent: AgentId): boolean;
|
|
|
175
179
|
* sorted alphabetically before the real self-updated grok binary and
|
|
176
180
|
* was silently launched instead.
|
|
177
181
|
*/
|
|
178
|
-
export declare const VERSIONED_ALIAS_SCHEMA_VERSION =
|
|
182
|
+
export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 19;
|
|
179
183
|
/**
|
|
180
184
|
* Agents whose config directory can be relocated by an environment variable
|
|
181
185
|
* (the per-agent `managedEnv` block in `generateVersionedAliasScript` below).
|
|
@@ -348,6 +352,8 @@ export declare function listShimFileNames(): string[];
|
|
|
348
352
|
export declare function pruneOrphanedCommandShim(fileName: string): boolean;
|
|
349
353
|
/** Regenerate the shim if missing or older than the current schema; never downgrade a newer on-disk shim. */
|
|
350
354
|
export declare function ensureShimCurrent(agent: AgentId): 'created' | 'updated' | 'current';
|
|
355
|
+
/** Refresh only existing generated launchers before unattended binary changes. */
|
|
356
|
+
export declare function refreshOwnedLaunchers(agent: AgentId, label: string): void;
|
|
351
357
|
/** Get the logical (extensionless) shim path for an agent. */
|
|
352
358
|
export declare function getShimPath(agent: AgentId): string;
|
|
353
359
|
/** Return the first executable on PATH that would shadow the managed shim, excluding the shim itself and legacy pre-split files. */
|
|
@@ -12,6 +12,9 @@ export { getShimsDir };
|
|
|
12
12
|
import { AGENTS, agentConfigDirName, readAuthAccountIdentity } from '../agents.js';
|
|
13
13
|
import { codexHomeShimBash } from '../codex-home.js';
|
|
14
14
|
import { resolveHarnessAdapter } from '../harness/index.js';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { captureProcessStartTime } from '../platform/process.js';
|
|
17
|
+
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
15
18
|
/** Files and directories to always skip during conflict detection and migration. */
|
|
16
19
|
const MIGRATION_IGNORE_LIST = new Set([
|
|
17
20
|
'node_modules',
|
|
@@ -31,6 +34,62 @@ function shouldIgnore(name) {
|
|
|
31
34
|
return true;
|
|
32
35
|
return false;
|
|
33
36
|
}
|
|
37
|
+
// Launch leases are written under the shared launch/update gate BEFORE exec or
|
|
38
|
+
// spawn. A live launcher protects the gap until its child appears in ps; an
|
|
39
|
+
// exec-replacing shim keeps the same PID. A birth fingerprint defeats PID reuse.
|
|
40
|
+
function launchLeaseDir(agent, label) {
|
|
41
|
+
return path.join(getVersionsDir(), agent, label, '.launch-leases');
|
|
42
|
+
}
|
|
43
|
+
/** Record that `pid` is about to execute this installation's binary. Call right before handing off to it. */
|
|
44
|
+
export function recordLaunchLease(agent, label, pid) {
|
|
45
|
+
const dir = launchLeaseDir(agent, label);
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
const file = path.join(dir, `${pid}-${randomUUID()}.json`);
|
|
48
|
+
atomicWriteFileSync(file, JSON.stringify({ pid, birth: captureProcessStartTime(pid, { fresh: true }) }));
|
|
49
|
+
return () => { try {
|
|
50
|
+
fs.unlinkSync(file);
|
|
51
|
+
}
|
|
52
|
+
catch { /* dead leases are also ignored by readers */ } };
|
|
53
|
+
}
|
|
54
|
+
function pidAlive(pid) {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return err.code !== 'ESRCH';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Read-only: stale leases cannot defer an update, and preview never deletes files. */
|
|
64
|
+
export function hasLiveLaunchLease(agent, label) {
|
|
65
|
+
const dir = launchLeaseDir(agent, label);
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = fs.readdirSync(dir);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return err.code !== 'ENOENT';
|
|
72
|
+
}
|
|
73
|
+
let live = false;
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const match = entry.match(/^(\d+)(?:-[a-f0-9-]+)?\.json$/);
|
|
76
|
+
if (!match)
|
|
77
|
+
continue;
|
|
78
|
+
const pid = Number(match[1]);
|
|
79
|
+
if (!pidAlive(pid))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const lease = JSON.parse(fs.readFileSync(path.join(dir, entry), 'utf8'));
|
|
83
|
+
const birth = captureProcessStartTime(pid, { fresh: true });
|
|
84
|
+
if (!lease.birth || !birth || lease.birth === birth)
|
|
85
|
+
live = true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
live = true;
|
|
89
|
+
} // Unknown state is busy, never permission to swap.
|
|
90
|
+
}
|
|
91
|
+
return live;
|
|
92
|
+
}
|
|
34
93
|
/** Detect filenames that exist in both `src` and `dest`, excluding symlinks in `dest`. */
|
|
35
94
|
function detectConflicts(src, dest, prefix = '') {
|
|
36
95
|
const conflicts = [];
|
|
@@ -207,7 +266,7 @@ async function promptConflictStrategy(conflictInfos) {
|
|
|
207
266
|
// grok-0.2.118-* file — a 99-byte wrapper that exec'd cursor-agent —
|
|
208
267
|
// sorted alphabetically before the real self-updated grok binary and
|
|
209
268
|
// was silently launched instead.
|
|
210
|
-
export const SHIM_SCHEMA_VERSION =
|
|
269
|
+
export const SHIM_SCHEMA_VERSION = 31;
|
|
211
270
|
/** Internal marker string used to embed the schema version in shim scripts. */
|
|
212
271
|
const SHIM_VERSION_MARKER = 'agents-shim-version:';
|
|
213
272
|
function shellQuote(value) {
|
|
@@ -699,6 +758,23 @@ if [ "\$LAUNCH_SKIP" = "0" ]; then
|
|
|
699
758
|
"\$AGENTS_BIN" sync --agent "\$AGENT" --agent-version "\$VERSION" --launch --cwd "\$PWD" --quiet 2>/dev/null || true
|
|
700
759
|
fi
|
|
701
760
|
|
|
761
|
+
# Register a launch lease for THIS pid before the exec below replaces this
|
|
762
|
+
# process image (PHNX-3940) — \$\$ survives exec, so the lease's pid matches
|
|
763
|
+
# the real running binary. Takes the SAME per-installation lock the automatic
|
|
764
|
+
# background update uses for its whole stage->commit transaction, so this
|
|
765
|
+
# call blocks here (never past the exec below) for as long as an update of
|
|
766
|
+
# this exact installation is actively in flight, and otherwise returns almost
|
|
767
|
+
# immediately. Unlike the sync call above, this is NOT best-effort: silently
|
|
768
|
+
# falling through on failure (a lock the updater is genuinely still holding,
|
|
769
|
+
# or any other error) would let this process exec straight into a binary an
|
|
770
|
+
# update could be mid-swap on — exactly the race this exists to close. Fail
|
|
771
|
+
# closed instead.
|
|
772
|
+
if ! "\$AGENTS_BIN" __launch-lease "\$AGENT" "\$VERSION" "\$\$"; then
|
|
773
|
+
echo "agents: could not safely coordinate this launch with a possibly in-progress update of \$AGENT@\$VERSION." >&2
|
|
774
|
+
echo " Check: agents update \$AGENT@\$VERSION --check Retry once any update finishes." >&2
|
|
775
|
+
exit 1
|
|
776
|
+
fi
|
|
777
|
+
|
|
702
778
|
${resolveHarnessAdapter(agent).shimExecTail?.(launchArgs) ?? `exec "$BINARY"${launchArgs} "$@"`}
|
|
703
779
|
`;
|
|
704
780
|
}
|
|
@@ -1018,7 +1094,7 @@ export function removeShim(agent) {
|
|
|
1018
1094
|
// v18 — Cursor aliases select the file credential store and swap HOME to the
|
|
1019
1095
|
// version home because current Cursor writes auth.json under ~/.cursor
|
|
1020
1096
|
// and ignores XDG_CONFIG_HOME for credential storage.
|
|
1021
|
-
export const VERSIONED_ALIAS_SCHEMA_VERSION =
|
|
1097
|
+
export const VERSIONED_ALIAS_SCHEMA_VERSION = 19;
|
|
1022
1098
|
/** Internal marker string used to embed the schema version in versioned alias scripts. */
|
|
1023
1099
|
const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
|
|
1024
1100
|
// The version string is interpolated into a generated bash script and into
|
|
@@ -1061,6 +1137,7 @@ export function supportsIsolatedInstall(agent) {
|
|
|
1061
1137
|
export function generateVersionedAliasScript(agent, version) {
|
|
1062
1138
|
assertSafeVersion(version);
|
|
1063
1139
|
const agentConfig = AGENTS[agent];
|
|
1140
|
+
const agentsBin = shellQuote(getAgentsBinForGeneratedShim());
|
|
1064
1141
|
// Same derivation as `generateShimScript` so nested layouts (e.g.,
|
|
1065
1142
|
// Antigravity's `~/.gemini/antigravity-cli`) land in the right place.
|
|
1066
1143
|
const configDirName = path.relative(os.homedir(), agentConfig.configDir);
|
|
@@ -1209,6 +1286,15 @@ if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
|
1209
1286
|
fi
|
|
1210
1287
|
${managedEnv}
|
|
1211
1288
|
|
|
1289
|
+
# Register a launch lease for THIS pid before the exec below (PHNX-3940) — see
|
|
1290
|
+
# generateShimScript's identical call for what this closes and why it fails
|
|
1291
|
+
# closed rather than falling through on error. \$\$ survives exec.
|
|
1292
|
+
if ! ${agentsBin} __launch-lease "${agent}" "${version}" "\$\$"; then
|
|
1293
|
+
echo "agents: could not safely coordinate this launch with a possibly in-progress update of ${agent}@${version}." >&2
|
|
1294
|
+
echo " Check: agents update ${agent}@${version} --check Retry once any update finishes." >&2
|
|
1295
|
+
exit 1
|
|
1296
|
+
fi
|
|
1297
|
+
|
|
1212
1298
|
${resolveHarnessAdapter(agent).shimExecTail?.(launchArgs) ?? `exec "$BINARY"${launchArgs} "$@"`}
|
|
1213
1299
|
`;
|
|
1214
1300
|
}
|
|
@@ -2061,6 +2147,23 @@ export function ensureShimCurrent(agent) {
|
|
|
2061
2147
|
}
|
|
2062
2148
|
return 'current';
|
|
2063
2149
|
}
|
|
2150
|
+
/** Refresh only existing generated launchers before unattended binary changes. */
|
|
2151
|
+
export function refreshOwnedLaunchers(agent, label) {
|
|
2152
|
+
const owned = (file) => {
|
|
2153
|
+
try {
|
|
2154
|
+
return fs.readFileSync(file, 'utf8').includes('Auto-generated by agents-cli - do not edit');
|
|
2155
|
+
}
|
|
2156
|
+
catch (err) {
|
|
2157
|
+
if (err.code === 'ENOENT')
|
|
2158
|
+
return false;
|
|
2159
|
+
throw err;
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
if (owned(onDiskShimPath(agent)))
|
|
2163
|
+
ensureShimCurrent(agent);
|
|
2164
|
+
if (owned(versionedAliasOnDiskPath(agent, label)))
|
|
2165
|
+
ensureVersionedAliasCurrent(agent, label);
|
|
2166
|
+
}
|
|
2064
2167
|
/** Get the logical (extensionless) shim path for an agent. */
|
|
2065
2168
|
export function getShimPath(agent) {
|
|
2066
2169
|
const shimsDir = getShimsDir();
|
|
@@ -25,6 +25,8 @@ export declare function mintInstallationId(): string;
|
|
|
25
25
|
* Never mints — use {@link ensureInstallation} for the migrating read.
|
|
26
26
|
*/
|
|
27
27
|
export declare function readInstallation(agent: AgentId, label: string): Installation | null;
|
|
28
|
+
/** Semantic feature checks use the release, while paths and settings keep the label. */
|
|
29
|
+
export declare function installedReleaseFor(agent: AgentId, label: string): string;
|
|
28
30
|
export declare function writeInstallation(installation: Installation): void;
|
|
29
31
|
/**
|
|
30
32
|
* Read the record for an existing version dir, minting and persisting one on
|
|
@@ -37,12 +39,14 @@ export declare function writeInstallation(installation: Installation): void;
|
|
|
37
39
|
* describe an install that isn't there.
|
|
38
40
|
*/
|
|
39
41
|
export declare function ensureInstallation(agent: AgentId, label: string): Installation;
|
|
42
|
+
/** Migration for callers already holding the canonical installation lock. */
|
|
43
|
+
export declare function ensureInstallationLocked(agent: AgentId, label: string, legacyCreatedAt?: string): Installation;
|
|
40
44
|
/**
|
|
41
45
|
* Create the record for a freshly-installed version dir. Idempotent: a repeat
|
|
42
46
|
* `agents add` of the same label keeps the original id (identity is frozen) and
|
|
43
47
|
* only records the release if it actually moved.
|
|
44
48
|
*/
|
|
45
|
-
export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string): Installation;
|
|
49
|
+
export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string, initialPolicy?: Installation['updatePolicy']): Installation;
|
|
46
50
|
/**
|
|
47
51
|
* Move an installation's recorded release forward, preserving identity. Returns
|
|
48
52
|
* the persisted record. Call only AFTER the new release is live on disk — the
|
|
@@ -4,7 +4,8 @@ import * as path from 'path';
|
|
|
4
4
|
import { execFile } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
6
|
import * as yaml from 'yaml';
|
|
7
|
-
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
7
|
+
import { atomicWriteFileSync, withFileLock } from '../fs-atomic.js';
|
|
8
|
+
import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
|
|
8
9
|
import { getHomeDir, getUserAgentsDir, getVersionsDir, readMeta } from '../state.js';
|
|
9
10
|
import { VERSION_RE, compareVersions } from '../agent-spec/primitives.js';
|
|
10
11
|
import { AGENTS, findInPath } from '../agents.js';
|
|
@@ -60,6 +61,9 @@ function assertValidRecord(value, file) {
|
|
|
60
61
|
if (!Array.isArray(record.history) || record.history.length === 0) {
|
|
61
62
|
throw new Error(`Installation record corrupted at ${file}: "history" must be a non-empty array.`);
|
|
62
63
|
}
|
|
64
|
+
if (record.updatePolicy !== undefined && record.updatePolicy !== 'latest' && record.updatePolicy !== 'pinned') {
|
|
65
|
+
throw new Error(`Installation record corrupted at ${file}: unknown update policy.`);
|
|
66
|
+
}
|
|
63
67
|
return record;
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
@@ -84,6 +88,12 @@ export function readInstallation(agent, label) {
|
|
|
84
88
|
}
|
|
85
89
|
return assertValidRecord(parsed, file);
|
|
86
90
|
}
|
|
91
|
+
/** Semantic feature checks use the release, while paths and settings keep the label. */
|
|
92
|
+
export function installedReleaseFor(agent, label) {
|
|
93
|
+
if (!Object.hasOwn(AGENTS, agent) || !VERSION_RE.test(label))
|
|
94
|
+
return label;
|
|
95
|
+
return readInstallation(agent, label)?.releaseVersion ?? label;
|
|
96
|
+
}
|
|
87
97
|
export function writeInstallation(installation) {
|
|
88
98
|
const file = installationRecordPath(installation.agent, installation.label);
|
|
89
99
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -100,6 +110,16 @@ export function writeInstallation(installation) {
|
|
|
100
110
|
* describe an install that isn't there.
|
|
101
111
|
*/
|
|
102
112
|
export function ensureInstallation(agent, label) {
|
|
113
|
+
const existing = readInstallation(agent, label);
|
|
114
|
+
if (existing)
|
|
115
|
+
return existing;
|
|
116
|
+
if (!fs.existsSync(installationDir(agent, label)))
|
|
117
|
+
throw new Error(`No installation directory for ${agent}@${label}.`);
|
|
118
|
+
const createdAt = fs.statSync(installationDir(agent, label)).mtime.toISOString();
|
|
119
|
+
return withFileLock(installationLockTarget(agent, label), () => ensureInstallationLocked(agent, label, createdAt), { ...INSTALLATION_LOCK_OPTIONS, acquireTimeoutMs: 0 });
|
|
120
|
+
}
|
|
121
|
+
/** Migration for callers already holding the canonical installation lock. */
|
|
122
|
+
export function ensureInstallationLocked(agent, label, legacyCreatedAt) {
|
|
103
123
|
const existing = readInstallation(agent, label);
|
|
104
124
|
if (existing)
|
|
105
125
|
return existing;
|
|
@@ -109,7 +129,7 @@ export function ensureInstallation(agent, label) {
|
|
|
109
129
|
}
|
|
110
130
|
let createdAt;
|
|
111
131
|
try {
|
|
112
|
-
createdAt = fs.statSync(dir).mtime.toISOString();
|
|
132
|
+
createdAt = legacyCreatedAt ?? fs.statSync(dir).mtime.toISOString();
|
|
113
133
|
}
|
|
114
134
|
catch {
|
|
115
135
|
createdAt = nowIso();
|
|
@@ -132,7 +152,7 @@ export function ensureInstallation(agent, label) {
|
|
|
132
152
|
* `agents add` of the same label keeps the original id (identity is frozen) and
|
|
133
153
|
* only records the release if it actually moved.
|
|
134
154
|
*/
|
|
135
|
-
export function createInstallation(agent, label, releaseVersion) {
|
|
155
|
+
export function createInstallation(agent, label, releaseVersion, initialPolicy = 'latest') {
|
|
136
156
|
if (!VERSION_RE.test(label)) {
|
|
137
157
|
throw new Error(`Invalid installation label: ${JSON.stringify(label)}`);
|
|
138
158
|
}
|
|
@@ -152,6 +172,7 @@ export function createInstallation(agent, label, releaseVersion) {
|
|
|
152
172
|
createdAt: at,
|
|
153
173
|
updatedAt: at,
|
|
154
174
|
history: [{ releaseVersion, at }],
|
|
175
|
+
updatePolicy: initialPolicy,
|
|
155
176
|
};
|
|
156
177
|
writeInstallation(created);
|
|
157
178
|
return created;
|