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/cli/main.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { BUSINESS_COMMANDS, PRODUCT_NAME, VERSION, ZCODE_RUNTIME } from './constants.mjs';
4
+ import { BUSINESS_COMMANDS, PRODUCT_NAME, VERSION, zcodeRuntimePath } from './constants.mjs';
5
5
  import { CliError } from './errors.mjs';
6
6
  import { installHooks, installPlan, runInit } from './install/init.mjs';
7
7
  import { nativeBinary } from './install/layout.mjs';
@@ -17,7 +17,12 @@ import { parseConfigArgs } from './commands/config.mjs';
17
17
  import { subagentsCommand, parseSubagentsArgs } from './commands/agents.mjs';
18
18
  import { parseProfileArgs, parseSpawnArgs, prepareSpawnInput, profileCommand } from './commands/tasks.mjs';
19
19
 
20
- const HELP = `external-subagent ${VERSION}\n\nUsage: external-subagent <command> [options]\n\nCommands:\n help, version Show basic product information\n init [--dry-run] [--resume] [--install-hooks] [--skip-service-start]\n Install the standalone daemon service only; bind a\n host afterwards with install-plugin or install-mcp\n hooks install [--dry-run] Install ZCode policy hooks explicitly\n install-plugin [codex|zcode] [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the managed host plugin (MCP + skill);\n the host defaults to codex, --codex-home is codex-only\n install-mcp [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the direct Codex MCP TOML binding\n status, diagnose Inspect local service and runtime state\n start, stop Bootstrap or boot out the daemon LaunchAgent\n profile list | profile show <name>\n Inspect global spawn profiles (profiles/*.json);\n invalid files are reported as path-based warnings,\n and warned or duplicate files are excluded from the\n available name set\n backup --output <dir> Back up retained product data\n restore --input <dir> Verify and restore product data\n uninstall Release Codex/ZCode bindings; remove service registration; retain data\n purge --yes Explicitly delete new product data\n`;
20
+ // Hosts that ship a supported service backend and payload: macOS (launchd)
21
+ // and Linux (systemd user services). The gate is structural, so any other
22
+ // platform is rejected before product paths are resolved or written.
23
+ const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux']);
24
+
25
+ const HELP = `external-subagent ${VERSION}\n\nUsage: external-subagent <command> [options]\n\nCommands:\n help, version Show basic product information\n init [--dry-run] [--resume] [--install-hooks] [--skip-service-start]\n Install the standalone daemon service only; bind a\n host afterwards with install-plugin or install-mcp\n hooks install [--dry-run] Install ZCode policy hooks explicitly\n install-plugin [codex|zcode] [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the managed host plugin (MCP + skill);\n the host defaults to codex, --codex-home is codex-only\n install-mcp [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the direct Codex MCP TOML binding\n status, diagnose Inspect local service and runtime state\n start, stop Bootstrap or boot out the daemon service\n profile list | profile show <name>\n Inspect global spawn profiles (profiles/*.json);\n invalid files are reported as path-based warnings,\n and warned or duplicate files are excluded from the\n available name set\n backup --output <dir> Back up retained product data\n restore --input <dir> Verify and restore product data\n uninstall Release Codex/ZCode bindings; remove service registration; retain data\n purge --yes Explicitly delete new product data\n`;
21
26
  const DAEMON_HELP = ` config get [key] | config set <key> <value>\n subagents list | subagents status [subagent] | subagents probe/models [subagent]\n create/spawn [--profile <name>], wait, list, send, respond, cancel, result, close, observe\n Daemon calls accept --json '<object>' or JSON stdin\n list JSON requires repository (workspace is an alias)\n observe JSON requires only agent_id\n`;
22
27
 
23
28
  function structuredInput(args, parser) {
@@ -262,7 +267,11 @@ async function diagnose(paths, args) {
262
267
  scope: agent ? { agent_id: agent } : { kind: 'global' },
263
268
  platform: platform(),
264
269
  runtime: {
265
- configured_artifact: fileArtifact(ZCODE_RUNTIME, 'cli_packaged_configuration'),
270
+ // The same platform-aware resolution the service generators use
271
+ // (constants.mjs): explicit ZCODE_RUNTIME_PATH, then the platform's
272
+ // conventional installation, then the packaged pin — reported as an
273
+ // artifact observation, never a probe.
274
+ configured_artifact: fileArtifact(zcodeRuntimePath(paths.home), 'cli_resolved_configuration'),
266
275
  running_identity: null,
267
276
  running_identity_source: 'not_observed_by_cli',
268
277
  },
@@ -332,7 +341,10 @@ export async function main(args) {
332
341
  process.stdout.write(`${VERSION}\n`); return;
333
342
  }
334
343
  if (command !== 'profile' && !BUSINESS_COMMANDS.has(command)) throw new CliError('UNKNOWN_COMMAND', `unknown command: ${command}`, 2);
335
- if (platform() !== 'darwin') throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS`);
344
+ // macOS and Linux are supported hosts; every other platform (Windows, BSD,
345
+ // an unknown tuple) is refused structurally before any filesystem side
346
+ // effect, so an unsupported host never writes HOME state.
347
+ if (!SUPPORTED_PLATFORMS.has(platform())) throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS and Linux`);
336
348
 
337
349
  const paths = productPaths();
338
350
  if (command === 'init') {
@@ -373,13 +385,15 @@ export async function main(args) {
373
385
  if (command === 'status') {
374
386
  const verbose = args.includes('--verbose');
375
387
  const local = localInstallStatus(paths, { verbose });
376
- // `service` is the read-only launchd view (registered job + process);
377
- // `daemon_status` stays the RPC view, so a loaded-but-unready or
378
- // ready-but-unregistered install reads differently instead of blurring.
379
- const serviceRaw = serviceRegistrationStatus();
380
- // PID is an implementation detail and may be reused by another process;
381
- // keep it out of the ordinary status projection.
382
- const { pid: _pid, ...service } = serviceRaw;
388
+ // `service` is the read-only backend view (launchd registered job or the
389
+ // systemd user unit's real state — `failed` included, never a fake
390
+ // healthy) selected by the platform dispatch in service-macos.mjs; a Linux
391
+ // host without a reachable user manager degrades to an explicit
392
+ // `unavailable` view with the linger hint instead of failing the whole
393
+ // read-only command. `daemon_status` stays the RPC view, so a
394
+ // loaded-but-unready or ready-but-unregistered install reads differently
395
+ // instead of blurring.
396
+ const { pid: _pid, ...service } = serviceRegistrationStatus();
383
397
  try {
384
398
  output({ ...local, service, daemon_status: publicDaemonStatus(await callDaemon(process.env.EXTERNAL_SUBAGENT_SOCKET || paths.socket, 'status', {}), { verbose }) });
385
399
  } catch (error) {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { CliError } from './errors.mjs';
4
4
  import { jsonBytes, sha256 } from './fs-atomic.mjs';
5
- import { productPaths } from './paths.mjs';
5
+ import { platform, productPaths } from './paths.mjs';
6
6
  import { bootoutService } from './install/service-macos.mjs';
7
7
 
8
8
  function copyTree(source, destination, records, root = source) {
@@ -75,20 +75,25 @@ function removeOne(target) {
75
75
  }
76
76
  }
77
77
 
78
- // Deleting the plist alone leaves a job launchd already loaded running until
79
- // the next logout (observed live: uninstall reported success while the daemon
80
- // process, the launchd registration, and the RPC socket all stayed alive).
81
- // The ES-owned service is therefore booted out — with the bounded
82
- // removal-confirmation bootoutService owns — before its definition file is
83
- // removed. A service that is not registered is not an error (idempotent
84
- // uninstall); a bootout that cannot complete fails the command rather than
85
- // deleting the definition out from under a still-running service.
78
+ // Deleting the definition alone leaves a job the service manager already
79
+ // loaded running until the next logout (observed live: uninstall reported
80
+ // success while the daemon process, the launchd registration, and the RPC
81
+ // socket all stayed alive). The ES-owned service is therefore booted out —
82
+ // with the bounded removal confirmation bootoutService owns on BOTH backends
83
+ // (launchd bootout on macOS, systemctl --user disable --now plus the inactive
84
+ // confirmation on Linux) — before its definition file is removed. A service
85
+ // that is not registered is not an error (idempotent uninstall); a stop that
86
+ // cannot complete fails the command rather than deleting the definition out
87
+ // from under a still-running service. `removed_launch_agent` is macOS-only;
88
+ // Linux reports the same removal as `removed_service_definition`.
86
89
  export function uninstall(paths = productPaths(), options = {}) {
87
- const service = bootoutService(paths, process.getuid(), options);
90
+ const macos = platform() === 'darwin';
91
+ const service = bootoutService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl });
92
+ const removed = removeOne(paths.launchAgent);
88
93
  return {
89
94
  service_stopped: true,
90
95
  service_already_stopped: Boolean(service.already_stopped),
91
- removed_launch_agent: removeOne(paths.launchAgent),
96
+ ...(macos ? { removed_launch_agent: removed } : { removed_service_definition: removed }),
92
97
  data_retained: true,
93
98
  data: paths.data,
94
99
  };
package/cli/paths.mjs CHANGED
@@ -3,14 +3,16 @@ import path from 'node:path';
3
3
  import { LAUNCH_AGENT_LABEL, PRODUCT_NAME } from './constants.mjs';
4
4
  import { PLUGIN_NAME } from './install/layout.mjs';
5
5
 
6
- export function platform() {
7
- return process.env.EXTERNAL_SUBAGENT_TEST_PLATFORM || process.platform;
6
+ // The process platform, overridable by the EXTERNAL_SUBAGENT_TEST_PLATFORM seam
7
+ // so both layouts are exercisable on any host. The seam keeps its original
8
+ // meaning: it is a *process platform* override, nothing else.
9
+ export function platform(env = process.env) {
10
+ return env.EXTERNAL_SUBAGENT_TEST_PLATFORM || process.platform;
8
11
  }
9
12
 
10
- // State paths derive from PRODUCT_NAME, so the debug variant gets a fully
11
- // parallel tree (its own data directory, database, socket, and logs) and
12
- // never touches the released product's state.
13
- export function productPaths(home = os.homedir()) {
13
+ // macOS derives the whole tree from `~/Library`; the bytes of every field below
14
+ // are contractually frozen (existing installs must keep resolving their state).
15
+ function darwinProductPaths(home) {
14
16
  const data = path.join(home, 'Library', 'Application Support', PRODUCT_NAME);
15
17
  return {
16
18
  home,
@@ -28,6 +30,52 @@ export function productPaths(home = os.homedir()) {
28
30
  };
29
31
  }
30
32
 
33
+ // XDG Base Directory resolution (D-S02). A variable selects its directory only
34
+ // when it is a non-empty absolute path; relative or empty values are ignored and
35
+ // the spec fallback under $HOME applies. Only the three XDG variables the
36
+ // product owns are read — no other user XDG directory is probed.
37
+ function xdgDirectory(env, variable, home, fallback) {
38
+ const configured = env[variable];
39
+ if (typeof configured === 'string' && configured !== '' && path.isAbsolute(configured)) {
40
+ return configured;
41
+ }
42
+ return path.join(home, ...fallback);
43
+ }
44
+
45
+ // Linux keeps the same internal relative layout as macOS (config, install
46
+ // state, database, socket, hook provenance, plugin staging, profiles all live
47
+ // inside the product data directory) so the two platforms stay structurally
48
+ // parallel. Data and logs follow XDG; the systemd user unit directory is
49
+ // provided for the S03 service backend.
50
+ function linuxProductPaths(home, env) {
51
+ const data = path.join(xdgDirectory(env, 'XDG_DATA_HOME', home, ['.local', 'share']), PRODUCT_NAME);
52
+ const logs = path.join(xdgDirectory(env, 'XDG_STATE_HOME', home, ['.local', 'state']), PRODUCT_NAME);
53
+ const configHome = xdgDirectory(env, 'XDG_CONFIG_HOME', home, ['.config']);
54
+ return {
55
+ home,
56
+ data,
57
+ config: path.join(data, 'config.json'),
58
+ state: path.join(data, 'install-state.json'),
59
+ hookProvenance: path.join(data, 'zcode-agent-hook-provenance.json'),
60
+ database: path.join(data, `${PRODUCT_NAME}.sqlite3`),
61
+ socket: path.join(data, `${PRODUCT_NAME}.sock`),
62
+ logs,
63
+ launchAgent: path.join(configHome, 'systemd', 'user', `${PRODUCT_NAME}.service`),
64
+ zcodeConfig: path.join(home, '.zcode', 'cli', 'config.json'),
65
+ zcodePlugin: path.join(data, 'zcode-plugin', PLUGIN_NAME),
66
+ profiles: path.join(data, 'profiles'),
67
+ };
68
+ }
69
+
70
+ // State paths derive from PRODUCT_NAME, so the debug variant gets a fully
71
+ // parallel tree (its own data directory, database, socket, and logs) and
72
+ // never touches the released product's state. The layout is selected by the
73
+ // process platform: macOS `~/Library`, every other supported host the XDG
74
+ // baseline. `zcodeConfig` is `~/.zcode/cli/config.json` on both platforms.
75
+ export function productPaths(home = os.homedir(), env = process.env) {
76
+ return platform(env) === 'darwin' ? darwinProductPaths(home) : linuxProductPaths(home, env);
77
+ }
78
+
31
79
  // Mirror the daemon's `profiles_directory()` exactly (rpc/profiles.rs):
32
80
  // `EXTERNAL_SUBAGENT_CONFIG` wins when it is *exported*, even when exported as
33
81
  // an empty string, and only then does `ZCODE_AGENT_CONFIG` apply; presence, not
@@ -35,7 +83,8 @@ export function productPaths(home = os.homedir()) {
35
83
  // exported path has no parent in Rust (`Path::new("").parent() == None`), so the
36
84
  // daemon loads no profiles directory at all; return null to represent that.
37
85
  // Otherwise take the sibling `profiles/` of the config file, or fall back to the
38
- // product data directory.
86
+ // product data directory (the daemon's data fallback is the same XDG/macOS data
87
+ // root selected here, so both sides resolve one directory).
39
88
 
40
89
  // Rust `Path::parent()` semantics on Unix, implemented with plain string
41
90
  // slicing (never `path.dirname`/`path.join`, which lexically normalise and would
@@ -83,7 +132,7 @@ export function profilesDir(env = {}, home = os.homedir()) {
83
132
  break;
84
133
  }
85
134
  }
86
- if (!exported) return path.join(productPaths(home).data, 'profiles');
135
+ if (!exported) return path.join(productPaths(home, env).data, 'profiles');
87
136
  const parent = rustPathParent(envPath);
88
137
  if (parent === null) return null;
89
138
  return joinProfiles(parent);
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.4.1",
4
+ "version": "0.5.1",
5
5
  "platform": "darwin-arm64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 10116128,
9
+ "bytes": 10075248,
10
10
  "mode": "755",
11
- "sha256": "9c650b9c096f4060e927eced4d05665749123177e3c7d105a13a8a466633ed77"
11
+ "sha256": "eb0b1c4ffb4ab44678adf5aa39ee96f61cf09ebdb03b74da9e12cebad2b868f5"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
15
  "bytes": 935888,
16
16
  "mode": "755",
17
- "sha256": "e519c71451bc27bf217316c57fe92d82e8e8fc546c13194455ad60dbc0cdc9f7"
17
+ "sha256": "1dce8a0c5566af2d9b4a12a39ad43c819bcb7f395a1a7c738389e5b0bbd24346"
18
18
  }
19
19
  ]
20
20
  }
@@ -0,0 +1,20 @@
1
+ {
2
+ "schema_version": 1,
3
+ "product": "external-subagent",
4
+ "version": "0.5.1",
5
+ "platform": "linux-x64",
6
+ "files": [
7
+ {
8
+ "name": "external-subagentd",
9
+ "bytes": 11452160,
10
+ "mode": "755",
11
+ "sha256": "c4f410496c2f9390288cb168e447127c9ee7ec0b47c591bcd84f25f688cad3af"
12
+ },
13
+ {
14
+ "name": "external-subagent-mcp",
15
+ "bytes": 1025360,
16
+ "mode": "755",
17
+ "sha256": "6553125c48dcd1a21554a6dd1181a7cb55d84f874118a1bd5136b5b29e916e60"
18
+ }
19
+ ]
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "external-subagent",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Manage bounded ZCode, DeepSeek Harness (DSH), Codex, and Google Antigravity (agy) subagent tasks through a local MCP service for any local host (Codex, ZCode, or a custom MCP client)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,10 +21,12 @@
21
21
  "agent-tasks"
22
22
  ],
23
23
  "os": [
24
- "darwin"
24
+ "darwin",
25
+ "linux"
25
26
  ],
26
27
  "cpu": [
27
- "arm64"
28
+ "arm64",
29
+ "x64"
28
30
  ],
29
31
  "bin": {
30
32
  "external-subagent": "bin/external-subagent.mjs",
@@ -52,6 +54,7 @@
52
54
  "postpack": "node scripts/release/check-native-tarball.mjs",
53
55
  "prepublishOnly": "node scripts/release/check-native-tarball.mjs --staged",
54
56
  "test:install": "node --test tests/install/*.test.mjs",
57
+ "test:release": "node --test tests/release/*.test.mjs",
55
58
  "test:affected": "node scripts/test/affected.mjs",
56
59
  "check": "node --check bin/external-subagent.mjs && node --check bin/external-subagent-mcp.mjs && node --check bin/external-subagent-debug.mjs && node --check bin/external-subagent-debug-mcp.mjs && node --check cli/main.mjs"
57
60
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "external-subagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Manage bounded external agent tasks through the external-subagent MCP service.",
5
5
  "author": { "name": "external-subagent maintainers" },
6
6
  "skills": "./skills/",
@@ -25,8 +25,22 @@ const script = hook?.args?.find((candidate) => typeof candidate === 'string' &&
25
25
  const guard = events.PreToolUse.find((entry) => entry?.matcher === 'Bash')?.hooks?.find((entry) => entry?.type === 'process')?.args?.[0];
26
26
  const audit = events.PostToolUse?.find((entry) => entry?.matcher === 'Bash')?.hooks?.find((entry) => entry?.type === 'process')?.args?.[0];
27
27
  if (!script || !fs.statSync(script).isFile() || hook.command !== process.execPath || !guard || !fs.statSync(guard).isFile() || !audit || !fs.statSync(audit).isFile()) process.exit(2);
28
- const provenancePath = path.join(home, 'Library', 'Application Support', 'external-subagent', 'zcode-agent-hook-provenance.json');
29
- if (!fs.existsSync(provenancePath)) process.exit(2);
28
+ // The provenance is staged by install-agent-hooks.mjs beside this verifier,
29
+ // inside the product data directory of the probe home. The candidates mirror
30
+ // the daemon's policy_verifier_candidates exactly: the frozen macOS
31
+ // `~/Library/Application Support` bytes on darwin; elsewhere an exported
32
+ // absolute $XDG_DATA_HOME is probed first, then the `~/.local/share` XDG
33
+ // fallback for the scope home.
34
+ const provenanceCandidates = process.platform === 'darwin'
35
+ ? [path.join(home, 'Library', 'Application Support', 'external-subagent', 'zcode-agent-hook-provenance.json')]
36
+ : [
37
+ ...(typeof process.env.XDG_DATA_HOME === 'string' && path.isAbsolute(process.env.XDG_DATA_HOME)
38
+ ? [path.join(process.env.XDG_DATA_HOME, 'external-subagent', 'zcode-agent-hook-provenance.json')]
39
+ : []),
40
+ path.join(home, '.local', 'share', 'external-subagent', 'zcode-agent-hook-provenance.json'),
41
+ ];
42
+ const provenancePath = provenanceCandidates.find((candidate) => fs.existsSync(candidate));
43
+ if (!provenancePath) process.exit(2);
30
44
  const provenance = JSON.parse(fs.readFileSync(provenancePath, 'utf8'));
31
45
  const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
32
46
  const policy = path.resolve(path.dirname(script), '../lib/agent-file-policy.mjs');
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Build the versioned native payload for the npm package: release binaries
3
3
  // for external-subagentd and external-subagent-mcp are copied into
4
- // npm/native/darwin-arm64 together with a deterministic payload.json manifest
5
- // (version, platform, per-file bytes/sha256/mode). Installed-package code
4
+ // npm/native/<platform> together with a deterministic payload.json manifest
5
+ // (version, platform, per-file bytes/sha256/mode), where <platform> is derived
6
+ // from the running host (darwin-arm64, linux-x64). Installed-package code
6
7
  // verifies against this manifest, so it must never contain volatile fields.
7
8
  //
8
9
  // `--variant debug` stages the parallel development payload instead: the same
9
- // cargo artifacts are copied under debug names into npm/native-debug/darwin-arm64
10
+ // cargo artifacts are copied under debug names into npm/native-debug/<platform>
10
11
  // (manifest product external-subagent-debug), and the debug plugin source is
11
12
  // staged at plugins/codex/external-subagent-debug. The debug payload is a
12
13
  // development checkout product and is rejected from release tarballs by
@@ -20,8 +21,19 @@ import crypto from 'node:crypto';
20
21
  import { spawnSync } from 'node:child_process';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  import { stageDebugPlugin } from './stage-debug-plugin.mjs';
24
+ import { nativePlatform } from '../../cli/install/layout.mjs';
23
25
 
24
26
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
27
+ // The staged directory and the manifest `platform` field derive from the
28
+ // running host through the shared platform table, so one script stages the
29
+ // correct tuple everywhere (release: darwin-arm64, linux-x64) and an
30
+ // unsupported host fails before touching the tree. The debug variant reuses
31
+ // the same tuple under its parallel native-debug directory.
32
+ const platform = nativePlatform();
33
+ if (platform === null) {
34
+ process.stderr.write(`no native payload for ${process.platform}-${process.arch}\n`);
35
+ process.exit(2);
36
+ }
25
37
  const variantIndex = process.argv.indexOf('--variant');
26
38
  const variant = variantIndex === -1 ? '' : (process.argv[variantIndex + 1] ?? '');
27
39
  if (variant !== '' && variant !== 'debug') {
@@ -42,7 +54,7 @@ const productName = isDebug ? 'external-subagent-debug' : 'external-subagent';
42
54
  const binaries = isDebug
43
55
  ? [['external-subagentd', 'external-subagent-debugd'], ['external-subagent-mcp', 'external-subagent-debug-mcp']]
44
56
  : [['external-subagentd', 'external-subagentd'], ['external-subagent-mcp', 'external-subagent-mcp']];
45
- const platformDir = path.join(packageRoot, 'npm', nativeDirName, 'darwin-arm64');
57
+ const platformDir = path.join(packageRoot, 'npm', nativeDirName, platform);
46
58
  const manifestPath = path.join(platformDir, 'payload.json');
47
59
  const sourceRoots = ['crates', 'profiles'].map((dir) => path.join(packageRoot, dir))
48
60
  .concat([path.join(packageRoot, 'Cargo.toml'), path.join(packageRoot, 'Cargo.lock')]);
@@ -73,7 +85,7 @@ function stagedIsCurrent(version) {
73
85
  if (!fs.existsSync(manifestPath)) return false;
74
86
  let manifest;
75
87
  try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return false; }
76
- if (manifest.version !== version || manifest.platform !== 'darwin-arm64' || manifest.product !== productName) return false;
88
+ if (manifest.version !== version || manifest.platform !== platform || manifest.product !== productName) return false;
77
89
  if ((manifest.profile ?? 'release') !== cargoProfile) return false;
78
90
  const inputs = newestInputMs();
79
91
  for (const file of manifest.files) {
@@ -121,7 +133,7 @@ for (const [source, name] of binaries) {
121
133
  files.push({ name, bytes: bytes.length, mode: '755', sha256: sha256(bytes) });
122
134
  }
123
135
 
124
- const manifest = { schema_version: 1, product: productName, version, platform: 'darwin-arm64' };
136
+ const manifest = { schema_version: 1, product: productName, version, platform };
125
137
  // The debug profile is recorded so --if-stale can tell a debug-staged payload
126
138
  // from a release one; the release manifest stays byte-identical to before.
127
139
  if (cargoProfile !== 'release') manifest.profile = cargoProfile;
@@ -3,26 +3,47 @@
3
3
  // check-native-tarball.mjs [package.tgz] verify a packed tarball (entries,
4
4
  // payload manifest consistency,
5
5
  // forbidden development material)
6
- // check-native-tarball.mjs --staged verify only the staged payload
7
- // (npm/native/darwin-arm64) against
6
+ // check-native-tarball.mjs --staged verify only the staged payloads
7
+ // (npm/native/<platform>) against
8
8
  // the package version — the publish
9
9
  // gate, since `npm publish` packs
10
10
  // into a private temp directory no
11
11
  // lifecycle script can inspect.
12
+ // A released tarball carries every supported platform payload (darwin-arm64
13
+ // and linux-x64), so both entry sets and both manifests are required in either
14
+ // mode: a tarball missing one platform is rejected. Version agreement with the
15
+ // packed package is enforced for the running host's payload (prepack rebuilds
16
+ // only the host payload from source) and, in --staged mode, for every staged
17
+ // platform — the merged publish tree where the foreign payload comes from CI's
18
+ // other platform job. Image checks (Mach-O arm64 / ELF x86-64) and the 755
19
+ // mode check use the same probes and layout table as cli/install/payload.mjs,
20
+ // so this gate cannot drift from what an installed package verifies.
12
21
  // Without --staged the tarball is resolved from: the explicit argument, the
13
22
  // npm --pack-destination directory (set during `npm pack` lifecycles), or the
14
23
  // newest matching tgz in the working directory. A dry run (npm pack --dry-run)
15
24
  // or a publish (no local tarball) skips the tarball checks and still runs the
16
25
  // staged payload checks.
17
26
  import fs from 'node:fs';
18
- import os from 'node:os';
19
27
  import path from 'node:path';
20
28
  import { spawnSync } from 'node:child_process';
29
+ import { elfArch, machoArch } from '../../cli/install/payload.mjs';
30
+ import { nativePlatform } from '../../cli/install/layout.mjs';
21
31
 
22
32
  const stagedOnly = process.argv.includes('--staged');
23
33
  const explicit = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : null;
24
34
  const dryRun = process.env.npm_config_dry_run === 'true';
25
35
 
36
+ // Every platform the single release tarball ships, and the image identity each
37
+ // payload must carry. Keeping both lists here (rather than a fixed
38
+ // darwin-arm64 path) is what makes the gate dual-platform.
39
+ const RELEASE_PLATFORMS = Object.freeze(['darwin-arm64', 'linux-x64']);
40
+ const BINARIES = Object.freeze(['external-subagentd', 'external-subagent-mcp']);
41
+ const IMAGE_CHECKS = Object.freeze({
42
+ 'darwin-arm64': { describe: 'Mach-O arm64 executable', probe: machoArch, arch: 'arm64' },
43
+ 'linux-x64': { describe: 'ELF x86-64 executable', probe: elfArch, arch: 'x64' },
44
+ });
45
+ const hostPlatform = nativePlatform();
46
+
26
47
  function findTarball() {
27
48
  if (explicit) return explicit;
28
49
  const candidates = [];
@@ -57,14 +78,17 @@ if (!stagedOnly) {
57
78
  'package/bin/external-subagent-debug.mjs',
58
79
  'package/bin/external-subagent-debug-mcp.mjs',
59
80
  'package/cli/main.mjs',
60
- 'package/npm/native/darwin-arm64/external-subagentd',
61
- 'package/npm/native/darwin-arm64/external-subagent-mcp',
62
- 'package/npm/native/darwin-arm64/payload.json',
63
81
  'package/plugins/codex/external-subagent/.codex-plugin/plugin.json',
64
82
  'package/plugins/codex/external-subagent/.mcp.json',
65
83
  'package/launchd/com.external-subagent.daemon.plist.template',
66
84
  'package/LICENSE',
67
85
  ];
86
+ // Both platform payloads are mandatory: a tarball that ships only one is
87
+ // rejected here (and by the --staged publish gate) before it can be
88
+ // published.
89
+ for (const platform of RELEASE_PLATFORMS) {
90
+ for (const name of [...BINARIES, 'payload.json']) required.push(`package/npm/native/${platform}/${name}`);
91
+ }
68
92
 
69
93
  const listing = spawnSync('tar', ['-tzf', tgz], { encoding: 'utf8' });
70
94
  if (listing.status !== 0) {
@@ -85,38 +109,82 @@ if (!stagedOnly) {
85
109
  }
86
110
 
87
111
  const packedPackage = JSON.parse(spawnSync('tar', ['-xzf', tgz, '-O', 'package/package.json'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout);
88
- const manifestText = spawnSync('tar', ['-xzf', tgz, '-O', 'package/npm/native/darwin-arm64/payload.json'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout;
89
- const manifest = JSON.parse(manifestText);
90
- if (manifest.version !== packedPackage.version) {
91
- process.stderr.write(`payload version ${manifest.version} does not match package version ${packedPackage.version}\n`);
92
- failed = true;
112
+ for (const platform of RELEASE_PLATFORMS) {
113
+ const manifestText = spawnSync('tar', ['-xzf', tgz, '-O', `package/npm/native/${platform}/payload.json`], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout;
114
+ let manifest;
115
+ try {
116
+ manifest = JSON.parse(manifestText);
117
+ } catch (error) {
118
+ process.stderr.write(`payload manifest for ${platform} is not valid JSON: ${error.message}\n`);
119
+ failed = true;
120
+ continue;
121
+ }
122
+ if (manifest.product !== 'external-subagent' || manifest.platform !== platform) {
123
+ process.stderr.write(`payload manifest for ${platform} has an inconsistent identity (${manifest.product}/${manifest.platform})\n`);
124
+ failed = true;
125
+ }
126
+ if (platform === hostPlatform && manifest.version !== packedPackage.version) {
127
+ process.stderr.write(`payload version ${manifest.version} (${platform}) does not match package version ${packedPackage.version}\n`);
128
+ failed = true;
129
+ }
93
130
  }
94
131
  }
95
132
  }
96
133
 
97
- // Staged payload checks run in every mode (pack, publish, dry run): the
98
- // package version and the staged native binaries must agree right where the
99
- // tarball is built from.
134
+ // Staged payload checks: the staged native binaries and the package version
135
+ // must agree right where the tarball is built from. The publish gate
136
+ // (--staged) checks every platform because CI merges both artifacts into one
137
+ // tree before packing; a plain `npm pack` checks the running host's platform,
138
+ // whose payload prepack rebuilds from source (the foreign platform is required
139
+ // to be present by the tarball entry checks above).
100
140
  const repoPackage = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
101
- const stagedManifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'npm', 'native', 'darwin-arm64', 'payload.json'), 'utf8'));
102
- if (stagedManifest.version !== repoPackage.version) {
103
- process.stderr.write(`staged payload version ${stagedManifest.version} does not match package version ${repoPackage.version}\n`);
104
- failed = true;
105
- }
106
- if (stagedManifest.product !== 'external-subagent') {
107
- process.stderr.write(`staged payload product ${stagedManifest.product} is not the release payload\n`);
108
- failed = true;
109
- }
110
-
111
- for (const name of ['external-subagentd', 'external-subagent-mcp']) {
112
- const local = path.join(process.cwd(), 'npm', 'native', 'darwin-arm64', name);
113
- const file = spawnSync('file', ['-b', local], { encoding: 'utf8' });
114
- if (!/Mach-O 64-bit executable arm64/u.test(file.stdout)) {
115
- process.stderr.write(`${name} is not a Mach-O arm64 executable: ${file.stdout.trim()}\n`);
141
+ function checkStagedPlatform(platform) {
142
+ const dir = path.join(process.cwd(), 'npm', 'native', platform);
143
+ const manifestPath = path.join(dir, 'payload.json');
144
+ let manifest;
145
+ try {
146
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
147
+ } catch (error) {
148
+ process.stderr.write(`staged ${platform} payload manifest is unavailable: ${error.message}\n`);
149
+ failed = true;
150
+ return;
151
+ }
152
+ if (manifest.version !== repoPackage.version) {
153
+ process.stderr.write(`staged ${platform} payload version ${manifest.version} does not match package version ${repoPackage.version}\n`);
154
+ failed = true;
155
+ }
156
+ if (manifest.product !== 'external-subagent') {
157
+ process.stderr.write(`staged ${platform} payload product ${manifest.product} is not the release payload\n`);
116
158
  failed = true;
117
159
  }
118
- const mode = spawnSync('stat', ['-f', '%Lp', local], { encoding: 'utf8' }).stdout.trim();
119
- if (mode !== '755') { process.stderr.write(`${name} has release mode ${mode}, expected 755\n`); failed = true; }
160
+ if (manifest.platform !== platform) {
161
+ process.stderr.write(`staged ${platform} payload manifest declares ${manifest.platform}\n`);
162
+ failed = true;
163
+ }
164
+ const image = IMAGE_CHECKS[platform];
165
+ for (const name of BINARIES) {
166
+ const local = path.join(dir, name);
167
+ let bytes;
168
+ try {
169
+ bytes = fs.readFileSync(local);
170
+ } catch (error) {
171
+ process.stderr.write(`${platform}/${name} is missing from the staged payload: ${error.code}\n`);
172
+ failed = true;
173
+ continue;
174
+ }
175
+ if (image.probe(bytes) !== image.arch) {
176
+ process.stderr.write(`${platform}/${name} is not a ${image.describe}\n`);
177
+ failed = true;
178
+ }
179
+ const mode = (fs.statSync(local).mode & 0o777).toString(8);
180
+ if (mode !== '755') { process.stderr.write(`${platform}/${name} has release mode ${mode}, expected 755\n`); failed = true; }
181
+ }
182
+ }
183
+
184
+ const stagedPlatforms = stagedOnly ? RELEASE_PLATFORMS : (hostPlatform ? [hostPlatform] : []);
185
+ for (const platform of stagedPlatforms) checkStagedPlatform(platform);
186
+ if (stagedPlatforms.length === 0) {
187
+ note(`no staged release platform for ${process.platform}-${process.arch}; staged payload checks skipped\n`);
120
188
  }
121
189
 
122
190
  if (failed) process.exit(1);
@@ -10,6 +10,7 @@ import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
+ import { nativePlatform } from '../../cli/install/layout.mjs';
13
14
 
14
15
  function run(command, args, options = {}) {
15
16
  const result = spawnSync(command, args, { encoding: 'utf8', ...options });
@@ -38,7 +39,11 @@ try {
38
39
  run('npm', ['install', '--global', `--prefix=${prefix}`, '--no-audit', '--no-fund', tgz], { cwd: work, env });
39
40
  const cli = path.join(prefix, 'bin', 'external-subagent');
40
41
  const packageRoot = path.join(prefix, 'lib', 'node_modules', 'external-subagent');
41
- for (const target of [cli, path.join(prefix, 'bin', 'external-subagent-mcp'), path.join(packageRoot, 'npm', 'native', 'darwin-arm64', 'external-subagentd')]) {
42
+ // The packed payload directory is the running host's tuple (S01), so the
43
+ // smoke checks the platform it is actually installing rather than a fixed
44
+ // darwin-arm64 path.
45
+ const payloadPlatform = nativePlatform() ?? `${process.platform}-${process.arch}`;
46
+ for (const target of [cli, path.join(prefix, 'bin', 'external-subagent-mcp'), path.join(packageRoot, 'npm', 'native', payloadPlatform, 'external-subagentd')]) {
42
47
  fs.accessSync(target, fs.constants.X_OK);
43
48
  }
44
49
  run(cli, ['version'], { env });