external-subagent 0.4.1 → 0.5.1
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/README.md +37 -19
- package/bin/external-subagent-debug-mcp.mjs +2 -1
- package/bin/external-subagent-mcp.mjs +36 -11
- package/cli/commands/config.mjs +6 -1
- package/cli/commands/daemon.mjs +12 -2
- package/cli/config/write.mjs +21 -3
- package/cli/constants.mjs +35 -1
- package/cli/install/init.mjs +30 -11
- package/cli/install/layout.mjs +11 -4
- package/cli/install/path.mjs +35 -10
- package/cli/install/payload.mjs +86 -6
- package/cli/install/service-activation.mjs +133 -1
- package/cli/install/service-linux.mjs +327 -0
- package/cli/install/service-macos.mjs +59 -18
- package/cli/install/update.mjs +7 -1
- package/cli/main.mjs +25 -11
- package/cli/maintenance.mjs +16 -11
- package/cli/paths.mjs +57 -8
- 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 +4 -4
- package/npm/native/linux-x64/external-subagent-mcp +0 -0
- package/npm/native/linux-x64/external-subagentd +0 -0
- package/npm/native/linux-x64/payload.json +20 -0
- package/package.json +6 -3
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +1 -1
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +16 -2
- package/scripts/release/build-native-payload.mjs +18 -6
- package/scripts/release/check-native-tarball.mjs +99 -31
- package/scripts/release/test-installed-tarball.mjs +6 -1
package/README.md
CHANGED
|
@@ -16,15 +16,23 @@ independent axes:
|
|
|
16
16
|
## Status
|
|
17
17
|
|
|
18
18
|
The package is published to the public npm registry as
|
|
19
|
-
`external-subagent@0.1
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
`external-subagent@0.5.1` for macOS arm64 and Linux x86_64. `os`/`cpu` in
|
|
20
|
+
`package.json` are independent npm arrays, so npm admits any darwin/linux +
|
|
21
|
+
arm64/x64 tuple at install time; the CLI then enforces the real support at
|
|
22
|
+
first use — a host outside macOS/Linux is rejected with `UNSUPPORTED_PLATFORM`
|
|
23
|
+
before any HOME write, and `darwin-x64`/`linux-arm64` (no native payload) are
|
|
24
|
+
rejected at `init` with `UNSUPPORTED_PAYLOAD_PLATFORM`.
|
|
25
|
+
`init` installs the standalone daemon service only — no host is bound
|
|
26
|
+
implicitly; hosts are bound through the explicit `install-plugin` (Codex or
|
|
27
|
+
ZCode) / `install-mcp` (Codex-only TOML binding) commands. Installing from a
|
|
28
|
+
packed artifact and explicitly initializing to a service is live-verified on
|
|
29
|
+
macOS arm64 (launchd, real GUI session) and on Linux x86_64 (systemd user
|
|
30
|
+
service, Ubuntu; idempotent repeat init/start); active-task upgrade and
|
|
31
|
+
standalone initialization are implemented and reviewed. On Linux x86_64 the
|
|
32
|
+
`codex` and `zcode` subagents are live-verified (OBSERVED), while `dsh` and
|
|
33
|
+
`agy` remain runtime-gated there: their runtimes are not installed on the
|
|
34
|
+
verification host, so they stay disabled by default with an explicit status,
|
|
35
|
+
exactly as on macOS.
|
|
28
36
|
|
|
29
37
|
All four subagents are **disabled by default** and are enabled per name with
|
|
30
38
|
`external-subagent agents enable <zcode|dsh|codex|agy>`: a successful local probe
|
|
@@ -62,12 +70,12 @@ acceptance matrix is recorded in
|
|
|
62
70
|
|
|
63
71
|
## Versioning
|
|
64
72
|
|
|
65
|
-
The product version has a single source: `package.json` (`0.1
|
|
73
|
+
The product version has a single source: `package.json` (`0.5.1`). The CLI
|
|
66
74
|
constant (`cli/constants.mjs`), the daemon crates, and the native payload
|
|
67
75
|
manifest all carry the same version, and `scripts/release/check-native-tarball.mjs`
|
|
68
76
|
enforces payload/package agreement. The managed Codex plugin manifest
|
|
69
77
|
(`plugins/codex/external-subagent/.codex-plugin/plugin.json`, currently
|
|
70
|
-
`0.1.
|
|
78
|
+
`0.1.5`) is deliberately **not** tied to the product version: it is codex's
|
|
71
79
|
plugin-cache identity (`plugin@marketplace@version`; under the reserved
|
|
72
80
|
`personal` marketplace name codex resolves the real user root
|
|
73
81
|
machine-globally, ignoring `CODEX_HOME`), which
|
|
@@ -80,7 +88,7 @@ intentionally diverge; see
|
|
|
80
88
|
## Install
|
|
81
89
|
|
|
82
90
|
```
|
|
83
|
-
npm install -g external-subagent # stages package + payload only (macOS arm64)
|
|
91
|
+
npm install -g external-subagent # stages package + payload only (macOS arm64, Linux x64)
|
|
84
92
|
external-subagent init # explicit: standalone daemon service only
|
|
85
93
|
external-subagent install-plugin zcode # optional host binding (or: install-plugin codex / install-mcp)
|
|
86
94
|
```
|
|
@@ -140,9 +148,13 @@ carries a `hint` field with this format summary.
|
|
|
140
148
|
## Publishing
|
|
141
149
|
|
|
142
150
|
Releases publish from CI via npm Trusted Publishing (OIDC): pushing a `vX.Y.Z`
|
|
143
|
-
tag runs [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml)
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
tag runs [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml).
|
|
152
|
+
One tarball carries both platform payloads, so the workflow builds them on two
|
|
153
|
+
runners: the `build-linux` job builds the `linux-x64` payload on Ubuntu and
|
|
154
|
+
uploads it as an artifact, and the `publish` job runs on an Apple Silicon
|
|
155
|
+
runner (where `prepack` rebuilds the `darwin-arm64` payload), merges the Linux
|
|
156
|
+
payload into `npm/native/` before the pack gates and release-path test suites
|
|
157
|
+
run, and publishes with no npm token in the environment — the runner's OIDC
|
|
146
158
|
identity is the credential. npm does not support trusted publishing for a
|
|
147
159
|
package's first release, so the initial version was published manually once
|
|
148
160
|
and the trusted publisher was then linked on npmjs.com; the workflow filename
|
|
@@ -165,10 +177,16 @@ completed update whose registered Codex homes only partially rebind reports
|
|
|
165
177
|
`CODEX_SYNC_PARTIAL` per home and keeps the verified activation; the public
|
|
166
178
|
`stop` confirms launchd removal before returning, and `uninstall` boots out
|
|
167
179
|
the ES-owned service before removing its registration while retaining all
|
|
168
|
-
data. Supported
|
|
169
|
-
|
|
170
|
-
`
|
|
171
|
-
|
|
180
|
+
data. Supported platforms: macOS arm64 and Linux x86_64. npm's independent
|
|
181
|
+
`os`/`cpu` arrays only bound the tuple to darwin/linux + arm64/x64 (so
|
|
182
|
+
`darwin-x64` and `linux-arm64` install as well); the CLI keeps `help`/`version`
|
|
183
|
+
working everywhere, rejects a host outside macOS/Linux with
|
|
184
|
+
`UNSUPPORTED_PLATFORM`, and rejects `darwin-x64`/`linux-arm64` at `init` with
|
|
185
|
+
`UNSUPPORTED_PAYLOAD_PLATFORM`, never writing HOME on a rejected host. macOS arm64 is live-verified
|
|
186
|
+
(launchd). Linux x86_64 (Ubuntu, systemd user service) is verified for the
|
|
187
|
+
install/init/service/CLI path and the codex and zcode subagents; `dsh` and
|
|
188
|
+
`agy` remain runtime-gated there (their runtimes are not present on the
|
|
189
|
+
verification host).
|
|
172
190
|
|
|
173
191
|
See [docs/operations.md](docs/operations.md) for service control, PATH
|
|
174
192
|
behavior, the Codex homes registry, backup/removal, and release checks; and
|
|
@@ -2,4 +2,5 @@
|
|
|
2
2
|
// Debug-variant MCP facade entry: selects the debug payload before
|
|
3
3
|
// delegating to the shared spawn shim.
|
|
4
4
|
process.env.EXTERNAL_SUBAGENT_VARIANT = 'debug';
|
|
5
|
-
await import('./external-subagent-mcp.mjs');
|
|
5
|
+
const { runMcpFacade } = await import('./external-subagent-mcp.mjs');
|
|
6
|
+
runMcpFacade();
|
|
@@ -1,20 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { nativePlatform } from '../cli/install/layout.mjs';
|
|
6
8
|
|
|
7
9
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
|
|
8
11
|
// The debug shims set EXTERNAL_SUBAGENT_VARIANT before re-entering this file,
|
|
9
12
|
// so the facade resolves to the variant payload staged beside the release one.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
process.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
13
|
+
// The platform tuple derives from the running host (falling back to the raw
|
|
14
|
+
// process tuple on an unsupported host so the diagnostic names the path it
|
|
15
|
+
// tried), matching cli/install/layout.mjs — a linux-x64 install must spawn the
|
|
16
|
+
// linux-x64 payload, never a hard-coded macOS directory.
|
|
17
|
+
export function mcpFacadePath(
|
|
18
|
+
platform = nativePlatform() ?? `${process.platform}-${process.arch}`,
|
|
19
|
+
debug = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug',
|
|
20
|
+
) {
|
|
21
|
+
return path.join(packageRoot, 'npm', debug ? 'native-debug' : 'native', platform,
|
|
22
|
+
debug ? 'external-subagent-debug-mcp' : 'external-subagent-mcp');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runMcpFacade() {
|
|
26
|
+
const child = spawn(mcpFacadePath(), process.argv.slice(2), { stdio: 'inherit', env: process.env });
|
|
27
|
+
child.on('error', (error) => {
|
|
28
|
+
console.error(error.message);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
});
|
|
31
|
+
child.on('exit', (code, signal) => {
|
|
32
|
+
process.exitCode = code ?? (signal ? 1 : 0);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Launch only when this shim is the process entry point (directly or through
|
|
37
|
+
// an npm bin symlink). Importing it — the debug shim does so explicitly, and
|
|
38
|
+
// the tests import the pure path resolver — must never spawn a facade.
|
|
39
|
+
function isEntryPoint() {
|
|
40
|
+
if (process.argv[1] === undefined) return false;
|
|
41
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
|
42
|
+
catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (isEntryPoint()) runMcpFacade();
|
package/cli/commands/config.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { CliError } from '../errors.mjs';
|
|
|
5
5
|
import { atomicWrite } from '../fs-atomic.mjs';
|
|
6
6
|
import { hasInstalledService } from '../install/service-activation.mjs';
|
|
7
7
|
import { launchAgentPlist } from '../install/service-macos.mjs';
|
|
8
|
+
import { platform } from '../paths.mjs';
|
|
8
9
|
|
|
9
10
|
const CONFIG_INPUT_FIELDS = new Set(['operation', 'patch', 'key']);
|
|
10
11
|
const GET_KEYS = new Set([
|
|
@@ -94,8 +95,12 @@ export function parseConfigArgs(args) {
|
|
|
94
95
|
// launchd (subagent homes, config revision), so every successful write also
|
|
95
96
|
// regenerates the service definition from the new config. Only the file is
|
|
96
97
|
// rewritten — launchd picks the environment up at its next load, so a running
|
|
97
|
-
// daemon is never restarted or disturbed here.
|
|
98
|
+
// daemon is never restarted or disturbed here. The launchd plist is macOS
|
|
99
|
+
// only; the Linux systemd unit is re-rendered by the S03 service backend, so a
|
|
100
|
+
// Linux config write refreshes nothing yet instead of writing a plist to the
|
|
101
|
+
// unit path.
|
|
98
102
|
function refreshServiceDefinition(paths) {
|
|
103
|
+
if (platform() !== 'darwin') return false;
|
|
99
104
|
if (!hasInstalledService(paths)) return false;
|
|
100
105
|
atomicWrite(paths.launchAgent, launchAgentPlist(paths), 0o600);
|
|
101
106
|
return true;
|
package/cli/commands/daemon.mjs
CHANGED
|
@@ -2,9 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import { bootoutService, bootstrapService } from '../install/service-macos.mjs';
|
|
3
3
|
import { loadCodexHomes } from '../install/reconcile.mjs';
|
|
4
4
|
import { verifyPayload } from '../install/payload.mjs';
|
|
5
|
+
import { platform } from '../paths.mjs';
|
|
5
6
|
|
|
6
7
|
// Daemon/service command surface: explicit start/stop through the managed
|
|
7
|
-
//
|
|
8
|
+
// service definition (launchd bootstrap/bootout on macOS, systemctl --user
|
|
9
|
+
// daemon-reload+enable --now / disable --now on Linux, via the platform
|
|
10
|
+
// dispatch in service-macos.mjs), and the local installation summary consumed
|
|
11
|
+
// by `status`.
|
|
8
12
|
|
|
9
13
|
export function startDaemon(paths) {
|
|
10
14
|
return bootstrapService(paths);
|
|
@@ -23,9 +27,15 @@ export function localInstallStatus(paths, { verbose = false } = {}) {
|
|
|
23
27
|
const publicPayload = verbose
|
|
24
28
|
? payload
|
|
25
29
|
: { status: payload.status, platform: payload.platform, version: payload.version };
|
|
30
|
+
// The service-definition path is the macOS LaunchAgent plist or the Linux
|
|
31
|
+
// systemd user unit (paths.mjs). The reported field stays platform-truthful
|
|
32
|
+
// so a Linux install never reads as a launchd agent.
|
|
33
|
+
const serviceDefinition = platform() === 'darwin'
|
|
34
|
+
? { launch_agent: fs.existsSync(paths.launchAgent) }
|
|
35
|
+
: { service_definition: fs.existsSync(paths.launchAgent) };
|
|
26
36
|
return {
|
|
27
37
|
installed: fs.existsSync(paths.state),
|
|
28
|
-
|
|
38
|
+
...serviceDefinition,
|
|
29
39
|
data: fs.existsSync(paths.data),
|
|
30
40
|
payload: publicPayload,
|
|
31
41
|
// Home paths are installation internals; status reports only the
|
package/cli/config/write.mjs
CHANGED
|
@@ -12,13 +12,31 @@ export function writeConfig(file, value) {
|
|
|
12
12
|
return config;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// The read-modify-write config transaction holds an exclusive lock on
|
|
16
|
+
// `<config>.lock` for its whole duration. The lock is owned by a helper
|
|
17
|
+
// process so a crashed writer releases it automatically: the helper acquires
|
|
18
|
+
// the lock, creates a `ready` acknowledgement, then holds the lock until this
|
|
19
|
+
// process kills the helper group. macOS uses lockf and Linux uses flock — both
|
|
20
|
+
// take an exclusive advisory lock on a file, run a command while holding it,
|
|
21
|
+
// and give up after a bounded wait, so the timeout/mutual-exclusion/failure
|
|
22
|
+
// semantics are equivalent. The binary is chosen by the host OS (a real
|
|
23
|
+
// capability), not by the EXTERNAL_SUBAGENT_TEST_PLATFORM layout seam.
|
|
24
|
+
const LOCK_TIMEOUT_SECONDS = 2;
|
|
25
|
+
const LOCK_READY_SCRIPT = 'ready="$1"; parent="$2"; printf ready > "$ready"; while kill -0 "$parent" 2>/dev/null; do sleep 0.05; done';
|
|
26
|
+
|
|
27
|
+
export function lockHelperInvocation(lock, ready, platform = process.platform) {
|
|
28
|
+
const tail = ['/bin/sh', '-c', LOCK_READY_SCRIPT, 'external-subagent-lock', ready, String(process.pid)];
|
|
29
|
+
return platform === 'darwin'
|
|
30
|
+
? { command: '/usr/bin/lockf', args: ['-t', String(LOCK_TIMEOUT_SECONDS), lock, ...tail] }
|
|
31
|
+
: { command: '/usr/bin/flock', args: ['-w', String(LOCK_TIMEOUT_SECONDS), lock, ...tail] };
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
export function updateConfig(file, updater) {
|
|
16
35
|
const lock = `${path.resolve(file)}.lock`;
|
|
17
36
|
fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
|
|
18
37
|
const ready = `${lock}.${process.pid}.${Date.now()}.ready`;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
'external-subagent-lock', ready, String(process.pid)], { stdio: 'ignore', detached: true });
|
|
38
|
+
const { command, args } = lockHelperInvocation(lock, ready);
|
|
39
|
+
const helper = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
22
40
|
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
23
41
|
const deadline = Date.now() + 2500;
|
|
24
42
|
while (!fs.existsSync(ready)) {
|
package/cli/constants.mjs
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
// debug build beside the released product without touching it. The token is
|
|
5
5
|
// read once at module load; the debug bin shims set it before importing the
|
|
6
6
|
// CLI, so every derived name below is stable for the process lifetime.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
7
11
|
const IS_DEBUG_VARIANT = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
|
|
8
12
|
|
|
9
13
|
export const PRODUCT_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug' : 'external-subagent';
|
|
10
14
|
export const PRODUCT_ID = IS_DEBUG_VARIANT ? 'external_subagent_debug' : 'external_subagent';
|
|
11
|
-
export const VERSION = '0.
|
|
15
|
+
export const VERSION = '0.5.1';
|
|
12
16
|
export const LAUNCH_AGENT_LABEL = IS_DEBUG_VARIANT ? 'com.external-subagent-debug.daemon' : 'com.external-subagent.daemon';
|
|
13
17
|
export const DAEMON_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debugd' : 'external-subagentd';
|
|
14
18
|
export const MCP_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug-mcp' : 'external-subagent-mcp';
|
|
@@ -16,6 +20,36 @@ export const NATIVE_DIR_NAME = IS_DEBUG_VARIANT ? 'native-debug' : 'native';
|
|
|
16
20
|
export const CLI_ENTRY_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug.mjs' : 'external-subagent.mjs';
|
|
17
21
|
export const ZCODE_RUNTIME = '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs';
|
|
18
22
|
|
|
23
|
+
// The zcode runtime the CLI pins for the daemon, resolved per installation:
|
|
24
|
+
// explicit configuration first, then the platform's conventional location,
|
|
25
|
+
// and — when neither yields a file — the packaged macOS constant, reported
|
|
26
|
+
// honestly as an absent pinned location (presence is the caller's
|
|
27
|
+
// `fs.existsSync` observation, never an assertion).
|
|
28
|
+
// explicit ZCODE_RUNTIME_PATH, the same variable the daemon itself
|
|
29
|
+
// resolves (external-subagentd --runtime / ZCODE_RUNTIME_PATH).
|
|
30
|
+
// A configured-but-absent path is returned verbatim; forwarding
|
|
31
|
+
// stays presence-gated so the daemon never starts against a
|
|
32
|
+
// --runtime that is not a regular file.
|
|
33
|
+
// darwin the app bundle resource (frozen bytes above).
|
|
34
|
+
// linux the ZCode server runtime the desktop's attached-remote flow
|
|
35
|
+
// deploys under `~/.zcode/server` (`agents/glm/zcode.cjs`) —
|
|
36
|
+
// the same default root the official `zcode-agent` launcher
|
|
37
|
+
// resolves (OBSERVED 2026-10-09; docs/compatibility/zcode.md).
|
|
38
|
+
// The deployment's ZCODE_SERVER_RUNTIME_ROOT variable is
|
|
39
|
+
// deliberately NOT consulted: service generation must stay
|
|
40
|
+
// independent of the interactive shell environment (the same
|
|
41
|
+
// design the fixed unit PATH follows), and the product home
|
|
42
|
+
// already owns `~/.zcode`.
|
|
43
|
+
export function zcodeRuntimePath(home = os.homedir(), env = process.env) {
|
|
44
|
+
const configured = env.ZCODE_RUNTIME_PATH;
|
|
45
|
+
if (typeof configured === 'string' && configured !== '') return configured;
|
|
46
|
+
// The process-platform seam, the same expression cli/paths.mjs owns;
|
|
47
|
+
// duplicated here because paths.mjs imports this module (no import cycle).
|
|
48
|
+
if ((env.EXTERNAL_SUBAGENT_TEST_PLATFORM || process.platform) === 'darwin') return ZCODE_RUNTIME;
|
|
49
|
+
const conventional = path.join(home, '.zcode', 'server', 'agents', 'glm', 'zcode.cjs');
|
|
50
|
+
return fs.existsSync(conventional) ? conventional : ZCODE_RUNTIME;
|
|
51
|
+
}
|
|
52
|
+
|
|
19
53
|
export const BUSINESS_COMMANDS = new Set([
|
|
20
54
|
'init', 'hooks', 'status', 'diagnose', 'backup', 'restore', 'start', 'stop',
|
|
21
55
|
'uninstall', 'purge', 'install-plugin', 'install-mcp', 'create', 'spawn', 'wait', 'list', 'send',
|
package/cli/install/init.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { LAUNCH_AGENT_LABEL } from '../constants.mjs';
|
|
4
|
+
import { LAUNCH_AGENT_LABEL, NATIVE_DIR_NAME } from '../constants.mjs';
|
|
5
5
|
import { CliError } from '../errors.mjs';
|
|
6
|
+
import { platform } from '../paths.mjs';
|
|
6
7
|
import { parseConfig } from '../config/read.mjs';
|
|
7
8
|
import { writeConfig } from '../config/write.mjs';
|
|
8
|
-
import { verifyPayload } from './payload.mjs';
|
|
9
|
+
import { normalizePayloadFiles, verifyPayload } from './payload.mjs';
|
|
9
10
|
import { pathReport } from './path.mjs';
|
|
10
11
|
import { packageRoot, payloadManifestPath } from './layout.mjs';
|
|
11
12
|
import { updateInstallation } from './update.mjs';
|
|
12
13
|
import { loadInstallState, markInstallStep, removeCreatedDirectories, rollbackFiles, snapshotFile } from './recovery.mjs';
|
|
13
|
-
import { bootstrapService, bootoutService,
|
|
14
|
+
import { SYSTEMD_UNIT_NAME, bootstrapService, bootoutService, installServiceDefinition, runtimeObservations } from './service-macos.mjs';
|
|
14
15
|
|
|
15
16
|
// Standalone-install coordination (S05, AUD-005 decision D1). A plain npm
|
|
16
17
|
// install only stages the package and payload; an explicit `init` installs
|
|
@@ -20,8 +21,12 @@ import { bootstrapService, bootoutService, installLaunchAgent, runtimeObservatio
|
|
|
20
21
|
// check-path PATH findings are reported, never written
|
|
21
22
|
// create-data private data/log directories
|
|
22
23
|
// write-product-config republish the current agent config schema
|
|
23
|
-
// install-launch-agent the one
|
|
24
|
-
//
|
|
24
|
+
// install-launch-agent the one service template: the launchd LaunchAgent
|
|
25
|
+
// plist on macOS, the systemd user unit on Linux (the
|
|
26
|
+
// step id predates the Linux backend and stays stable so
|
|
27
|
+
// resume journals keep matching)
|
|
28
|
+
// start-service launchctl bootstrap / systemctl --user daemon-reload +
|
|
29
|
+
// enable --now (best-effort, reported)
|
|
25
30
|
// publish-active-payload the verified active identity and retained bytes
|
|
26
31
|
// (B-3): a successful init establishes the version and
|
|
27
32
|
// retention baseline itself, so the standard `npm A ->
|
|
@@ -49,14 +54,20 @@ function hookInstallerPath() {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export function installPlan(paths, options = {}) {
|
|
52
|
-
|
|
57
|
+
// The plan reports the payload the verifier will read. When the running
|
|
58
|
+
// tuple has a supported payload this is the resolved manifest path; on an
|
|
59
|
+
// unsupported host the fallback derives the same tuple from the process so
|
|
60
|
+
// the reported path matches the layout instead of a hard-coded platform.
|
|
61
|
+
const manifest = payloadManifestPath()
|
|
62
|
+
?? path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, `${process.platform}-${process.arch}`, 'payload.json');
|
|
63
|
+
const darwin = platform() === 'darwin';
|
|
53
64
|
const plan = [
|
|
54
65
|
{ id: 'verify-payload', action: 'verify staged native payload', path: manifest },
|
|
55
66
|
{ id: 'check-path', action: 'report PATH availability without writing profiles' },
|
|
56
67
|
{ id: 'create-data', action: 'create private product data and log directories', paths: [paths.data, paths.logs] },
|
|
57
68
|
{ id: 'write-product-config', action: 'republish the product agent config schema', path: paths.config },
|
|
58
|
-
{ id: 'install-launch-agent', action: 'install daemon LaunchAgent', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
|
|
59
|
-
{ id: 'start-service', action: 'bootstrap the daemon service', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
|
|
69
|
+
{ id: 'install-launch-agent', action: darwin ? 'install daemon LaunchAgent' : 'install daemon systemd user unit', path: paths.launchAgent, ...(darwin ? { label: LAUNCH_AGENT_LABEL } : { unit: SYSTEMD_UNIT_NAME }) },
|
|
70
|
+
{ id: 'start-service', action: darwin ? 'bootstrap the daemon service' : 'enable and start the daemon service', path: paths.launchAgent, ...(darwin ? { label: LAUNCH_AGENT_LABEL } : { unit: SYSTEMD_UNIT_NAME }) },
|
|
60
71
|
{ id: 'publish-active-payload', action: 'publish the verified active payload and retained-byte baseline', path: paths.state },
|
|
61
72
|
];
|
|
62
73
|
if (options.installHooks) plan.splice(plan.findIndex((step) => step.id === 'publish-active-payload'), 0, { id: 'install-hooks', action: 'install ZCode policy hooks', path: paths.zcodeConfig, provenance: paths.hookProvenance });
|
|
@@ -82,6 +93,14 @@ export function runInit(options = {}) {
|
|
|
82
93
|
if (options.dryRun) return { dry_run: true, plan };
|
|
83
94
|
|
|
84
95
|
const skipPayload = Boolean(options.skipPayloadProbe);
|
|
96
|
+
if (!skipPayload) {
|
|
97
|
+
// Repair an extraction mode masked by the installer's umask before the
|
|
98
|
+
// strict verify-payload gate reads the staged files (a 0775 extract from a
|
|
99
|
+
// 0755 archive is a real npm-install artifact, not a payload defect).
|
|
100
|
+
for (const entry of normalizePayloadFiles()) {
|
|
101
|
+
process.stderr.write(`normalized payload mode ${entry.from} -> ${entry.to} (${entry.name})\n`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
85
104
|
const payload = skipPayload ? { status: 'skipped', platform: null, version: null, files: [] } : verifyPayload();
|
|
86
105
|
const pathFindings = pathReport();
|
|
87
106
|
|
|
@@ -139,12 +158,12 @@ export function runInit(options = {}) {
|
|
|
139
158
|
mark('write-product-config');
|
|
140
159
|
}
|
|
141
160
|
if (!completed.has('install-launch-agent')) {
|
|
142
|
-
|
|
161
|
+
installServiceDefinition(paths);
|
|
143
162
|
failAt('install-launch-agent');
|
|
144
163
|
mark('install-launch-agent');
|
|
145
164
|
}
|
|
146
165
|
if (!completed.has('start-service') && !options.skipServiceStart) {
|
|
147
|
-
service = bootstrapService(paths, process.getuid(), { launchctl: options.launchctl });
|
|
166
|
+
service = bootstrapService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl });
|
|
148
167
|
serviceLoadedByThisRun = !service.skipped && !service.already_loaded;
|
|
149
168
|
mark('start-service');
|
|
150
169
|
}
|
|
@@ -183,7 +202,7 @@ export function runInit(options = {}) {
|
|
|
183
202
|
...removeCreatedDirectories(directories),
|
|
184
203
|
];
|
|
185
204
|
if (serviceLoadedByThisRun) {
|
|
186
|
-
try { bootoutService(paths, process.getuid(), { launchctl: options.launchctl }); } catch (bootoutError) {
|
|
205
|
+
try { bootoutService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl }); } catch (bootoutError) {
|
|
187
206
|
rollbackErrors.push(`service rollback (bootout) failed: ${bootoutError.message}`);
|
|
188
207
|
}
|
|
189
208
|
}
|
package/cli/install/layout.mjs
CHANGED
|
@@ -10,7 +10,14 @@ import { DAEMON_BIN_NAME, MCP_BIN_NAME, NATIVE_DIR_NAME, PRODUCT_NAME, VERSION }
|
|
|
10
10
|
// constants.mjs, so the debug variant resolves its own payload directory,
|
|
11
11
|
// binary names, and plugin source without a parallel code path.
|
|
12
12
|
export const PLUGIN_NAME = PRODUCT_NAME;
|
|
13
|
-
|
|
13
|
+
// Every platform tuple that ships a prebuilt payload maps to its own staged
|
|
14
|
+
// directory name. The tuple is the package's platform identity: the payload
|
|
15
|
+
// directory, the manifest `platform` field, and the image check in
|
|
16
|
+
// payload.mjs all key off the same value. A tuple outside this table has no
|
|
17
|
+
// payload and is rejected by the installer, never silently downgraded.
|
|
18
|
+
// NATIVE_PLATFORM stays the reference platform (the released macOS identity).
|
|
19
|
+
export const NATIVE_PLATFORMS = Object.freeze(['darwin-arm64', 'linux-x64']);
|
|
20
|
+
export const NATIVE_PLATFORM = NATIVE_PLATFORMS[0];
|
|
14
21
|
export const NATIVE_BINARIES = Object.freeze([DAEMON_BIN_NAME, MCP_BIN_NAME]);
|
|
15
22
|
|
|
16
23
|
export function packageRoot() {
|
|
@@ -18,12 +25,12 @@ export function packageRoot() {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export function nativePlatform(platform = process.platform, arch = process.arch) {
|
|
21
|
-
|
|
22
|
-
return null;
|
|
28
|
+
const tuple = `${platform}-${arch}`;
|
|
29
|
+
return NATIVE_PLATFORMS.includes(tuple) ? tuple : null;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export function nativePayloadDir(platform = nativePlatform()) {
|
|
26
|
-
if (platform
|
|
33
|
+
if (!NATIVE_PLATFORMS.includes(platform)) return null;
|
|
27
34
|
return path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, platform);
|
|
28
35
|
}
|
|
29
36
|
|
package/cli/install/path.mjs
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { nativeBinary } from './layout.mjs';
|
|
4
|
+
import { platform } from '../paths.mjs';
|
|
4
5
|
|
|
5
|
-
// The launchd/GUI
|
|
6
|
-
// Every managed entry point therefore resolves to an
|
|
7
|
-
// installed package, and the fixed
|
|
8
|
-
// may use to locate system tools. This module
|
|
9
|
-
// PATH findings are reported so an explicit
|
|
6
|
+
// The launchd/GUI and systemd/user-manager environments do not inherit the
|
|
7
|
+
// interactive shell PATH. Every managed entry point therefore resolves to an
|
|
8
|
+
// absolute file inside the installed package, and the fixed PATHs below are
|
|
9
|
+
// only what the daemon itself may use to locate system tools. This module
|
|
10
|
+
// never writes user profiles; PATH findings are reported so an explicit
|
|
11
|
+
// repair stays a user decision.
|
|
10
12
|
export const LAUNCHD_FIXED_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
13
|
+
// The Linux fixed set covers systemctl and the payload's system-tool
|
|
14
|
+
// dependencies. systemdServicePath() below appends the interpreter directory
|
|
15
|
+
// of the Node that rendered the unit, so a persisted shim-style subagent
|
|
16
|
+
// runtime (an nvm-installed Codex launcher) resolves `node` from the service
|
|
17
|
+
// environment instead of relying on the interactive shell PATH.
|
|
18
|
+
export const SYSTEMD_FIXED_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
19
|
+
|
|
20
|
+
// The Linux service PATH is the fixed system-tool set plus the interpreter
|
|
21
|
+
// directory of the node that rendered the unit. The persisted Codex runtime
|
|
22
|
+
// is commonly an nvm-installed Node launcher whose `#!/usr/bin/env node`
|
|
23
|
+
// shebang resolves `node` from PATH; without that directory the
|
|
24
|
+
// systemd-managed daemon cannot execute it. This mirrors the macOS fixed PATH
|
|
25
|
+
// carrying the Homebrew bin that holds its own `node`. The appended directory
|
|
26
|
+
// is absolute and newline-free by construction, so the unit generator's
|
|
27
|
+
// assertUnitLineSafe admits it unconditionally.
|
|
28
|
+
export function systemdServicePath() {
|
|
29
|
+
return `${SYSTEMD_FIXED_PATH}:${path.dirname(process.execPath)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fixedServicePath(env = process.env) {
|
|
33
|
+
return platform(env) === 'darwin' ? LAUNCHD_FIXED_PATH : systemdServicePath();
|
|
34
|
+
}
|
|
11
35
|
|
|
12
36
|
export function which(name, env = process.env) {
|
|
13
37
|
const searchPath = (env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
@@ -25,17 +49,18 @@ export function pathReport(options = {}) {
|
|
|
25
49
|
const env = options.env || process.env;
|
|
26
50
|
const daemon = nativeBinary('external-subagentd');
|
|
27
51
|
const facade = nativeBinary('external-subagent-mcp');
|
|
52
|
+
const darwin = platform(env) === 'darwin';
|
|
28
53
|
return {
|
|
29
54
|
shell: {
|
|
30
55
|
path: env.PATH || null,
|
|
31
56
|
cli_on_path: which('external-subagent', env),
|
|
32
57
|
mcp_on_path: which('external-subagent-mcp', env),
|
|
33
58
|
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
mcp_entry: facade
|
|
38
|
-
|
|
59
|
+
// The fixed service-environment PATH, keyed by the backend that owns it
|
|
60
|
+
// so the report never calls a systemd unit "launchd".
|
|
61
|
+
...(darwin
|
|
62
|
+
? { launchd: { path: LAUNCHD_FIXED_PATH, daemon_entry: daemon, mcp_entry: facade } }
|
|
63
|
+
: { systemd: { path: systemdServicePath(), daemon_entry: daemon, mcp_entry: facade } }),
|
|
39
64
|
stable_entries_absolute: Boolean(daemon && facade && path.isAbsolute(daemon) && path.isAbsolute(facade)),
|
|
40
65
|
shell_profile_writes: 'none',
|
|
41
66
|
node_executable: process.execPath,
|
package/cli/install/payload.mjs
CHANGED
|
@@ -4,11 +4,25 @@ import { CliError } from '../errors.mjs';
|
|
|
4
4
|
import { sha256 } from '../fs-atomic.mjs';
|
|
5
5
|
import { NATIVE_DIR_NAME, PRODUCT_NAME } from '../constants.mjs';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
NATIVE_PLATFORMS, cliVersion, nativePayloadDir, nativePlatform, packageVersion, payloadManifestPath,
|
|
8
8
|
} from './layout.mjs';
|
|
9
9
|
|
|
10
10
|
const MH_MAGIC_64 = 0xfeedfacf;
|
|
11
11
|
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
12
|
+
// The single executable mode every released payload file must carry.
|
|
13
|
+
const RELEASE_FILE_MODE = 0o755;
|
|
14
|
+
|
|
15
|
+
// ELF64 header identity, read little-endian:
|
|
16
|
+
// e_ident[0..3] = 0x7f 'E' 'L' 'F' (0x464c457f as a LE uint32)
|
|
17
|
+
// e_ident[4] = EI_CLASS (2 = ELFCLASS64)
|
|
18
|
+
// e_ident[5] = EI_DATA (1 = ELFDATA2LSB)
|
|
19
|
+
// e_machine = uint16 at offset 18 (0x3e = EM_X86_64)
|
|
20
|
+
const ELF_MAGIC = 0x464c457f;
|
|
21
|
+
const ELFCLASS64 = 2;
|
|
22
|
+
const ELFDATA2LSB = 1;
|
|
23
|
+
const EM_X86_64 = 0x3e;
|
|
24
|
+
const ELF_E_MACHINE_OFFSET = 18;
|
|
25
|
+
const ELF_HEADER_MIN_BYTES = ELF_E_MACHINE_OFFSET + 2;
|
|
12
26
|
|
|
13
27
|
export function machoArch(bytes) {
|
|
14
28
|
if (bytes.length < 8) return null;
|
|
@@ -16,6 +30,25 @@ export function machoArch(bytes) {
|
|
|
16
30
|
return bytes.readUInt32LE(4) === CPU_TYPE_ARM64 ? 'arm64' : null;
|
|
17
31
|
}
|
|
18
32
|
|
|
33
|
+
// The Linux image check mirrors machoArch: only a 64-bit little-endian x86-64
|
|
34
|
+
// ELF is a supported payload. A truncated header (shorter than e_machine), a
|
|
35
|
+
// 32-bit or big-endian image, or any other machine returns null and is
|
|
36
|
+
// rejected as an unsupported architecture.
|
|
37
|
+
export function elfArch(bytes) {
|
|
38
|
+
if (bytes.length < ELF_HEADER_MIN_BYTES) return null;
|
|
39
|
+
if (bytes.readUInt32LE(0) !== ELF_MAGIC) return null;
|
|
40
|
+
if (bytes[4] !== ELFCLASS64 || bytes[5] !== ELFDATA2LSB) return null;
|
|
41
|
+
return bytes.readUInt16LE(ELF_E_MACHINE_OFFSET) === EM_X86_64 ? 'x64' : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Each supported platform has exactly one image identity. verifyPayload reads
|
|
45
|
+
// the tuple once and uses the paired probe, so a valid image of the *other*
|
|
46
|
+
// platform can never satisfy this one.
|
|
47
|
+
const IMAGE_CHECKS = Object.freeze({
|
|
48
|
+
'darwin-arm64': { arch: 'arm64', image: 'Mach-O arm64 image', probe: machoArch },
|
|
49
|
+
'linux-x64': { arch: 'x64', image: 'ELF x86-64 image', probe: elfArch },
|
|
50
|
+
});
|
|
51
|
+
|
|
19
52
|
export function readPayloadManifest(options = {}) {
|
|
20
53
|
const platform = options.platform || nativePlatform();
|
|
21
54
|
const root = options.root || undefined;
|
|
@@ -41,9 +74,10 @@ export function readPayloadManifest(options = {}) {
|
|
|
41
74
|
// defect, never a silent downgrade path.
|
|
42
75
|
export function verifyPayload(options = {}) {
|
|
43
76
|
const platform = options.platform || nativePlatform();
|
|
44
|
-
if (platform
|
|
45
|
-
throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${process.platform}-${process.arch}; supported platforms: ${
|
|
77
|
+
if (!NATIVE_PLATFORMS.includes(platform)) {
|
|
78
|
+
throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${platform ?? `${process.platform}-${process.arch}`}; supported platforms: ${NATIVE_PLATFORMS.join(', ')}`);
|
|
46
79
|
}
|
|
80
|
+
const image = IMAGE_CHECKS[platform];
|
|
47
81
|
const manifest = readPayloadManifest({ platform, root: options.root });
|
|
48
82
|
if (manifest.platform !== platform) {
|
|
49
83
|
throw new CliError('PAYLOAD_PLATFORM_MISMATCH', `payload manifest declares ${manifest.platform}, expected ${platform}`);
|
|
@@ -74,11 +108,57 @@ export function verifyPayload(options = {}) {
|
|
|
74
108
|
if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) {
|
|
75
109
|
throw new CliError('PAYLOAD_DIGEST_MISMATCH', `native payload file ${record.name} does not match the release digest`);
|
|
76
110
|
}
|
|
77
|
-
const arch =
|
|
78
|
-
if (arch !==
|
|
79
|
-
throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a
|
|
111
|
+
const arch = image.probe(bytes);
|
|
112
|
+
if (arch !== image.arch) {
|
|
113
|
+
throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a ${image.image}`);
|
|
80
114
|
}
|
|
81
115
|
return { name: record.name, bytes: bytes.length, mode: '755', sha256: record.sha256, arch };
|
|
82
116
|
});
|
|
83
117
|
return { status: 'verified', platform, version: manifest.version, files };
|
|
84
118
|
}
|
|
119
|
+
|
|
120
|
+
// npm materializes the packaged payload with the permission recorded in the
|
|
121
|
+
// tarball, masked by the installing process's umask. A host running under a
|
|
122
|
+
// permissive umask (e.g. 0002) can therefore extract an archived 0755 binary
|
|
123
|
+
// as 0775, and the strict gate in verifyPayload below rejects it as
|
|
124
|
+
// PAYLOAD_PERMISSIONS_INVALID — correct, since a group/world-writable native
|
|
125
|
+
// binary is an installation defect and the gate must never be loosened. The
|
|
126
|
+
// install paths instead repair the extracted mode before verification: only
|
|
127
|
+
// the permission is restored, the bytes are untouched, and the digest/arch
|
|
128
|
+
// checks still run. Returns true iff the mode actually changed; a missing or
|
|
129
|
+
// non-regular file (directory, symlink) is never chmodded and returns false so
|
|
130
|
+
// verifyPayload keeps owning that rejection.
|
|
131
|
+
export function normalizePayloadMode(target) {
|
|
132
|
+
let stat;
|
|
133
|
+
try { stat = fs.lstatSync(target); } catch { return false; }
|
|
134
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
135
|
+
if ((stat.mode & 0o777) === RELEASE_FILE_MODE) return false;
|
|
136
|
+
fs.chmodSync(target, RELEASE_FILE_MODE);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Normalize every payload file the manifest names, exactly the set and order
|
|
141
|
+
// verifyPayload will read. An absent/invalid manifest or an unreadable file is
|
|
142
|
+
// left for verifyPayload to reject with its own code — this helper never turns
|
|
143
|
+
// a rejection into a pass. Returns the repaired entries with their prior octal
|
|
144
|
+
// mode so the install step can report what it changed.
|
|
145
|
+
export function normalizePayloadFiles(options = {}) {
|
|
146
|
+
const platform = options.platform || nativePlatform();
|
|
147
|
+
if (!NATIVE_PLATFORMS.includes(platform)) return [];
|
|
148
|
+
let manifest;
|
|
149
|
+
try { manifest = readPayloadManifest({ platform, root: options.root }); } catch { return []; }
|
|
150
|
+
const dir = options.root
|
|
151
|
+
? path.join(options.root, 'npm', NATIVE_DIR_NAME, platform)
|
|
152
|
+
: nativePayloadDir(platform);
|
|
153
|
+
const normalized = [];
|
|
154
|
+
for (const record of manifest.files) {
|
|
155
|
+
if (!record || typeof record.name !== 'string' || path.basename(record.name) !== record.name || record.name.includes('..')) continue;
|
|
156
|
+
const target = path.join(dir, record.name);
|
|
157
|
+
let stat;
|
|
158
|
+
try { stat = fs.lstatSync(target); } catch { continue; }
|
|
159
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
160
|
+
const before = stat.mode & 0o777;
|
|
161
|
+
if (normalizePayloadMode(target)) normalized.push({ name: record.name, from: before.toString(8).padStart(3, '0'), to: '755' });
|
|
162
|
+
}
|
|
163
|
+
return normalized;
|
|
164
|
+
}
|