external-subagent 0.4.1 → 0.5.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/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.0` (macOS arm64 only; `os`/`cpu` in `package.json`
20
- reject installation on other platforms at npm install time). `init` installs
21
- the standalone daemon service only — no host is bound implicitly; hosts are
22
- bound through the explicit `install-plugin` (Codex or ZCode) /
23
- `install-mcp` (Codex-only TOML binding) commands. Installing from a packed
24
- artifact and explicitly initializing to a launchd-resident service
25
- (idempotent repeat init/start) is live-verified on a real GUI session;
26
- active-task upgrade and standalone initialization are implemented and
27
- reviewed.
19
+ `external-subagent@0.5.0` 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.0`). The CLI
73
+ The product version has a single source: `package.json` (`0.5.0`). 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.3`) is deliberately **not** tied to the product version: it is codex's
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
- on an Apple Silicon runner, which rebuilds the payload, runs the pack gates,
145
- and publishes with no npm token in the environment — the runner's OIDC
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 platform: macOS arm64 — `os`/`cpu` in `package.json` make npm
169
- refuse installation elsewhere up front; the CLI additionally keeps
170
- `help`/`version` working on other platforms and rejects business commands
171
- without writing HOME.
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
- const debug = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
11
- const facade = path.join(packageRoot, 'npm', debug ? 'native-debug' : 'native', 'darwin-arm64',
12
- debug ? 'external-subagent-debug-mcp' : 'external-subagent-mcp');
13
- const child = spawn(facade, process.argv.slice(2), { stdio: 'inherit', env: process.env });
14
- child.on('error', (error) => {
15
- console.error(error.message);
16
- process.exitCode = 1;
17
- });
18
- child.on('exit', (code, signal) => {
19
- process.exitCode = code ?? (signal ? 1 : 0);
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();
@@ -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;
@@ -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
- // LaunchAgent, and the local installation summary consumed by `status`.
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
- launch_agent: fs.existsSync(paths.launchAgent),
38
+ ...serviceDefinition,
29
39
  data: fs.existsSync(paths.data),
30
40
  payload: publicPayload,
31
41
  // Home paths are installation internals; status reports only the
@@ -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 helper = spawn('/usr/bin/lockf', ['-t', '2', lock, '/bin/sh', '-c',
20
- 'ready="$1"; parent="$2"; printf ready > "$ready"; while kill -0 "$parent" 2>/dev/null; do sleep 0.05; done',
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.4.1';
15
+ export const VERSION = '0.5.0';
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',
@@ -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, installLaunchAgent, runtimeObservations } from './service-macos.mjs';
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 macOS service template
24
- // start-service launchctl bootstrap (best-effort, reported)
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
- const manifest = payloadManifestPath() ?? path.join(packageRoot(), 'npm', 'native', 'darwin-arm64', 'payload.json');
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
- installLaunchAgent(paths);
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
  }
@@ -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
- export const NATIVE_PLATFORM = 'darwin-arm64';
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
- if (platform === 'darwin' && arch === 'arm64') return NATIVE_PLATFORM;
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 !== NATIVE_PLATFORM) return null;
33
+ if (!NATIVE_PLATFORMS.includes(platform)) return null;
27
34
  return path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, platform);
28
35
  }
29
36
 
@@ -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 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.
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
- launchd: {
35
- path: LAUNCHD_FIXED_PATH,
36
- daemon_entry: daemon,
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,
@@ -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
- NATIVE_PLATFORM, cliVersion, nativePayloadDir, nativePlatform, packageVersion, payloadManifestPath,
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 === null) {
45
- throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${process.platform}-${process.arch}; supported platforms: ${NATIVE_PLATFORM}`);
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 = 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`);
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
+ }