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,49 @@
1
+ import { productPaths } from '../paths.mjs';
2
+ import { npmUpdateCoordination } from './reconcile.mjs';
3
+ import { updateCommand } from '../commands/update.mjs';
4
+ import { callDaemon } from '../rpc.mjs';
5
+
6
+ // npm runs this hook after replacing an installed package. A package that
7
+ // has never been initialized must remain stage-only; only an initialized
8
+ // installation with a locally detectable active-version drift enters the
9
+ // existing reconcile owner. No provider probe or daemon RPC is performed by
10
+ // the detection itself.
11
+ const paths = productPaths();
12
+ const globalInstall = process.env.npm_config_global === 'true';
13
+ let detected;
14
+ try {
15
+ detected = npmUpdateCoordination(paths);
16
+ } catch (error) {
17
+ process.stderr.write(`${JSON.stringify({ external_subagent: 'postinstall', coordinated: false, error: { code: error.code || 'UPDATE_STATE_READ_FAILED', message: error.message } })}\n`);
18
+ process.exit(1);
19
+ }
20
+
21
+ if (!globalInstall || !detected.initialized || !detected.update_pending) {
22
+ process.stdout.write(`${JSON.stringify({ external_subagent: 'postinstall', ...detected })}\n`);
23
+ process.exit(0);
24
+ }
25
+
26
+ let interrupted = false;
27
+ let handlingSignal = null;
28
+ const abortOnSignal = (signal) => {
29
+ if (handlingSignal) return;
30
+ interrupted = true;
31
+ handlingSignal = callDaemon(paths.socket, 'drain-abort', {})
32
+ .catch(() => null)
33
+ .finally(() => { process.exitCode = signal === 'SIGINT' ? 130 : 143; });
34
+ };
35
+ process.once('SIGINT', () => abortOnSignal('SIGINT'));
36
+ process.once('SIGTERM', () => abortOnSignal('SIGTERM'));
37
+
38
+ try {
39
+ const result = await updateCommand(paths, ['reconcile'], { drainTimeoutMs: 30_000 });
40
+ if (interrupted) {
41
+ if (handlingSignal) await handlingSignal;
42
+ process.exit(process.exitCode || 130);
43
+ }
44
+ process.stdout.write(`${JSON.stringify({ external_subagent: 'postinstall', coordinated: true, result })}\n`);
45
+ } catch (error) {
46
+ if (handlingSignal) await handlingSignal;
47
+ process.stderr.write(`${JSON.stringify({ external_subagent: 'postinstall', coordinated: false, error: { code: error.code || 'UPDATE_FAILED', message: error.message } })}\n`);
48
+ process.exitCode = 1;
49
+ }
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { nativeBinary } from './layout.mjs';
4
+
5
+ // The launchd/GUI environment does not inherit the interactive shell PATH.
6
+ // Every managed entry point therefore resolves to an absolute file inside the
7
+ // installed package, and the fixed PATH below is only what the daemon itself
8
+ // may use to locate system tools. This module never writes user profiles;
9
+ // PATH findings are reported so an explicit repair stays a user decision.
10
+ export const LAUNCHD_FIXED_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
11
+
12
+ export function which(name, env = process.env) {
13
+ const searchPath = (env.PATH || '').split(path.delimiter).filter(Boolean);
14
+ for (const dir of searchPath) {
15
+ const candidate = path.join(dir, name);
16
+ try {
17
+ const stat = fs.statSync(candidate);
18
+ if (stat.isFile() && (stat.mode & 0o111) !== 0) return candidate;
19
+ } catch { /* keep scanning */ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function pathReport(options = {}) {
25
+ const env = options.env || process.env;
26
+ const daemon = nativeBinary('external-subagentd');
27
+ const facade = nativeBinary('external-subagent-mcp');
28
+ return {
29
+ shell: {
30
+ path: env.PATH || null,
31
+ cli_on_path: which('external-subagent', env),
32
+ mcp_on_path: which('external-subagent-mcp', env),
33
+ },
34
+ launchd: {
35
+ path: LAUNCHD_FIXED_PATH,
36
+ daemon_entry: daemon,
37
+ mcp_entry: facade,
38
+ },
39
+ stable_entries_absolute: Boolean(daemon && facade && path.isAbsolute(daemon) && path.isAbsolute(facade)),
40
+ shell_profile_writes: 'none',
41
+ node_executable: process.execPath,
42
+ };
43
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../errors.mjs';
4
+ import { sha256 } from '../fs-atomic.mjs';
5
+ import { NATIVE_DIR_NAME, PRODUCT_NAME } from '../constants.mjs';
6
+ import {
7
+ NATIVE_PLATFORM, cliVersion, nativePayloadDir, nativePlatform, packageVersion, payloadManifestPath,
8
+ } from './layout.mjs';
9
+
10
+ const MH_MAGIC_64 = 0xfeedfacf;
11
+ const CPU_TYPE_ARM64 = 0x0100000c;
12
+
13
+ export function machoArch(bytes) {
14
+ if (bytes.length < 8) return null;
15
+ if (bytes.readUInt32LE(0) !== MH_MAGIC_64) return null;
16
+ return bytes.readUInt32LE(4) === CPU_TYPE_ARM64 ? 'arm64' : null;
17
+ }
18
+
19
+ export function readPayloadManifest(options = {}) {
20
+ const platform = options.platform || nativePlatform();
21
+ const root = options.root || undefined;
22
+ const file = root ? path.join(root, 'npm', NATIVE_DIR_NAME, platform || '', 'payload.json') : payloadManifestPath(platform);
23
+ if (file === null || !fs.existsSync(file)) {
24
+ throw new CliError('PAYLOAD_MANIFEST_MISSING', `the ${platform || 'current'} platform payload manifest is missing from this package`);
25
+ }
26
+ let manifest;
27
+ try {
28
+ manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ } catch (error) {
30
+ throw new CliError('PAYLOAD_MANIFEST_INVALID', `payload manifest is not valid JSON: ${error.message}`);
31
+ }
32
+ if (manifest?.schema_version !== 1 || manifest?.product !== PRODUCT_NAME || !Array.isArray(manifest.files)) {
33
+ throw new CliError('PAYLOAD_MANIFEST_INVALID', 'payload manifest identity or file table is invalid');
34
+ }
35
+ return manifest;
36
+ }
37
+
38
+ // Verify the staged native payload: the manifest, package, and CLI versions
39
+ // must agree, and every binary must exist with release permissions and a
40
+ // little-endian Mach-O arm64 image. A failed verification is an installation
41
+ // defect, never a silent downgrade path.
42
+ export function verifyPayload(options = {}) {
43
+ const platform = options.platform || nativePlatform();
44
+ if (platform === null) {
45
+ throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${process.platform}-${process.arch}; supported platforms: ${NATIVE_PLATFORM}`);
46
+ }
47
+ const manifest = readPayloadManifest({ platform, root: options.root });
48
+ if (manifest.platform !== platform) {
49
+ throw new CliError('PAYLOAD_PLATFORM_MISMATCH', `payload manifest declares ${manifest.platform}, expected ${platform}`);
50
+ }
51
+ let candidatePackage = packageVersion();
52
+ if (options.root) {
53
+ try { candidatePackage = JSON.parse(fs.readFileSync(path.join(options.root, 'package.json'), 'utf8')).version; }
54
+ catch (error) { throw new CliError('PAYLOAD_PACKAGE_INVALID', `candidate package manifest is unavailable: ${error.message}`); }
55
+ }
56
+ const versions = { package: candidatePackage, cli: options.root ? candidatePackage : cliVersion(), payload: manifest.version };
57
+ if (versions.package !== versions.cli || versions.payload !== versions.cli) {
58
+ throw new CliError('PAYLOAD_VERSION_MISMATCH', `payload ${versions.payload}, package ${versions.package}, and CLI ${versions.cli} versions disagree`);
59
+ }
60
+ const dir = options.root ? path.join(options.root, 'npm', NATIVE_DIR_NAME, platform) : nativePayloadDir(platform);
61
+ const files = manifest.files.map((record) => {
62
+ if (!record || typeof record.name !== 'string' || path.basename(record.name) !== record.name || record.name.includes('..')) {
63
+ throw new CliError('PAYLOAD_MANIFEST_INVALID', 'payload file name must be a direct child');
64
+ }
65
+ const target = path.join(dir, record.name);
66
+ let stat;
67
+ try { stat = fs.statSync(target); } catch (error) {
68
+ throw new CliError('PAYLOAD_FILE_MISSING', `native payload file is missing: ${record.name} (${error.code})`);
69
+ }
70
+ if (!stat.isFile() || fs.lstatSync(target).isSymbolicLink() || (stat.mode & 0o777) !== 0o755) {
71
+ throw new CliError('PAYLOAD_PERMISSIONS_INVALID', `native payload file ${record.name} must be a regular file with mode 755`);
72
+ }
73
+ const bytes = fs.readFileSync(target);
74
+ if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) {
75
+ throw new CliError('PAYLOAD_DIGEST_MISMATCH', `native payload file ${record.name} does not match the release digest`);
76
+ }
77
+ const arch = machoArch(bytes);
78
+ if (arch !== 'arm64') {
79
+ throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a Mach-O arm64 image`);
80
+ }
81
+ return { name: record.name, bytes: bytes.length, mode: '755', sha256: record.sha256, arch };
82
+ });
83
+ return { status: 'verified', platform, version: manifest.version, files };
84
+ }
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { CliError } from '../errors.mjs';
5
+ import { MCP_BIN_NAME } from '../constants.mjs';
6
+ import { nativeBinary, PLUGIN_NAME } from './layout.mjs';
7
+
8
+ // Host-agnostic plugin staging: validating the shipped plugin source,
9
+ // materializing it into a product-owned staging tree, and fingerprinting
10
+ // that tree. Every host binding (codex, zcode) stages the same source and
11
+ // pins the same MCP binding — the facade command plus the daemon socket —
12
+ // into the staged `.mcp.json`; only the registration surface around the
13
+ // staged tree differs per host.
14
+ //
15
+ // Publishing is a REPLACEMENT, never an overlay (AUD-002): the candidate
16
+ // tree is built, parsed, and rewritten with its final binding paths in an
17
+ // owned sibling directory BESIDE the target — never inside the live managed
18
+ // tree — so a file deleted from the source disappears from the published
19
+ // tree and an invalid candidate never moves a managed byte. The publish
20
+ // itself is two same-directory renames (target -> retained prior tree,
21
+ // candidate -> target); the prior tree survives until the caller commits,
22
+ // and restore() puts it back when the host binding transaction fails. The
23
+ // boundary is honest, not journaling: a process crash between the two
24
+ // publish renames can leave the target absent with the prior tree at its
25
+ // sibling path, and nothing here detects or repairs that automatically.
26
+
27
+ export function pluginManifest(source) {
28
+ const manifest = JSON.parse(fs.readFileSync(path.join(source, '.codex-plugin', 'plugin.json'), 'utf8'));
29
+ if (manifest.name !== PLUGIN_NAME || manifest.skills !== './skills/' || manifest.mcpServers !== './.mcp.json') {
30
+ throw new CliError('INVALID_PLUGIN_SOURCE', 'plugin manifest identity or relative paths are invalid');
31
+ }
32
+ return manifest;
33
+ }
34
+
35
+ // Digest of the staged plugin tree. Valid symlinks are followed: the linked
36
+ // content is hashed under the link's own relative path, so an edit of a
37
+ // target that lives OUTSIDE the traversed root and is reachable only through
38
+ // the link still changes the digest. Dangling links are skipped (there is
39
+ // no content to hash) and directory loops/diamonds are bounded by resolved
40
+ // real path plus a depth backstop, so the walk always terminates.
41
+ export function treeDigest(root) {
42
+ const hash = crypto.createHash('sha256');
43
+ const seen = new Set([fs.realpathSync(root)]);
44
+ const walk = (dir, depth) => {
45
+ if (depth > 64) return;
46
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
47
+ const target = path.join(dir, entry.name);
48
+ const isLink = entry.isSymbolicLink();
49
+ let resolvedStat = entry;
50
+ if (isLink) {
51
+ try { resolvedStat = fs.statSync(target); } catch { continue; }
52
+ }
53
+ if (resolvedStat.isDirectory()) {
54
+ const real = fs.realpathSync(target);
55
+ if (seen.has(real)) continue;
56
+ seen.add(real);
57
+ walk(target, depth + 1);
58
+ } else if (resolvedStat.isFile() && entry.name !== '.mcp.json') {
59
+ hash.update(path.relative(root, target));
60
+ hash.update(fs.readFileSync(target));
61
+ }
62
+ }
63
+ };
64
+ walk(root, 0); return hash.digest('hex');
65
+ }
66
+
67
+ // `binding` overrides what gets pinned as the staged MCP command. Codex
68
+ // hosts spawn the native facade directly (the default); hosts with
69
+ // restricted spawn environments (zcode) pass an interpreter command plus
70
+ // args — e.g. the installing node running the staged stdio bridge. A
71
+ // binding may also carry `timeoutMs`, pinned onto the server entry for
72
+ // hosts whose MCP client caps tool calls at a short default (zcode:
73
+ // 30000ms) that a long `external_subagent_wait` would exceed.
74
+ //
75
+ // The publish protocol keeps two private sibling trees beside the target,
76
+ // named `.<target>.candidate.<pid>.<uuid>` and `.<target>.prior.<pid>.<uuid>`.
77
+
78
+ const siblingSuffix = () => `${process.pid}.${crypto.randomUUID()}`;
79
+
80
+ function discardTree(dir) {
81
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
82
+ }
83
+
84
+ // Parse and rewrite the CANDIDATE tree's `.mcp.json` for the final binding
85
+ // paths. This runs before any managed byte moves, so an unreadable or
86
+ // incomplete MCP payload fails the refresh while the live tree stays intact.
87
+ function bindCandidate(candidate, paths, command, args, timeoutMs) {
88
+ const mcpPath = path.join(candidate, '.mcp.json');
89
+ let mcp;
90
+ try {
91
+ mcp = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
92
+ } catch (error) {
93
+ throw new CliError('INVALID_PLUGIN_SOURCE', `plugin MCP config is not readable JSON (${error.message})`);
94
+ }
95
+ const server = mcp.mcpServers?.external_subagent;
96
+ if (!server) throw new CliError('INVALID_PLUGIN_SOURCE', 'plugin MCP server is missing');
97
+ server.command = command;
98
+ if (args !== null) server.args = args;
99
+ if (timeoutMs !== null) server.timeoutMs = timeoutMs;
100
+ server.env = { ...(server.env || {}), ZCODE_AGENTD_SOCKET: paths.socket };
101
+ fs.writeFileSync(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`, { mode: 0o600 });
102
+ }
103
+
104
+ // Prepare a validated replacement for `staging` (AUD-002). Ownership of an
105
+ // existing managed tree is checked first; publish() then builds the
106
+ // candidate beside the target — same parent directory, so every swap step
107
+ // is a rename — and the returned handle coordinates the publish with the
108
+ // caller's host binding transaction: restore() undoes a published swap,
109
+ // complete() drops the retained prior tree once the transaction committed.
110
+ export function preparePluginStage(source, staging, paths, binding = {}) {
111
+ const command = binding.command || nativeBinary(MCP_BIN_NAME);
112
+ const args = Array.isArray(binding.args) ? binding.args : null;
113
+ const timeoutMs = Number.isFinite(binding.timeoutMs) ? binding.timeoutMs : null;
114
+ if (fs.existsSync(staging)) {
115
+ const existing = path.join(staging, '.codex-plugin', 'plugin.json');
116
+ if (!fs.existsSync(existing) || JSON.parse(fs.readFileSync(existing, 'utf8')).name !== PLUGIN_NAME) {
117
+ throw new CliError('PLUGIN_STAGING_CONFLICT', `staging path is not managed by ${PLUGIN_NAME}`);
118
+ }
119
+ // Managed staging is refreshable: skill/docs may have changed since the
120
+ // last install. Keep the ownership check above, then replace its contents
121
+ // from the current source.
122
+ const priorMcp = JSON.parse(fs.readFileSync(path.join(staging, '.mcp.json'), 'utf8'));
123
+ const priorServer = priorMcp.mcpServers?.external_subagent;
124
+ const priorCommand = priorServer?.command;
125
+ // Refreshable when the prior binding is any form this product wrote:
126
+ // the requested binding, the native facade (earlier managed binding),
127
+ // or the shipped placeholder.
128
+ const managedCommands = new Set([command, nativeBinary(MCP_BIN_NAME), '__EXTERNAL_SUBAGENT_MCP_EXECUTABLE__']);
129
+ const commandMatches = managedCommands.has(priorCommand);
130
+ // An interpreter-form binding also pins its script inside the staged
131
+ // tree itself, so a prior args[0] living in this staging proves the
132
+ // binding is ours even after the pinned interpreter path drifted
133
+ // (e.g. a Homebrew node upgrade replaced the Cellar path).
134
+ const priorArgs = Array.isArray(priorServer?.args) ? priorServer.args : [];
135
+ const argsOwned = typeof priorArgs[0] === 'string'
136
+ && path.resolve(priorArgs[0]).startsWith(`${path.resolve(staging)}${path.sep}`);
137
+ // Args are only load-bearing for interpreter bindings (e.g. node running
138
+ // the staged bridge); a prior native-form binding carried none, and the
139
+ // rewrite below installs the requested form regardless.
140
+ const argsMatch = args === null || priorCommand !== command || JSON.stringify(priorArgs) === JSON.stringify(args);
141
+ if (!priorServer || (!commandMatches && !argsOwned) || !argsMatch || priorServer.env?.ZCODE_AGENTD_SOCKET !== paths.socket) {
142
+ throw new CliError('PLUGIN_STAGING_CONFLICT', 'staging MCP binding differs from the managed product endpoint');
143
+ }
144
+ }
145
+ const parent = path.dirname(staging);
146
+ const candidate = path.join(parent, `.${path.basename(staging)}.candidate.${siblingSuffix()}`);
147
+ const prior = path.join(parent, `.${path.basename(staging)}.prior.${siblingSuffix()}`);
148
+ let published = false;
149
+ return {
150
+ staging,
151
+ // Build and validate the candidate, then swap it in with same-directory
152
+ // renames: target -> prior (when a managed tree exists), candidate ->
153
+ // target. If the second rename fails, the first is undone before the
154
+ // error surfaces, so publish either replaces the target completely or
155
+ // leaves the prior tree live at the target path.
156
+ publish() {
157
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
158
+ try {
159
+ fs.cpSync(source, candidate, { recursive: true });
160
+ bindCandidate(candidate, paths, command, args, timeoutMs);
161
+ if (fs.existsSync(staging)) {
162
+ fs.renameSync(staging, prior);
163
+ try {
164
+ fs.renameSync(candidate, staging);
165
+ } catch (error) {
166
+ try { fs.renameSync(prior, staging); } catch { /* prior stays at its sibling path; see the crash boundary */ }
167
+ throw error;
168
+ }
169
+ } else {
170
+ fs.renameSync(candidate, staging);
171
+ }
172
+ published = true;
173
+ } catch (error) {
174
+ discardTree(candidate);
175
+ throw error;
176
+ }
177
+ },
178
+ // Undo a published swap: drop the freshly published tree and put the
179
+ // retained prior tree back (or remove the target entirely when this was
180
+ // a first install). Best-effort by design — the caller's original
181
+ // failure is the one that must surface, so secondary cleanup errors
182
+ // here are suppressed rather than thrown; the boolean return reports
183
+ // whether the pre-publish state was actually restored, so the caller
184
+ // can attach an unrestored staging tree to its error chain (AUD-002)
185
+ // instead of letting the failed restore pass silently. A restore that
186
+ // cannot put the prior tree back never discards it: the retained
187
+ // sibling stays on disk as the last-good recovery material.
188
+ restore() {
189
+ if (!published) return true;
190
+ const hadPrior = fs.existsSync(prior);
191
+ discardTree(staging);
192
+ if (hadPrior) {
193
+ try { fs.renameSync(prior, staging); } catch { /* prior stays at its sibling path; see the crash boundary */ }
194
+ }
195
+ published = false;
196
+ return hadPrior ? !fs.existsSync(prior) : !fs.existsSync(staging);
197
+ },
198
+ // Drop the retained prior tree once the host transaction committed; a
199
+ // cleanup failure never fails a verified install.
200
+ complete() {
201
+ published = false;
202
+ discardTree(prior);
203
+ },
204
+ };
205
+ }
206
+
207
+ // One-shot form for callers without a host transaction around the swap.
208
+ export function stagePlugin(source, staging, paths, binding = {}) {
209
+ const staged = preparePluginStage(source, staging, paths, binding);
210
+ staged.publish();
211
+ staged.complete();
212
+ }
213
+
214
+ // AUD-002: a failed install must still attempt every independent restore.
215
+ // Each attempt runs guarded — one restore throwing (a host config or
216
+ // marketplace parent that STAYS read-only, say) neither skips the remaining
217
+ // restores nor replaces the caller's original error. Attempts that failed
218
+ // are attached to that error as `restoreFailures: [{ resource, code,
219
+ // message }]`, so the unrestored-resource list rides the thrown error chain
220
+ // where callers and tests can assert it; this is not a log line.
221
+ export function guardedRestores(error, attempts) {
222
+ const restoreFailures = [];
223
+ for (const attempt of attempts) {
224
+ try {
225
+ attempt.restore();
226
+ } catch (restoreError) {
227
+ restoreFailures.push({ resource: attempt.resource, code: restoreError.code || 'RESTORE_FAILED', message: restoreError.message });
228
+ }
229
+ }
230
+ if (restoreFailures.length > 0) error.restoreFailures = restoreFailures;
231
+ return error;
232
+ }
@@ -0,0 +1,209 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../errors.mjs';
4
+ import { atomicWrite, jsonBytes, readOptional } from '../fs-atomic.mjs';
5
+ import { installMcp, installPlugin } from './codex.mjs';
6
+ import { packageVersion } from './layout.mjs';
7
+
8
+ // D08: the single owner of the global installed-Codex-homes registry. The
9
+ // registry records every Codex home this product has successfully claimed via
10
+ // an explicit init or plugin install; reconcile only ever writes homes that
11
+ // are both registered and actually writable, so unrelated CODEX_HOME
12
+ // directories are never touched. A corrupted registry is atomically replaced
13
+ // and reported as a recoverable error instead of aborting the caller.
14
+
15
+ const REGISTRY_SCHEMA_VERSION = 1;
16
+
17
+ export function codexHomesRegistryPath(paths) {
18
+ return path.join(paths.data, 'codex-homes.json');
19
+ }
20
+
21
+ function emptyRegistry() {
22
+ return { schema_version: REGISTRY_SCHEMA_VERSION, product: 'external-subagent', homes: [] };
23
+ }
24
+
25
+ function resolveHome(home) {
26
+ if (typeof home !== 'string' || home.length === 0) throw new CliError('INVALID_ARGUMENT', 'codex home must be a non-empty string', 2);
27
+ const absolute = path.resolve(home);
28
+ try { return fs.realpathSync(absolute); } catch { return absolute; }
29
+ }
30
+
31
+ function normalizeEntry(entry) {
32
+ if (!entry || typeof entry.home !== 'string') throw new CliError('CODEX_HOMES_REGISTRY_INVALID', 'registry entry is missing its home path');
33
+ const hasMode = Object.prototype.hasOwnProperty.call(entry, 'binding_mode');
34
+ if (hasMode && entry.binding_mode !== 'mcp' && entry.binding_mode !== 'plugin') {
35
+ throw new CliError('CODEX_HOMES_REGISTRY_INVALID', 'registry entry has an unsupported binding mode');
36
+ }
37
+ return {
38
+ home: entry.home,
39
+ claimed_at_ms: Number.isInteger(entry.claimed_at_ms) ? entry.claimed_at_ms : null,
40
+ version: typeof entry.version === 'string' ? entry.version : null,
41
+ digest: typeof entry.digest === 'string' ? entry.digest : null,
42
+ // Schema-1 claims were plugin-only; default omitted modes accordingly.
43
+ binding_mode: hasMode ? entry.binding_mode : 'plugin',
44
+ last_sync_ms: Number.isInteger(entry.last_sync_ms) ? entry.last_sync_ms : null,
45
+ last_status: typeof entry.last_status === 'string' ? entry.last_status : null,
46
+ };
47
+ }
48
+
49
+ function parseRegistry(bytes, file) {
50
+ const doc = JSON.parse(bytes.toString('utf8'));
51
+ if (doc?.schema_version !== REGISTRY_SCHEMA_VERSION || doc?.product !== 'external-subagent' || !Array.isArray(doc.homes)) {
52
+ throw new CliError('CODEX_HOMES_REGISTRY_INVALID', 'registry identity or homes table is invalid');
53
+ }
54
+ const registry = emptyRegistry();
55
+ registry.homes = doc.homes.map(normalizeEntry);
56
+ return registry;
57
+ }
58
+
59
+ export function loadCodexHomes(paths) {
60
+ const file = codexHomesRegistryPath(paths);
61
+ const bytes = readOptional(file);
62
+ if (bytes === null) return { registry: emptyRegistry(), recovery: null };
63
+ try {
64
+ return { registry: parseRegistry(bytes, file), recovery: null };
65
+ } catch (error) {
66
+ // Corrupted registry: preserve the bytes for inspection, atomically stage
67
+ // a fresh registry, and report a recoverable error to the caller.
68
+ const backup = `${file}.corrupt-${Date.now()}`;
69
+ try { fs.renameSync(file, backup); } catch (renameError) {
70
+ throw new CliError('CODEX_HOMES_REGISTRY_CORRUPT', `registry is corrupted and could not be preserved: ${renameError.message}`);
71
+ }
72
+ atomicWrite(file, jsonBytes(emptyRegistry()));
73
+ return {
74
+ registry: emptyRegistry(),
75
+ recovery: { status: 'recovered', code: 'CODEX_HOMES_REGISTRY_CORRUPT', backup, error: error.message },
76
+ };
77
+ }
78
+ }
79
+
80
+ function persistRegistry(paths, registry) {
81
+ atomicWrite(codexHomesRegistryPath(paths), jsonBytes(registry));
82
+ }
83
+
84
+ export function registerCodexHome(paths, home, meta = {}) {
85
+ const { registry, recovery } = loadCodexHomes(paths);
86
+ const resolved = resolveHome(home);
87
+ const existing = registry.homes.find((entry) => entry.home === resolved);
88
+ let entry;
89
+ if (existing) {
90
+ entry = existing;
91
+ if (typeof meta.version === 'string') entry.version = meta.version;
92
+ if (typeof meta.digest === 'string') entry.digest = meta.digest;
93
+ if (typeof meta.status === 'string') entry.last_status = meta.status;
94
+ if (meta.binding_mode === 'mcp' || meta.binding_mode === 'plugin') entry.binding_mode = meta.binding_mode;
95
+ } else {
96
+ entry = {
97
+ home: resolved,
98
+ claimed_at_ms: Date.now(),
99
+ version: typeof meta.version === 'string' ? meta.version : null,
100
+ digest: typeof meta.digest === 'string' ? meta.digest : null,
101
+ binding_mode: meta.binding_mode === 'mcp' ? 'mcp' : 'plugin',
102
+ last_sync_ms: null,
103
+ last_status: typeof meta.status === 'string' ? meta.status : 'claimed',
104
+ };
105
+ registry.homes.push(entry);
106
+ }
107
+ persistRegistry(paths, registry);
108
+ return { registered: true, home: resolved, deduplicated: Boolean(existing), homes: registry.homes.length, recovery };
109
+ }
110
+
111
+ export function unregisterCodexHome(paths, home, bindingMode = null) {
112
+ const { registry } = loadCodexHomes(paths);
113
+ const resolved = resolveHome(home);
114
+ const remaining = registry.homes.filter((entry) => entry.home !== resolved
115
+ || (bindingMode !== null && entry.binding_mode !== bindingMode));
116
+ const unregistered = remaining.length !== registry.homes.length;
117
+ registry.homes = remaining;
118
+ if (unregistered) persistRegistry(paths, registry);
119
+ return { unregistered, home: resolved, homes: registry.homes.length };
120
+ }
121
+
122
+ // Product uninstall: release every claim at once. The registry file itself
123
+ // stays inside retained product data, but no home remains claimed. A
124
+ // never-initialized product is not given a registry file by uninstalling.
125
+ export function unregisterAllCodexHomes(paths) {
126
+ const { registry } = loadCodexHomes(paths);
127
+ const released = registry.homes.map((entry) => entry.home);
128
+ const file = codexHomesRegistryPath(paths);
129
+ if (released.length > 0 || fs.existsSync(file)) {
130
+ registry.homes = [];
131
+ persistRegistry(paths, registry);
132
+ }
133
+ return { unregistered: released.length, homes: released };
134
+ }
135
+
136
+ function writableDirectory(home) {
137
+ try {
138
+ const stat = fs.statSync(home);
139
+ if (!stat.isDirectory()) return false;
140
+ fs.accessSync(home, fs.constants.W_OK);
141
+ return true;
142
+ } catch { return false; }
143
+ }
144
+
145
+ // The auto-coordination entry for npm-driven updates (U06): npm replaces the
146
+ // package directory in place, so an ALREADY-INITIALIZED product detects here
147
+ // that the installed package and its published active payload have drifted.
148
+ // Detection is strictly local: no daemon RPC, no codex invocation, no provider
149
+ // probe, and not one byte of product state is written, so an ordinary
150
+ // read-only status can never fire a paid probe through this path. A product
151
+ // that was never initialized stays stage-only — first installs never activate —
152
+ // and a corrupted state coordinates through the updater, which preserves the
153
+ // corrupted bytes as evidence instead of treating them as an empty state.
154
+ export function npmUpdateCoordination(paths) {
155
+ const installed = packageVersion();
156
+ const file = readOptional(paths.state);
157
+ let state = null;
158
+ let readable = true;
159
+ if (file !== null) {
160
+ try { state = JSON.parse(file); } catch { state = null; readable = false; }
161
+ }
162
+ const active = state && typeof state === 'object' && state.active && typeof state.active === 'object' ? state.active : null;
163
+ if (!active) {
164
+ return file === null
165
+ ? { initialized: false, stage_only: true, package_version: installed, active_version: null, update_pending: false, coordination: 'none' }
166
+ : { initialized: false, state_readable: readable, stage_only: false, package_version: installed, active_version: null, update_pending: true, coordination: 'update' };
167
+ }
168
+ const update_pending = installed !== active.version;
169
+ return {
170
+ initialized: true,
171
+ stage_only: false,
172
+ package_version: installed,
173
+ active_version: active.version,
174
+ update_pending,
175
+ coordination: update_pending ? 'update' : 'reaffirm',
176
+ };
177
+ }
178
+
179
+ // Refresh the managed binding in every registered, writable Codex home.
180
+ // Results are reported per home; a skipped or failed home never collapses
181
+ // into a single success for the whole reconcile (P13).
182
+ export function reconcileCodexHomes(paths, options = {}) {
183
+ const { registry } = loadCodexHomes(paths);
184
+ const results = [];
185
+ for (const entry of registry.homes) {
186
+ if (!writableDirectory(entry.home)) {
187
+ entry.last_status = 'skipped_not_writable';
188
+ results.push({ home: entry.home, status: 'skipped_not_writable' });
189
+ continue;
190
+ }
191
+ try {
192
+ const installer = options.installer || (entry.binding_mode === 'mcp' ? installMcp : installPlugin);
193
+ if (typeof installer !== 'function') throw new CliError('CODEX_BINDING_FAILED', `no installer is available for ${entry.binding_mode} binding`);
194
+ const install = installer(paths, { ...options, codexHome: entry.home });
195
+ entry.last_status = 'updated';
196
+ entry.last_sync_ms = Date.now();
197
+ if (install.digest) entry.digest = install.digest;
198
+ results.push({ home: entry.home, status: 'updated', cache: install.cache || null });
199
+ } catch (error) {
200
+ entry.last_status = 'failed';
201
+ results.push({ home: entry.home, status: 'failed', error: { code: error.code || 'CODEX_BINDING_FAILED', message: error.message } });
202
+ }
203
+ }
204
+ persistRegistry(paths, registry);
205
+ return {
206
+ homes: results,
207
+ all_updated: results.length > 0 && results.every((result) => result.status === 'updated'),
208
+ };
209
+ }