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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/external-subagent-debug-mcp.mjs +5 -0
- package/bin/external-subagent-debug.mjs +13 -0
- package/bin/external-subagent-mcp.mjs +20 -0
- package/bin/external-subagent.mjs +8 -0
- package/bin/zas.mjs +8 -0
- package/cli/commands/agents.mjs +117 -0
- package/cli/commands/config.mjs +117 -0
- package/cli/commands/daemon.mjs +36 -0
- package/cli/commands/maintenance.mjs +27 -0
- package/cli/commands/plugin.mjs +96 -0
- package/cli/commands/tasks.mjs +64 -0
- package/cli/commands/update.mjs +219 -0
- package/cli/config/read.mjs +24 -0
- package/cli/config/schema.mjs +75 -0
- package/cli/config/write.mjs +39 -0
- package/cli/constants.mjs +24 -0
- package/cli/errors.mjs +7 -0
- package/cli/fs-atomic.mjs +36 -0
- package/cli/install/codex.mjs +352 -0
- package/cli/install/init.mjs +206 -0
- package/cli/install/layout.mjs +54 -0
- package/cli/install/npm-hook.mjs +49 -0
- package/cli/install/path.mjs +43 -0
- package/cli/install/payload.mjs +84 -0
- package/cli/install/plugin-stage.mjs +232 -0
- package/cli/install/reconcile.mjs +209 -0
- package/cli/install/recovery.mjs +79 -0
- package/cli/install/service-activation.mjs +133 -0
- package/cli/install/service-macos.mjs +146 -0
- package/cli/install/update.mjs +170 -0
- package/cli/install/zcode.mjs +239 -0
- package/cli/main.mjs +429 -0
- package/cli/maintenance.mjs +104 -0
- package/cli/paths.mjs +40 -0
- package/cli/rpc.mjs +261 -0
- package/launchd/com.external-subagent.daemon.plist.template +37 -0
- package/npm/native/README.md +19 -0
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +20 -0
- package/package.json +60 -0
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
- package/plugins/codex/external-subagent/.mcp.json +9 -0
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
- package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
- package/schema/external-subagent-public-api.json +185 -0
- package/schema/zas-observation-v1.1.schema.json +58 -0
- package/schema/zas-public-reasoning-source.json +14 -0
- package/scripts/release/build-native-payload.mjs +119 -0
- package/scripts/release/check-native-tarball.mjs +125 -0
- package/scripts/release/stage-debug-plugin.mjs +60 -0
- 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
|
+
}
|