external-subagent 0.1.0

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/external-subagent-debug-mcp.mjs +5 -0
  4. package/bin/external-subagent-debug.mjs +13 -0
  5. package/bin/external-subagent-mcp.mjs +20 -0
  6. package/bin/external-subagent.mjs +8 -0
  7. package/bin/zas.mjs +8 -0
  8. package/cli/commands/agents.mjs +117 -0
  9. package/cli/commands/config.mjs +117 -0
  10. package/cli/commands/daemon.mjs +36 -0
  11. package/cli/commands/maintenance.mjs +27 -0
  12. package/cli/commands/plugin.mjs +96 -0
  13. package/cli/commands/tasks.mjs +64 -0
  14. package/cli/commands/update.mjs +219 -0
  15. package/cli/config/read.mjs +24 -0
  16. package/cli/config/schema.mjs +75 -0
  17. package/cli/config/write.mjs +39 -0
  18. package/cli/constants.mjs +24 -0
  19. package/cli/errors.mjs +7 -0
  20. package/cli/fs-atomic.mjs +36 -0
  21. package/cli/install/codex.mjs +352 -0
  22. package/cli/install/init.mjs +206 -0
  23. package/cli/install/layout.mjs +54 -0
  24. package/cli/install/npm-hook.mjs +49 -0
  25. package/cli/install/path.mjs +43 -0
  26. package/cli/install/payload.mjs +84 -0
  27. package/cli/install/plugin-stage.mjs +232 -0
  28. package/cli/install/reconcile.mjs +209 -0
  29. package/cli/install/recovery.mjs +79 -0
  30. package/cli/install/service-activation.mjs +133 -0
  31. package/cli/install/service-macos.mjs +146 -0
  32. package/cli/install/update.mjs +170 -0
  33. package/cli/install/zcode.mjs +239 -0
  34. package/cli/main.mjs +429 -0
  35. package/cli/maintenance.mjs +104 -0
  36. package/cli/paths.mjs +40 -0
  37. package/cli/rpc.mjs +261 -0
  38. package/launchd/com.external-subagent.daemon.plist.template +37 -0
  39. package/npm/native/README.md +19 -0
  40. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  41. package/npm/native/darwin-arm64/external-subagentd +0 -0
  42. package/npm/native/darwin-arm64/payload.json +20 -0
  43. package/package.json +60 -0
  44. package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
  45. package/plugins/codex/external-subagent/.mcp.json +9 -0
  46. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
  47. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
  48. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
  49. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
  50. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
  51. package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
  52. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
  53. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
  54. package/schema/external-subagent-public-api.json +185 -0
  55. package/schema/zas-observation-v1.1.schema.json +58 -0
  56. package/schema/zas-public-reasoning-source.json +14 -0
  57. package/scripts/release/build-native-payload.mjs +119 -0
  58. package/scripts/release/check-native-tarball.mjs +125 -0
  59. package/scripts/release/stage-debug-plugin.mjs +60 -0
  60. package/scripts/release/test-installed-tarball.mjs +73 -0
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import { CliError } from '../errors.mjs';
3
+ import { atomicWrite, jsonBytes, readOptional, restoreOptional, sha256 } from '../fs-atomic.mjs';
4
+
5
+ // Install-state snapshots, step journal, and rollback. A failed init restores
6
+ // every tracked file to its prior bytes (or removes files it created) and
7
+ // removes directories it created, so a partial installation never masquerades
8
+ // as a complete one.
9
+
10
+ export function loadInstallState(file) {
11
+ const bytes = readOptional(file);
12
+ if (bytes === null) return { schema_version: 1, completed: [] };
13
+ try { return JSON.parse(bytes); } catch { throw new CliError('INVALID_INSTALL_STATE', 'install state is invalid JSON'); }
14
+ }
15
+
16
+ // The updater's schema-2 state (candidate/active/phase). Corrupted bytes
17
+ // are evidence, never an empty state: the original file is preserved under a
18
+ // timestamped name so the recovery can be inspected, and the caller restarts
19
+ // from an empty state with the preservation reported alongside the result.
20
+ export function loadUpdateState(file) {
21
+ const bytes = readOptional(file);
22
+ if (bytes === null) return { state: null, recovery: null };
23
+ try {
24
+ const state = JSON.parse(bytes);
25
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
26
+ throw new SyntaxError('update state is not a JSON object');
27
+ }
28
+ return { state, recovery: null };
29
+ } catch (error) {
30
+ const backup = `${file}.corrupt-${Date.now()}`;
31
+ try { fs.renameSync(file, backup); } catch (renameError) {
32
+ throw new CliError('INSTALL_STATE_CORRUPT', `install state is corrupted and could not be preserved: ${renameError.message}`);
33
+ }
34
+ return {
35
+ state: null,
36
+ recovery: { status: 'recovered', code: 'INSTALL_STATE_CORRUPT', backup, error: error.message },
37
+ };
38
+ }
39
+ }
40
+
41
+ export function markInstallStep(file, completed, id) {
42
+ completed.add(id);
43
+ atomicWrite(file, jsonBytes({ schema_version: 1, completed: [...completed] }));
44
+ }
45
+
46
+ export function snapshotFile(file) {
47
+ const bytes = readOptional(file);
48
+ return { bytes, sha256: bytes === null ? null : sha256(bytes) };
49
+ }
50
+
51
+ export function restoreSnapshotFile(file, snapshot) {
52
+ if (snapshot.bytes !== null && sha256(snapshot.bytes) !== snapshot.sha256) {
53
+ throw new Error(`snapshot hash mismatch for ${file}`);
54
+ }
55
+ restoreOptional(file, snapshot.bytes);
56
+ const restored = readOptional(file);
57
+ if ((snapshot.bytes === null && restored !== null)
58
+ || (snapshot.bytes !== null && (!restored || sha256(restored) !== snapshot.sha256))) {
59
+ throw new Error(`rollback verification failed for ${file}`);
60
+ }
61
+ }
62
+
63
+ export function rollbackFiles(tracked) {
64
+ const rollbackErrors = [];
65
+ for (const entry of Object.values(tracked)) {
66
+ try { restoreSnapshotFile(entry.file, entry.snapshot); } catch (rollbackError) { rollbackErrors.push(rollbackError); }
67
+ }
68
+ return rollbackErrors;
69
+ }
70
+
71
+ export function removeCreatedDirectories(directories) {
72
+ const rollbackErrors = [];
73
+ for (const [name, directory] of Object.entries(directories)) {
74
+ if (!directory.existed && fs.existsSync(directory.path)) {
75
+ try { fs.rmSync(directory.path, { recursive: true, force: true }); } catch (rollbackError) { rollbackErrors.push(rollbackError); }
76
+ }
77
+ }
78
+ return rollbackErrors;
79
+ }
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ import { atomicWrite } from '../fs-atomic.mjs';
4
+ import { CliError } from '../errors.mjs';
5
+ import { callDaemon } from '../rpc.mjs';
6
+ import { LAUNCH_AGENT_LABEL } from '../constants.mjs';
7
+ import { launchctl } from './service-macos.mjs';
8
+
9
+ const xml = (s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
10
+ const unxml = (s) => s.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
11
+ const program = /(<key>ProgramArguments<\/key>\s*<array>\s*<string>)([^<]+)(<\/string>)/;
12
+ const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
13
+
14
+ export function hasInstalledService(paths) {
15
+ return Boolean(paths.launchAgent && fs.existsSync(paths.launchAgent));
16
+ }
17
+
18
+ // The service definition is the executable entry: change only its first argv,
19
+ // retaining the installed configuration/environment and rollback bytes exactly.
20
+ export async function activateService(paths, candidate, options = {}) {
21
+ const control = options.launchctl || launchctl;
22
+ const rpc = options.callDaemon || callDaemon;
23
+ const target = `gui/${process.getuid()}/${LAUNCH_AGENT_LABEL}`;
24
+ const oldPlist = fs.readFileSync(paths.launchAgent);
25
+ const match = oldPlist.toString().match(program);
26
+ if (!match) throw new CliError('SERVICE_DEFINITION_INVALID', 'installed service has no executable entry');
27
+ const oldPath = unxml(match[2]);
28
+ const servicePid = async () => {
29
+ const result = await control(['print', target]);
30
+ if (result.absent) return null;
31
+ if (result.skipped) throw new CliError('SERVICE_NOT_VERIFIED', 'service control was skipped');
32
+ const pid = Number(result.stdout?.match(/\bpid = (\d+)/)?.[1]);
33
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
34
+ };
35
+ const oldPid = await servicePid();
36
+ const oldStatus = oldPid ? await rpc(paths.socket, 'status', {}) : null;
37
+ // The running daemon's self-reported digest is the truth about which bytes
38
+ // the service was executing (bound to the running executable at startup);
39
+ // the file at oldPath may already carry the NEXT version after an npm
40
+ // install replaced the package directory in place.
41
+ const runningArtifact = oldStatus?.identity?.daemon?.artifact;
42
+ const oldSha = (typeof runningArtifact?.sha256 === 'string' && runningArtifact.sha256) || digest(oldPath);
43
+ if (oldPid) {
44
+ const drain = await rpc(paths.socket, 'drain-status', {});
45
+ if (!drain.ready_for_activation) throw new CliError('DAEMON_NOT_DRAINED', 'daemon still owns active or unreaped work');
46
+ const claim = await rpc(paths.socket, 'activate-ready', {});
47
+ if (!claim.activation_claim) throw new CliError('ACTIVATION_NOT_CLAIMED', 'daemon did not grant activation');
48
+ }
49
+ // npm may have replaced the old program path with the new payload: rolling
50
+ // the service back must run the OLD bytes, from the original path when they
51
+ // are still intact or from the retained payload copy the updater staged
52
+ // for exactly this recovery.
53
+ const rollbackExecutable = () => {
54
+ if (fs.existsSync(oldPath) && digest(oldPath) === oldSha) return { path: oldPath, sha256: oldSha };
55
+ const retained = options.rollbackPayload;
56
+ if (retained && typeof retained.path === 'string' && typeof retained.sha256 === 'string'
57
+ && fs.existsSync(retained.path) && digest(retained.path) === retained.sha256) {
58
+ return { path: retained.path, sha256: retained.sha256 };
59
+ }
60
+ throw new CliError('ROLLBACK_PAYLOAD_UNAVAILABLE', 'previous payload bytes are unavailable; automatic rollback refused');
61
+ };
62
+ const healthy = async (expected, previousPid, previousGeneration) => {
63
+ const deadline = Date.now() + (options.healthTimeoutMs ?? 10_000);
64
+ let last;
65
+ do {
66
+ try {
67
+ const pid = await servicePid();
68
+ if (!pid || pid === previousPid) throw new Error('service has no new process');
69
+ const status = await rpc(paths.socket, 'status', {});
70
+ const identity = status.identity?.daemon;
71
+ // Health verification uses the service generation and the running
72
+ // artifact identity. The diagnostic-only mcp_version never gates
73
+ // admission.
74
+ if (!status.service_generation || status.service_generation === previousGeneration
75
+ || identity?.artifact?.path !== expected.path || identity?.artifact?.sha256 !== expected.sha256
76
+ || (expected.version && identity?.version !== expected.version)) throw new Error('running daemon identity does not match selected payload');
77
+ if (await servicePid() !== pid) throw new Error('service process changed during health verification');
78
+ return { pid, version: identity.version, artifact: identity.artifact, service_generation: status.service_generation };
79
+ } catch (error) { last = error; }
80
+ await new Promise((resolve) => setTimeout(resolve, 50));
81
+ } while (Date.now() < deadline);
82
+ throw new CliError('SERVICE_HEALTH_FAILED', last?.message || 'service did not become healthy');
83
+ };
84
+ const unload = async () => {
85
+ const result = await control(['print', target]);
86
+ if (result.absent) return;
87
+ await control(['bootout', target]);
88
+ const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
89
+ do {
90
+ const probe = await control(['print', target]);
91
+ if (probe.absent) return;
92
+ await new Promise((resolve) => setTimeout(resolve, 50));
93
+ } while (Date.now() < deadline);
94
+ throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'launchd service remained registered after bootout');
95
+ };
96
+ const writeProgram = (entryPath) => atomicWrite(paths.launchAgent, Buffer.from(oldPlist.toString().replace(program, (_, a, b, c) => `${a}${xml(entryPath)}${c}`)), 0o600);
97
+ const rollbackService = async () => {
98
+ await unload();
99
+ const previous = rollbackExecutable();
100
+ writeProgram(previous.path);
101
+ await control(['bootstrap', `gui/${process.getuid()}`, paths.launchAgent]);
102
+ return healthy({ path: previous.path, sha256: previous.sha256, version: oldStatus?.identity?.daemon?.version }, oldPid, oldStatus?.service_generation);
103
+ };
104
+ try {
105
+ await unload();
106
+ writeProgram(candidate.path);
107
+ await control(['bootstrap', `gui/${process.getuid()}`, paths.launchAgent]);
108
+ const health = await healthy(candidate, oldPid, oldStatus?.service_generation);
109
+ return {
110
+ ...health,
111
+ rollback: async () => {
112
+ const restored = await rollbackService();
113
+ if (digest(oldPath) !== oldSha) return { ...restored, restored_from: 'retained_payload' };
114
+ return restored;
115
+ },
116
+ };
117
+ } catch (error) {
118
+ if (!oldPid) {
119
+ try {
120
+ await unload();
121
+ atomicWrite(paths.launchAgent, oldPlist, 0o600);
122
+ error.rollback = { attempted: true, restored: false, plist_restored: true, error: 'no previous service identity to verify' };
123
+ } catch (rollbackError) {
124
+ error.rollback = { attempted: true, restored: false, plist_restored: false, error: rollbackError.message };
125
+ }
126
+ throw error;
127
+ }
128
+ try {
129
+ error.rollback = await rollbackService();
130
+ } catch (rollbackError) { error.rollbackError = rollbackError.message; }
131
+ throw error;
132
+ }
133
+ }
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL, ZCODE_RUNTIME } from '../constants.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+ import { atomicWrite } from '../fs-atomic.mjs';
7
+ import { nativeBinary } from './layout.mjs';
8
+ import { LAUNCHD_FIXED_PATH } from './path.mjs';
9
+ import { readConfig } from '../config/read.mjs';
10
+
11
+ // The one macOS service template for the product daemon. The plist pins
12
+ // absolute payload paths and a fixed PATH so the GUI/launchd environment can
13
+ // never depend on the interactive shell. Launchctl activation honors the
14
+ // EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL seam so fixture runs never load real
15
+ // services; everything else still writes real files.
16
+
17
+ function escapeXml(value) {
18
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
19
+ }
20
+
21
+ export function launchAgentPlist(paths, options = {}) {
22
+ const daemon = nativeBinary(DAEMON_BIN_NAME);
23
+ const config = readConfig(paths.config);
24
+ const { runtime_path: dshRuntime, home: dshHome, profile: dshProfile, version: dshVersion } = config.subagents.dsh;
25
+ const { runtime_path: codexRuntime, home: codexHome } = config.subagents.codex;
26
+ const configRevision = config.revision;
27
+ // AUD-005/D1: the standalone service never depends on an unrelated runtime.
28
+ // The pinned ZCode runtime is forwarded only when that installation exists;
29
+ // without it the daemon still starts and the zcode adapter fails closed at
30
+ // spawn ("ZCODE_RUNTIME_PATH is unavailable") instead of the whole service
31
+ // dying on launchd because --runtime cannot canonicalize. Installing (or
32
+ // removing) ZCode later is picked up by the next init, which rewrites the
33
+ // plist. The seam exists so tests can pin both branches deterministically.
34
+ const zcodeRuntime = options.zcodeRuntime ?? ZCODE_RUNTIME;
35
+ const programArguments = [
36
+ `<string>${escapeXml(daemon)}</string>`,
37
+ '<string>--database</string>',
38
+ `<string>${escapeXml(paths.database)}</string>`,
39
+ '<string>--socket</string>',
40
+ `<string>${escapeXml(paths.socket)}</string>`,
41
+ ...(fs.existsSync(zcodeRuntime)
42
+ ? ['<string>--runtime</string>', `<string>${escapeXml(zcodeRuntime)}</string>`]
43
+ : []),
44
+ '<string>--diagnostic-log</string>',
45
+ `<string>${escapeXml(path.join(paths.logs, 'daemon-error.log'))}</string>`,
46
+ ];
47
+ const dshEnvironment = [
48
+ `<key>PATH</key><string>${LAUNCHD_FIXED_PATH}</string>`,
49
+ ...(configRevision === null ? [] : [`<key>EXTERNAL_SUBAGENT_CONFIG_REVISION</key><string>${configRevision}</string>`]),
50
+ ...(dshRuntime ? [`<key>DSH_RUNTIME_PATH</key><string>${escapeXml(dshRuntime)}</string>`] : []),
51
+ ...(dshHome ? [`<key>DSH_HOME</key><string>${escapeXml(dshHome)}</string>`] : []),
52
+ ...(dshProfile ? [`<key>DSH_PROFILE</key><string>${escapeXml(dshProfile)}</string>`] : []),
53
+ ...(dshVersion ? [`<key>DSH_VERSION</key><string>${escapeXml(dshVersion)}</string>`] : []),
54
+ // The persisted Codex runtime/home pair is forwarded so the service-side
55
+ // daemon resolves the same launch contract the interactive one does. The
56
+ // home is only exported when configured; an inherited launchd CODEX_HOME
57
+ // (if any) stays the second-priority source, and neither being present
58
+ // fails Codex admission closed instead of falling back to ~/.codex.
59
+ ...(codexRuntime ? [`<key>CODEX_RUNTIME_PATH</key><string>${escapeXml(codexRuntime)}</string>`] : []),
60
+ ...(codexHome ? [`<key>CODEX_HOME</key><string>${escapeXml(codexHome)}</string>`] : []),
61
+ ].join('');
62
+ return Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LAUNCH_AGENT_LABEL}</string>\n<key>ProgramArguments</key><array>${programArguments.join('')}</array>\n<key>EnvironmentVariables</key><dict>${dshEnvironment}</dict>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>StandardOutPath</key><string>${escapeXml(path.join(paths.logs, 'daemon.log'))}</string>\n<key>StandardErrorPath</key><string>${escapeXml(path.join(paths.logs, 'daemon-error.log'))}</string>\n</dict></plist>\n`);
63
+ }
64
+
65
+ export function installLaunchAgent(paths, options = {}) {
66
+ const plist = options.plist || launchAgentPlist(paths);
67
+ atomicWrite(paths.launchAgent, plist, 0o600);
68
+ return { installed: true, path: paths.launchAgent, label: LAUNCH_AGENT_LABEL };
69
+ }
70
+
71
+ export function launchctl(args) {
72
+ if (process.env.EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL === '1') {
73
+ return { action: args[0], skipped: true, reason: 'launchd neutralized by test seam' };
74
+ }
75
+ const result = spawnSync('/bin/launchctl', args, { encoding: 'utf8' });
76
+ if (args[0] === 'print' && result.status === 113) return { action: 'print', absent: true };
77
+ if (result.error || result.status !== 0) {
78
+ throw new CliError('DAEMON_CONTROL_FAILED', (result.stderr || result.error?.message || 'launchctl failed').trim());
79
+ }
80
+ return { action: args[0], status: result.status, stdout: result.stdout };
81
+ }
82
+
83
+ // launchd answers a bootstrap whose label is already loaded in the target
84
+ // domain with "Bootstrap failed: 5: Input/output error" (reproduced live on
85
+ // this GUI session; the same EIO was logged by the first acceptance run).
86
+ // A repeat init/start must stay idempotent instead of failing the whole run,
87
+ // so the registration lookup below — the same print/absent discriminator the
88
+ // update owner uses — decides between "already loaded" and a real failure.
89
+ export function serviceRegistrationStatus(uid = process.getuid(), options = {}) {
90
+ const control = options.launchctl || launchctl;
91
+ const result = control(['print', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]);
92
+ if (result.skipped) return { query: 'skipped', registered: null, reason: result.reason };
93
+ if (result.absent) return { registered: false };
94
+ const pid = Number(result.stdout?.match(/\bpid = (\d+)/)?.[1]);
95
+ const state = result.stdout?.match(/^\s*state = (\S+)$/m)?.[1] ?? null;
96
+ return { registered: true, state, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
97
+ }
98
+
99
+ export function bootstrapService(paths, uid = process.getuid(), options = {}) {
100
+ const control = options.launchctl || launchctl;
101
+ const alreadyLoaded = (lookup) => ({
102
+ action: 'bootstrap', label: LAUNCH_AGENT_LABEL, already_loaded: true,
103
+ state: lookup.state ?? null, pid: lookup.pid ?? null,
104
+ });
105
+ const existing = serviceRegistrationStatus(uid, { launchctl: control });
106
+ if (existing.registered) return alreadyLoaded(existing);
107
+ try {
108
+ return control(['bootstrap', `gui/${uid}`, paths.launchAgent]);
109
+ } catch (error) {
110
+ // Another loader (a racing init, or launchd itself re-reading the plist)
111
+ // may have won after the check above; confirm before reporting failure.
112
+ const loaded = serviceRegistrationStatus(uid, { launchctl: control });
113
+ if (loaded.registered) return alreadyLoaded(loaded);
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ // A bootout call returns before launchd has necessarily finished tearing the
119
+ // job down, and a bootstrap issued inside that window can be swept away by the
120
+ // still-running removal (observed live: rapid stop→start cycles left the
121
+ // service unregistered roughly one time in three). The stop owner therefore
122
+ // confirms the job is actually gone — bounded, with the same print/absent
123
+ // discriminator the update owner's unload uses — before reporting success.
124
+ export function bootoutService(paths, uid = process.getuid(), options = {}) {
125
+ const control = options.launchctl || launchctl;
126
+ const existing = serviceRegistrationStatus(uid, { launchctl: control });
127
+ if (!existing.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
128
+ let result;
129
+ try {
130
+ result = control(['bootout', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]);
131
+ } catch (error) {
132
+ // A racing removal (another stop, or launchd itself) may have taken the
133
+ // job out between the registration probe and the bootout; confirm before
134
+ // reporting failure, so a repeated stop stays idempotent.
135
+ const settled = serviceRegistrationStatus(uid, { launchctl: control });
136
+ if (!settled.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
137
+ throw error;
138
+ }
139
+ const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
140
+ for (;;) {
141
+ const probe = serviceRegistrationStatus(uid, { launchctl: control });
142
+ if (!probe.registered) return { ...result, label: LAUNCH_AGENT_LABEL, removed: true };
143
+ if (Date.now() >= deadline) throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'launchd service remained registered after bootout');
144
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
145
+ }
146
+ }
@@ -0,0 +1,170 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { atomicWrite, jsonBytes, sha256 } from '../fs-atomic.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+ import { CLI_ENTRY_NAME, DAEMON_BIN_NAME, NATIVE_DIR_NAME } from '../constants.mjs';
7
+ import { reconcileCodexHomes } from './reconcile.mjs';
8
+ import { packageVersion, packageRoot } from './layout.mjs';
9
+ import { loadUpdateState } from './recovery.mjs';
10
+ import { verifyPayload } from './payload.mjs';
11
+
12
+ const SCHEMA_VERSION = 2;
13
+ const lockPath = (paths) => path.join(paths.data, 'install.lock');
14
+
15
+ function withLock(paths, fn) {
16
+ fs.mkdirSync(paths.data, { recursive: true, mode: 0o700 });
17
+ let fd;
18
+ try { fd = fs.openSync(lockPath(paths), 'wx', 0o600); fs.writeSync(fd, JSON.stringify({ pid: process.pid, started_at_ms: Date.now() })); } catch (error) {
19
+ if (error.code === 'EEXIST') {
20
+ let stale = false;
21
+ try {
22
+ const lock = JSON.parse(fs.readFileSync(lockPath(paths), 'utf8'));
23
+ if (!Number.isInteger(lock.pid) || lock.pid <= 0) stale = true;
24
+ else { try { process.kill(lock.pid, 0); } catch (probe) { stale = probe.code === 'ESRCH'; } }
25
+ } catch { stale = true; }
26
+ if (stale) { try { fs.unlinkSync(lockPath(paths)); } catch {} return withLock(paths, fn); }
27
+ throw new CliError('UPDATE_IN_PROGRESS', 'another installation update is in progress');
28
+ }
29
+ throw error;
30
+ }
31
+ try { return fn(); } finally { fs.closeSync(fd); fs.unlinkSync(lockPath(paths)); }
32
+ }
33
+
34
+ function readState(paths) {
35
+ const { state, recovery } = loadUpdateState(paths.state);
36
+ return { state: state ?? { schema_version: SCHEMA_VERSION, candidate: null, active: null, phase: 'idle' }, recovery };
37
+ }
38
+
39
+ // npm replaces the package directory in place, so the bytes the published
40
+ // active identity was verified against disappear with the next install. The
41
+ // retained payload store keeps byte-for-byte copies of the verified release
42
+ // (native artifacts, stable entry, release manifest) under versioned product
43
+ // data, outside the directory npm is about to overwrite: active/rollback keep
44
+ // pointing at readable, verifiable old bytes.
45
+ function retainPayload(paths, candidateRoot, verified) {
46
+ const root = path.join(paths.data, 'payload-store', verified.version);
47
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
48
+ const platformDir = path.join(fs.realpathSync(candidateRoot), 'npm', NATIVE_DIR_NAME, verified.payload.platform);
49
+ for (const file of verified.payload.files) {
50
+ atomicWrite(path.join(root, file.name), fs.readFileSync(path.join(platformDir, file.name)), 0o755);
51
+ }
52
+ atomicWrite(path.join(root, CLI_ENTRY_NAME), fs.readFileSync(verified.stable.entry), 0o755);
53
+ atomicWrite(path.join(root, 'payload.json'), fs.readFileSync(path.join(platformDir, 'payload.json')));
54
+ const retained = {
55
+ root,
56
+ entry: path.join(root, CLI_ENTRY_NAME),
57
+ entry_sha256: verified.stable.digest,
58
+ daemon_entry: path.join(root, DAEMON_BIN_NAME),
59
+ daemon_entry_sha256: verified.daemon.digest,
60
+ };
61
+ for (const [file, digest] of [[retained.daemon_entry, verified.daemon.digest], [retained.entry, verified.stable.digest]]) {
62
+ if (sha256(fs.readFileSync(file)) !== digest) {
63
+ throw new CliError('PAYLOAD_RETENTION_FAILED', `retained copy of ${path.basename(file)} does not match the verified digest`);
64
+ }
65
+ }
66
+ return retained;
67
+ }
68
+
69
+ function verifyStableEntry(candidateRoot) {
70
+ const root = fs.realpathSync(candidateRoot);
71
+ const entry = path.join(root, 'bin', CLI_ENTRY_NAME);
72
+ let stat;
73
+ try { stat = fs.lstatSync(entry); } catch { throw new CliError('PAYLOAD_ENTRY_MISSING', 'candidate stable entry is missing'); }
74
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry must be a regular file');
75
+ if ((stat.mode & 0o111) === 0) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry must be executable');
76
+ const resolved = fs.realpathSync(entry);
77
+ if (resolved !== entry || !resolved.startsWith(`${root}${path.sep}`)) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry escapes candidate root');
78
+ const digest = crypto.createHash('sha256').update(fs.readFileSync(entry)).digest('hex');
79
+ return { entry, digest };
80
+ }
81
+
82
+ // The service runs the native daemon binary, never the npm bin shim: the
83
+ // activation identity is the external-subagentd payload artifact whose digest
84
+ // verifyPayload already checked against the release manifest.
85
+ function verifyDaemonArtifact(candidateRoot, payload) {
86
+ const record = payload.files.find((file) => file.name === DAEMON_BIN_NAME);
87
+ if (!record) throw new CliError('PAYLOAD_DAEMON_MISSING', 'candidate payload does not carry the daemon artifact');
88
+ const root = fs.realpathSync(candidateRoot);
89
+ const entry = path.join(root, 'npm', NATIVE_DIR_NAME, payload.platform, record.name);
90
+ let stat;
91
+ try { stat = fs.lstatSync(entry); } catch { throw new CliError('PAYLOAD_DAEMON_MISSING', 'candidate daemon artifact is missing'); }
92
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate daemon artifact must be a regular file');
93
+ if ((stat.mode & 0o111) === 0) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate daemon artifact must be executable');
94
+ const digest = crypto.createHash('sha256').update(fs.readFileSync(entry)).digest('hex');
95
+ if (digest !== record.sha256) throw new CliError('PAYLOAD_DIGEST_MISMATCH', 'candidate daemon artifact does not match the verified payload digest');
96
+ return { entry, digest };
97
+ }
98
+
99
+ // The pure validation prologue of a locked update, extracted so the
100
+ // coordinator can run the SAME rules (verifyPayload, the stable-entry and
101
+ // daemon-artifact owners) before it takes the working daemon offline. It
102
+ // writes nothing — no install state, no receipt — never contacts the daemon
103
+ // or a provider, and derives everything from the candidate root, never the
104
+ // user HOME, so a rejected candidate cannot disturb a working installation.
105
+ export function preflightUpdate(options = {}) {
106
+ // An explicit update always has a candidate root: the controlled staged
107
+ // payload when the caller passes one, otherwise the installed package the
108
+ // CLI itself runs from. There is no rootless "version-only" activation.
109
+ const candidateRoot = options.candidateRoot || packageRoot();
110
+ const requested = options.version || 'current';
111
+ const version = requested === 'current' ? packageVersion() : requested;
112
+ const available = options.availableVersions || [packageVersion()];
113
+ if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) || !available.includes(version)) {
114
+ throw new CliError('PAYLOAD_VERSION_UNAVAILABLE', `requested payload version is unavailable: ${version}`);
115
+ }
116
+ const payload = verifyPayload({ root: candidateRoot, platform: options.platform });
117
+ if (version !== payload.version) throw new CliError('PAYLOAD_VERSION_MISMATCH', `requested version ${version} differs from candidate ${payload.version}`);
118
+ // Reject a doomed candidate up front: an unusable stable entry or daemon
119
+ // artifact must never publish candidate state or touch a single Codex home.
120
+ const stable = verifyStableEntry(candidateRoot);
121
+ const daemon = verifyDaemonArtifact(candidateRoot, payload);
122
+ return { candidateRoot, version, payload, stable, daemon };
123
+ }
124
+
125
+ export function updateInstallation(paths, options = {}) {
126
+ if (options.dryRun) return { dry_run: true, phase: 'candidate', version: options.version || 'current' };
127
+ return withLock(paths, () => {
128
+ const { candidateRoot, payload, stable, daemon } = preflightUpdate(options);
129
+ const verifiedVersion = payload.version;
130
+ const { state: prior, recovery } = readState(paths);
131
+ const state = { schema_version: SCHEMA_VERSION, candidate: { version: verifiedVersion, root: candidateRoot, payload: payload.files }, active: prior.active, phase: 'candidate', updated_at_ms: Date.now() };
132
+ atomicWrite(paths.state, jsonBytes(state));
133
+ const publishedCandidate = state.candidate;
134
+ try {
135
+ const sync = options.deferCodexSync ? { homes: [], all_updated: true, deferred: true } : reconcileCodexHomes(paths, options);
136
+ const ok = sync.homes.length === 0 || sync.all_updated;
137
+ state.phase = ok ? 'active' : (sync.homes.some((h) => h.status === 'failed') ? 'failed' : 'partial');
138
+ if (ok) {
139
+ // Re-verify immediately before publishing: active may only ever point
140
+ // at the entry bytes verified above, never a candidate mutated since.
141
+ const stableNow = verifyStableEntry(candidateRoot);
142
+ const daemonNow = verifyDaemonArtifact(candidateRoot, payload);
143
+ if (stableNow.entry !== stable.entry || stableNow.digest !== stable.digest
144
+ || daemonNow.entry !== daemon.entry || daemonNow.digest !== daemon.digest) {
145
+ throw new CliError('PAYLOAD_ENTRY_CHANGED', 'candidate verified artifacts changed during activation');
146
+ }
147
+ state.active = state.candidate; state.candidate = null;
148
+ state.active.entry = stableNow.entry;
149
+ state.active.entry_sha256 = stableNow.digest;
150
+ state.active.daemon_entry = daemonNow.entry;
151
+ state.active.daemon_entry_sha256 = daemonNow.digest;
152
+ state.active.retained = retainPayload(paths, candidateRoot, { version: verifiedVersion, payload, stable: stableNow, daemon: daemonNow });
153
+ }
154
+ atomicWrite(paths.state, jsonBytes(state));
155
+ return recovery ? { ...state, recovery } : state;
156
+ } catch (error) {
157
+ // Record retryable failure evidence instead of a phantom in-flight
158
+ // candidate: the prior active stays published and the rejected
159
+ // candidate remains identified for the next attempt.
160
+ const evidence = { ...state, candidate: state.candidate ?? publishedCandidate, active: prior.active, phase: 'failed', error: { code: error.code || 'UPDATE_FAILED', message: error.message }, failed_at_ms: Date.now() };
161
+ try { atomicWrite(paths.state, jsonBytes(evidence)); } catch { /* evidence is best effort; the thrown error stays authoritative */ }
162
+ throw error;
163
+ }
164
+ });
165
+ }
166
+
167
+ export function reconcileInstallation(paths, options = {}) {
168
+ if (options.cancelActive && !options.yes) throw new CliError('CONFIRMATION_REQUIRED', '--cancel-active requires --yes');
169
+ return withLock(paths, () => readState(paths).state);
170
+ }