external-subagent 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +8 -5
  2. package/bin/external-subagent-mcp.mjs +3 -2
  3. package/cli/commands/agents.mjs +4 -9
  4. package/cli/commands/config.mjs +1 -1
  5. package/cli/commands/daemon.mjs +8 -3
  6. package/cli/commands/maintenance.mjs +158 -1
  7. package/cli/commands/plugin.mjs +28 -3
  8. package/cli/commands/tasks.mjs +7 -1
  9. package/cli/commands/update.mjs +18 -7
  10. package/cli/config/write.mjs +195 -14
  11. package/cli/constants.mjs +2 -2
  12. package/cli/install/codex.mjs +1 -1
  13. package/cli/install/dsh.mjs +606 -0
  14. package/cli/install/init.mjs +23 -33
  15. package/cli/install/layout.mjs +10 -2
  16. package/cli/install/payload.mjs +59 -12
  17. package/cli/install/service-activation.mjs +10 -2
  18. package/cli/install/service-linux.mjs +1 -1
  19. package/cli/install/service-macos.mjs +40 -14
  20. package/cli/install/service-windows.mjs +463 -0
  21. package/cli/install/update.mjs +6 -5
  22. package/cli/main.mjs +10 -14
  23. package/cli/maintenance.mjs +7 -1
  24. package/cli/paths.mjs +152 -8
  25. package/npm/native/README.md +2 -2
  26. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  27. package/npm/native/darwin-arm64/external-subagentd +0 -0
  28. package/npm/native/darwin-arm64/payload.json +5 -5
  29. package/npm/native/linux-x64/external-subagent-mcp +0 -0
  30. package/npm/native/linux-x64/external-subagentd +0 -0
  31. package/npm/native/linux-x64/payload.json +5 -5
  32. package/package.json +3 -2
  33. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +5 -3
  34. package/schema/external-subagent-public-api.json +3 -3
  35. package/scripts/release/build-native-payload.mjs +22 -5
  36. package/scripts/release/check-native-tarball.mjs +34 -14
  37. package/scripts/release/stage-debug-plugin.mjs +9 -3
  38. package/scripts/release/test-installed-tarball.mjs +1 -1
  39. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +0 -55
  40. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +0 -19
  41. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +0 -15
  42. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +0 -206
  43. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +0 -149
  44. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +0 -58
@@ -0,0 +1,463 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { DAEMON_BIN_NAME, NATIVE_DIR_NAME } from '../constants.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+ import { sha256 } from '../fs-atomic.mjs';
7
+ import { productPaths } from '../paths.mjs';
8
+ import { callDaemon } from '../rpc.mjs';
9
+ import { packageRoot } from './layout.mjs';
10
+
11
+ // The Windows service backend for the product daemon: a single SCM service
12
+ // registered through `sc.exe`, the structural twin of the launchd plist
13
+ // (service-macos.mjs) and the systemd user unit (service-linux.mjs). The
14
+ // service command line pins the verified daemon executable and the product
15
+ // database/socket arguments; the SCM registration itself is the service
16
+ // definition (Windows has no file-based unit, so paths.launchAgent is null).
17
+ //
18
+ // Every `sc.exe` invocation is checkable: a non-zero exit is projected into a
19
+ // structured CliError (exit 5 = access denied, the expected non-elevated
20
+ // outcome on a normal user shell), never swallowed. The injectable seam is
21
+ // `options.sc`, the twin of `options.launchctl`/`options.systemctl`, so the
22
+ // lifecycle contract is exercisable without touching a real SCM database.
23
+ export const WINDOWS_SERVICE_NAME = DAEMON_BIN_NAME;
24
+ // Pin the tool path exactly like the launchd/systemd twins pin their own
25
+ // binaries (/bin/launchctl, /usr/bin/systemctl). SystemRoot is the Windows
26
+ // installation root; a missing/relative value falls back to the conventional
27
+ // C:\Windows. Never resolve `sc.exe` through PATH (or the current directory):
28
+ // a CWD-shadowing executable must not be able to intercept service control.
29
+ function systemRoot(env = process.env) {
30
+ const configured = env.SystemRoot;
31
+ return typeof configured === 'string' && configured !== '' && path.win32.isAbsolute(configured)
32
+ ? configured
33
+ : 'C:\\Windows';
34
+ }
35
+
36
+ export const SC_EXE_PATH = path.win32.join(systemRoot(), 'System32', 'sc.exe');
37
+
38
+ const ERROR_ACCESS_DENIED = 5;
39
+ const ERROR_SERVICE_DOES_NOT_EXIST = 1060;
40
+ const ERROR_SERVICE_NOT_ACTIVE = 1062;
41
+ const ERROR_SERVICE_ALREADY_RUNNING = 1056;
42
+ const ERROR_SERVICE_EXISTS = 1073;
43
+ const ERROR_SERVICE_MARKED_FOR_DELETE = 1072;
44
+
45
+ // SCM service states (SERVICE_STATUS.dwCurrentState) that mean the service
46
+ // owns (or is acquiring) a live process right now.
47
+ const LIVE_STATES = new Set([2 /* START_PENDING */, 4 /* RUNNING */, 5 /* CONTINUE_PENDING */]);
48
+ const STOPPED_STATE = 1;
49
+
50
+ export function scExe(args) {
51
+ const result = spawnSync(SC_EXE_PATH, args, { encoding: 'utf8', windowsHide: true });
52
+ return {
53
+ action: args[0],
54
+ status: result.status,
55
+ stdout: result.stdout,
56
+ stderr: result.stderr,
57
+ error: result.error,
58
+ };
59
+ }
60
+
61
+ function controlFor(options) {
62
+ return options.sc || scExe;
63
+ }
64
+
65
+ function runSc(control, args) {
66
+ let result;
67
+ try {
68
+ result = control(args);
69
+ } catch (error) {
70
+ if (error instanceof CliError) throw error;
71
+ throw new CliError('DAEMON_CONTROL_FAILED', `sc.exe ${args[0]} is unavailable: ${error.message}`);
72
+ }
73
+ if (result?.skipped) return result;
74
+ if (!result || typeof result !== 'object') {
75
+ throw new CliError('DAEMON_CONTROL_FAILED', `sc.exe ${args[0]} returned an invalid control result`);
76
+ }
77
+ if (result.error) {
78
+ throw new CliError('DAEMON_CONTROL_FAILED', `sc.exe ${args[0]} is unavailable: ${result.error.message || result.error}`);
79
+ }
80
+ if (typeof result.status !== 'number') {
81
+ throw new CliError('DAEMON_CONTROL_FAILED', `sc.exe ${args[0]} returned an invalid control result`);
82
+ }
83
+ return result;
84
+ }
85
+
86
+ // sc.exe emits localized text on some hosts; the ASCII projection keeps an
87
+ // actionable English fragment and never leaks mojibake into the error message.
88
+ function asciiDetail(result) {
89
+ const text = `${result?.stderr || ''}${result?.stdout || ''}`
90
+ .split(/\r?\n/u)
91
+ .map((line) => line.replace(/[^\x20-\x7e]/gu, '').trim())
92
+ .filter(Boolean)
93
+ .join(' ');
94
+ return text.length > 0 ? `: ${text.slice(0, 400)}` : '';
95
+ }
96
+
97
+ function scFailure(args, result) {
98
+ if (result.status === ERROR_ACCESS_DENIED) {
99
+ return new CliError(
100
+ 'SERVICE_ACCESS_DENIED',
101
+ `sc.exe ${args[0]} was denied access (exit code 5)${asciiDetail(result)}; registering, starting, stopping, or deleting a Windows service requires an elevated (Administrator) session`,
102
+ );
103
+ }
104
+ return new CliError('DAEMON_CONTROL_FAILED', `sc.exe ${args[0]} failed with exit code ${result.status}${asciiDetail(result)}`);
105
+ }
106
+
107
+ function parseServiceQuery(stdout) {
108
+ const text = String(stdout || '');
109
+ const state = text.match(/STATE\s*:\s*(\d+)\s+([A-Z_]+)/iu);
110
+ const pid = text.match(/\bPID\s*:\s*(\d+)/iu);
111
+ const stateCode = state ? Number(state[1]) : null;
112
+ const processId = pid ? Number(pid[1]) : 0;
113
+ return {
114
+ state: state ? state[2].toLowerCase() : null,
115
+ state_code: Number.isInteger(stateCode) ? stateCode : null,
116
+ pid: Number.isInteger(processId) && processId > 0 ? processId : null,
117
+ };
118
+ }
119
+
120
+ function queryService(control) {
121
+ const args = ['queryex', WINDOWS_SERVICE_NAME];
122
+ const result = runSc(control, args);
123
+ if (result.skipped) return { skipped: true, reason: result.reason || 'sc.exe neutralized by test seam' };
124
+ if (result.status === ERROR_SERVICE_DOES_NOT_EXIST) return { registered: false };
125
+ if (result.status === ERROR_ACCESS_DENIED) {
126
+ return { unavailable: true, reason: scFailure(args, result).message };
127
+ }
128
+ if (result.status !== 0) throw scFailure(args, result);
129
+ return { registered: true, ...parseServiceQuery(result.stdout) };
130
+ }
131
+
132
+ function isLive(view) {
133
+ return view.registered === true && LIVE_STATES.has(view.state_code);
134
+ }
135
+
136
+ function quoteServiceArgument(value, label) {
137
+ if (typeof value !== 'string' || value.length === 0) {
138
+ throw new CliError('SERVICE_DEFINITION_INVALID', `${label} must be a non-empty string`);
139
+ }
140
+ if (/["\r\n]/u.test(value)) {
141
+ throw new CliError('SERVICE_DEFINITION_INVALID', `${label} cannot contain a quote, carriage return, or line feed; the Windows service command line cannot represent it`);
142
+ }
143
+ return `"${value}"`;
144
+ }
145
+
146
+ // The verified executable lives in the packaged payload once win32 support is
147
+ // activated; a development checkout falls back to the host cargo output so the
148
+ // library-level lifecycle is exercisable on a real machine before activation.
149
+ function resolveDaemonPath(options = {}) {
150
+ if (typeof options.daemonPath === 'string' && options.daemonPath !== '') return options.daemonPath;
151
+ const tuple = `win32-${process.arch}`;
152
+ const packaged = path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, tuple, `${DAEMON_BIN_NAME}.exe`);
153
+ if (fs.existsSync(packaged)) return packaged;
154
+ const development = path.join(packageRoot(), 'target', 'debug', `${DAEMON_BIN_NAME}.exe`);
155
+ if (fs.existsSync(development)) return development;
156
+ return packaged;
157
+ }
158
+
159
+ function resolvedPaths(paths) {
160
+ if (paths && typeof paths.database === 'string' && typeof paths.socket === 'string') return paths;
161
+ return productPaths();
162
+ }
163
+
164
+ // The service command line SCM stores in BINARY_PATH_NAME. The executable and
165
+ // every argument value are quoted; a path containing a quote or newline is
166
+ // refused instead of producing an ambiguous service definition.
167
+ export function windowsServiceBinPath(paths, options = {}) {
168
+ const daemon = resolveDaemonPath(options);
169
+ return [
170
+ quoteServiceArgument(daemon, 'daemon executable'),
171
+ '--database',
172
+ quoteServiceArgument(paths.database, '--database path'),
173
+ '--socket',
174
+ quoteServiceArgument(paths.socket, '--socket name'),
175
+ ].join(' ');
176
+ }
177
+
178
+ // Read-only registration view for `status`. A missing service is registered
179
+ // false; an access-denied probe degrades to an explicit unavailable view (the
180
+ // same shape the systemd backend uses without a user session) instead of
181
+ // failing the whole read-only command.
182
+ export function windowsServiceRegistrationStatus(options = {}) {
183
+ const view = queryService(controlFor(options));
184
+ if (view.skipped) return { query: 'skipped', registered: null, reason: view.reason };
185
+ if (view.unavailable) return { query: 'unavailable', registered: null, reason: view.reason };
186
+ if (!view.registered) return { query: 'available', registered: false };
187
+ return {
188
+ query: 'available',
189
+ registered: true,
190
+ service: WINDOWS_SERVICE_NAME,
191
+ state: view.state,
192
+ pid: view.pid,
193
+ };
194
+ }
195
+
196
+ // Windows has no file-based service definition: the SCM registration created
197
+ // by bootstrap IS the definition. This step therefore renders and validates
198
+ // the exact command line that bootstrap will register, so init can fail before
199
+ // touching the SCM when a path cannot be represented.
200
+ export function installServiceDefinitionWindows(paths, options = {}) {
201
+ const resolved = resolvedPaths(paths);
202
+ return {
203
+ installed: true,
204
+ backend: 'scm',
205
+ service: WINDOWS_SERVICE_NAME,
206
+ bin_path: windowsServiceBinPath(resolved, options),
207
+ start_type: 'auto',
208
+ };
209
+ }
210
+
211
+ function startService(control, existing, extra = {}) {
212
+ if (isLive(existing)) {
213
+ return {
214
+ action: 'bootstrap', service: WINDOWS_SERVICE_NAME, already_loaded: true,
215
+ state: existing.state ?? null, pid: existing.pid ?? null, ...extra,
216
+ };
217
+ }
218
+ const start = runSc(control, ['start', WINDOWS_SERVICE_NAME]);
219
+ if (start.status === ERROR_SERVICE_ALREADY_RUNNING) {
220
+ return { action: 'bootstrap', service: WINDOWS_SERVICE_NAME, already_loaded: true, ...extra };
221
+ }
222
+ if (start.status !== 0) {
223
+ // A definition this call just created must not survive a failed start:
224
+ // init rollback cannot otherwise discover a half-created service.
225
+ if (extra.created) {
226
+ try { runSc(control, ['delete', WINDOWS_SERVICE_NAME]); } catch { /* best-effort self-clean */ }
227
+ }
228
+ throw scFailure(['start', WINDOWS_SERVICE_NAME], start);
229
+ }
230
+ const settled = queryService(control);
231
+ return {
232
+ action: 'bootstrap',
233
+ service: WINDOWS_SERVICE_NAME,
234
+ created: Boolean(extra.created),
235
+ started: true,
236
+ state: settled.skipped || !settled.registered ? null : settled.state,
237
+ pid: settled.skipped || !settled.registered ? null : settled.pid,
238
+ };
239
+ }
240
+
241
+ export function bootstrapServiceWindows(paths, options = {}) {
242
+ const control = controlFor(options);
243
+ const resolved = resolvedPaths(paths);
244
+ const existing = queryService(control);
245
+ if (existing.skipped) {
246
+ return { action: 'bootstrap', service: WINDOWS_SERVICE_NAME, skipped: true, reason: existing.reason };
247
+ }
248
+ if (existing.unavailable) throw new CliError('SERVICE_ACCESS_DENIED', existing.reason);
249
+ if (existing.registered) return startService(control, existing);
250
+
251
+ const daemon = resolveDaemonPath(options);
252
+ if (!fs.existsSync(daemon)) {
253
+ throw new CliError('PAYLOAD_FILE_MISSING', `the daemon executable is missing: ${daemon}`);
254
+ }
255
+ const binPath = windowsServiceBinPath(resolved, { ...options, daemonPath: daemon });
256
+ const create = runSc(control, ['create', WINDOWS_SERVICE_NAME, 'binPath=', binPath, 'start=', 'auto']);
257
+ if (create.status === ERROR_SERVICE_EXISTS) {
258
+ const settled = queryService(control);
259
+ if (settled.skipped) {
260
+ return { action: 'bootstrap', service: WINDOWS_SERVICE_NAME, skipped: true, reason: settled.reason };
261
+ }
262
+ if (settled.unavailable) throw new CliError('SERVICE_ACCESS_DENIED', settled.reason);
263
+ if (settled.registered) return startService(control, settled);
264
+ throw scFailure(['create', WINDOWS_SERVICE_NAME], create);
265
+ }
266
+ if (create.status !== 0) throw scFailure(['create', WINDOWS_SERVICE_NAME], create);
267
+ return startService(control, { registered: true, state_code: null }, { created: true });
268
+ }
269
+
270
+ function waitForStopped(control, timeoutMs) {
271
+ const deadline = Date.now() + timeoutMs;
272
+ for (;;) {
273
+ const probe = queryService(control);
274
+ if (probe.skipped || !probe.registered || probe.state_code === STOPPED_STATE) return;
275
+ if (Date.now() >= deadline) {
276
+ throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'Windows service remained active after sc.exe stop');
277
+ }
278
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
279
+ }
280
+ }
281
+
282
+ export function bootoutServiceWindows(paths, options = {}) {
283
+ const control = controlFor(options);
284
+ const resolved = resolvedPaths(paths);
285
+ const existing = queryService(control);
286
+ if (existing.skipped) {
287
+ return { action: 'bootout', service: WINDOWS_SERVICE_NAME, already_stopped: true, skipped: true, reason: existing.reason };
288
+ }
289
+ if (existing.unavailable) throw new CliError('SERVICE_ACCESS_DENIED', existing.reason);
290
+ if (!existing.registered) {
291
+ return { action: 'bootout', service: WINDOWS_SERVICE_NAME, already_stopped: true };
292
+ }
293
+ if (existing.state_code !== STOPPED_STATE) {
294
+ const stop = runSc(control, ['stop', WINDOWS_SERVICE_NAME]);
295
+ if (stop.status !== 0
296
+ && stop.status !== ERROR_SERVICE_NOT_ACTIVE
297
+ && stop.status !== ERROR_SERVICE_DOES_NOT_EXIST) {
298
+ throw scFailure(['stop', WINDOWS_SERVICE_NAME], stop);
299
+ }
300
+ waitForStopped(control, options.unloadTimeoutMs ?? 10_000);
301
+ }
302
+ const removed = runSc(control, ['delete', WINDOWS_SERVICE_NAME]);
303
+ if (removed.status === ERROR_SERVICE_DOES_NOT_EXIST || removed.status === ERROR_SERVICE_MARKED_FOR_DELETE) {
304
+ return { action: 'bootout', service: WINDOWS_SERVICE_NAME, removed: true, socket_removed: !fs.existsSync(resolved.socket) };
305
+ }
306
+ if (removed.status !== 0) throw scFailure(['delete', WINDOWS_SERVICE_NAME], removed);
307
+ return { action: 'bootout', service: WINDOWS_SERVICE_NAME, removed: true, socket_removed: !fs.existsSync(resolved.socket) };
308
+ }
309
+
310
+ function serviceConfiguration(control) {
311
+ const result = runSc(control, ['qc', WINDOWS_SERVICE_NAME]);
312
+ if (result.skipped) return { skipped: true, reason: result.reason || 'sc.exe neutralized by test seam' };
313
+ if (result.status === ERROR_SERVICE_DOES_NOT_EXIST) return { registered: false };
314
+ if (result.status !== 0) throw scFailure(['qc', WINDOWS_SERVICE_NAME], result);
315
+ const match = String(result.stdout || '').match(/^\s*BINARY_PATH_NAME\s*:\s*(.*)$/imu);
316
+ return { registered: true, bin_path: match ? match[1].trim() : null };
317
+ }
318
+
319
+ // The executable SCM will launch — the Windows twin of the plist's first
320
+ // ProgramArguments entry and the systemd unit's ExecStart program.
321
+ function firstServiceProgram(binPath) {
322
+ if (typeof binPath !== 'string' || binPath.trim() === '') return null;
323
+ const text = binPath.trim();
324
+ if (text.startsWith('"')) {
325
+ const end = text.indexOf('"', 1);
326
+ return end > 1 ? text.slice(1, end) : null;
327
+ }
328
+ const end = text.search(/\s/u);
329
+ return (end === -1 ? text : text.slice(0, end)) || null;
330
+ }
331
+
332
+ // Activation health/rollback over SCM: the same drain/claim prelude, the same
333
+ // "a NEW process runs the selected artifact's exact bytes and self-reports its
334
+ // identity" verification, and the same rollback-to-retained-bytes contract as
335
+ // the launchd and systemd backends. unload = stop + bounded STOPPED
336
+ // confirmation; load = sc.exe start; the definition update is sc.exe config
337
+ // on BINARY_PATH_NAME.
338
+ export async function activateServiceWindows(paths, candidate, options = {}) {
339
+ const control = controlFor(options);
340
+ const rpc = options.callDaemon || callDaemon;
341
+ const configuration = serviceConfiguration(control);
342
+ if (configuration.skipped) throw new CliError('SERVICE_NOT_VERIFIED', 'sc.exe control was skipped');
343
+ if (!configuration.registered) throw new CliError('SERVICE_NOT_INSTALLED', 'the Windows service is not registered');
344
+ const oldPath = firstServiceProgram(configuration.bin_path);
345
+ if (!oldPath) throw new CliError('SERVICE_DEFINITION_INVALID', 'installed service has no executable entry');
346
+
347
+ const servicePid = async () => {
348
+ const view = queryService(control);
349
+ if (view.skipped) throw new CliError('SERVICE_NOT_VERIFIED', 'sc.exe control was skipped');
350
+ if (view.unavailable) throw new CliError('SERVICE_ACCESS_DENIED', view.reason);
351
+ if (!view.registered) return null;
352
+ return isLive(view) ? view.pid : null;
353
+ };
354
+ const oldPid = await servicePid();
355
+ const oldStatus = oldPid ? await rpc(paths.socket, 'status', {}) : null;
356
+ // The running daemon's self-reported digest is the truth about which bytes
357
+ // the service was executing; the file at oldPath may already carry the NEXT
358
+ // version after an npm install replaced the package directory in place.
359
+ const runningArtifact = oldStatus?.identity?.daemon?.artifact;
360
+ const oldSha = (typeof runningArtifact?.sha256 === 'string' && runningArtifact.sha256)
361
+ || (fs.existsSync(oldPath) ? sha256(fs.readFileSync(oldPath)) : null);
362
+ if (oldPid) {
363
+ const drain = await rpc(paths.socket, 'drain-status', {});
364
+ if (!drain.ready_for_activation) throw new CliError('DAEMON_NOT_DRAINED', 'daemon still owns active or unreaped work');
365
+ const claim = await rpc(paths.socket, 'activate-ready', {});
366
+ if (!claim.activation_claim) throw new CliError('ACTIVATION_NOT_CLAIMED', 'daemon did not grant activation');
367
+ }
368
+ const rollbackExecutable = () => {
369
+ if (oldSha && fs.existsSync(oldPath) && sha256(fs.readFileSync(oldPath)) === oldSha) {
370
+ return { path: oldPath, sha256: oldSha };
371
+ }
372
+ const retained = options.rollbackPayload;
373
+ if (retained && typeof retained.path === 'string' && typeof retained.sha256 === 'string'
374
+ && fs.existsSync(retained.path) && sha256(fs.readFileSync(retained.path)) === retained.sha256) {
375
+ return { path: retained.path, sha256: retained.sha256 };
376
+ }
377
+ throw new CliError('ROLLBACK_PAYLOAD_UNAVAILABLE', 'previous payload bytes are unavailable; automatic rollback refused');
378
+ };
379
+ const healthy = async (expected, previousPid, previousGeneration) => {
380
+ const deadline = Date.now() + (options.healthTimeoutMs ?? 10_000);
381
+ let last;
382
+ do {
383
+ try {
384
+ const pid = await servicePid();
385
+ if (!pid || pid === previousPid) throw new Error('service has no new process');
386
+ const status = await rpc(paths.socket, 'status', {});
387
+ const identity = status.identity?.daemon;
388
+ // Health verification uses the service generation and the running
389
+ // artifact identity. The diagnostic-only mcp_version never gates
390
+ // admission.
391
+ if (!status.service_generation || status.service_generation === previousGeneration
392
+ || identity?.artifact?.path !== expected.path || identity?.artifact?.sha256 !== expected.sha256
393
+ || (expected.version && identity?.version !== expected.version)) throw new Error('running daemon identity does not match selected payload');
394
+ if (await servicePid() !== pid) throw new Error('service process changed during health verification');
395
+ return { pid, version: identity.version, artifact: identity.artifact, service_generation: status.service_generation };
396
+ } catch (error) { last = error; }
397
+ await new Promise((resolve) => setTimeout(resolve, 50));
398
+ } while (Date.now() < deadline);
399
+ throw new CliError('SERVICE_HEALTH_FAILED', last?.message || 'service did not become healthy');
400
+ };
401
+ const unload = async () => {
402
+ const view = queryService(control);
403
+ if (view.skipped) return;
404
+ if (view.unavailable) throw new CliError('SERVICE_ACCESS_DENIED', view.reason);
405
+ if (!view.registered || view.state_code === STOPPED_STATE) return;
406
+ const stop = runSc(control, ['stop', WINDOWS_SERVICE_NAME]);
407
+ if (stop.status !== 0 && stop.status !== ERROR_SERVICE_NOT_ACTIVE && stop.status !== ERROR_SERVICE_DOES_NOT_EXIST) {
408
+ throw scFailure(['stop', WINDOWS_SERVICE_NAME], stop);
409
+ }
410
+ waitForStopped(control, options.unloadTimeoutMs ?? 10_000);
411
+ };
412
+ const configure = (entryPath) => {
413
+ const next = windowsServiceBinPath(paths, { ...options, daemonPath: entryPath });
414
+ const result = runSc(control, ['config', WINDOWS_SERVICE_NAME, 'binPath=', next]);
415
+ if (result.status !== 0) throw scFailure(['config', WINDOWS_SERVICE_NAME], result);
416
+ return next;
417
+ };
418
+ const restoreDefinition = (entryPath) => configure(entryPath);
419
+ const load = async () => {
420
+ const start = runSc(control, ['start', WINDOWS_SERVICE_NAME]);
421
+ if (start.status !== 0 && start.status !== ERROR_SERVICE_ALREADY_RUNNING) {
422
+ throw scFailure(['start', WINDOWS_SERVICE_NAME], start);
423
+ }
424
+ };
425
+ const rollbackService = async () => {
426
+ await unload();
427
+ const previous = rollbackExecutable();
428
+ restoreDefinition(previous.path);
429
+ await load();
430
+ return healthy({ path: previous.path, sha256: previous.sha256, version: oldStatus?.identity?.daemon?.version }, oldPid, oldStatus?.service_generation);
431
+ };
432
+ try {
433
+ await unload();
434
+ configure(candidate.path);
435
+ await load();
436
+ const health = await healthy(candidate, oldPid, oldStatus?.service_generation);
437
+ return {
438
+ ...health,
439
+ rollback: async () => {
440
+ const restored = await rollbackService();
441
+ if (oldSha && fs.existsSync(oldPath) && sha256(fs.readFileSync(oldPath)) !== oldSha) {
442
+ return { ...restored, restored_from: 'retained_payload' };
443
+ }
444
+ return restored;
445
+ },
446
+ };
447
+ } catch (error) {
448
+ if (!oldPid) {
449
+ try {
450
+ await unload();
451
+ restoreDefinition(oldPath);
452
+ error.rollback = { attempted: true, restored: false, definition_restored: true, error: 'no previous service identity to verify' };
453
+ } catch (rollbackError) {
454
+ error.rollback = { attempted: true, restored: false, definition_restored: false, error: rollbackError.message };
455
+ }
456
+ throw error;
457
+ }
458
+ try {
459
+ error.rollback = await rollbackService();
460
+ } catch (rollbackError) { error.rollbackError = rollbackError.message; }
461
+ throw error;
462
+ }
463
+ }
@@ -5,7 +5,7 @@ import { atomicWrite, jsonBytes, sha256 } from '../fs-atomic.mjs';
5
5
  import { CliError } from '../errors.mjs';
6
6
  import { CLI_ENTRY_NAME, DAEMON_BIN_NAME, NATIVE_DIR_NAME } from '../constants.mjs';
7
7
  import { reconcileCodexHomes } from './reconcile.mjs';
8
- import { packageVersion, packageRoot } from './layout.mjs';
8
+ import { nativeExecutableName, packageVersion, packageRoot } from './layout.mjs';
9
9
  import { loadUpdateState } from './recovery.mjs';
10
10
  import { normalizePayloadFiles, verifyPayload } from './payload.mjs';
11
11
 
@@ -55,7 +55,7 @@ function retainPayload(paths, candidateRoot, verified) {
55
55
  root,
56
56
  entry: path.join(root, CLI_ENTRY_NAME),
57
57
  entry_sha256: verified.stable.digest,
58
- daemon_entry: path.join(root, DAEMON_BIN_NAME),
58
+ daemon_entry: path.join(root, nativeExecutableName(DAEMON_BIN_NAME, verified.payload.platform)),
59
59
  daemon_entry_sha256: verified.daemon.digest,
60
60
  };
61
61
  for (const [file, digest] of [[retained.daemon_entry, verified.daemon.digest], [retained.entry, verified.stable.digest]]) {
@@ -72,7 +72,7 @@ function verifyStableEntry(candidateRoot) {
72
72
  let stat;
73
73
  try { stat = fs.lstatSync(entry); } catch { throw new CliError('PAYLOAD_ENTRY_MISSING', 'candidate stable entry is missing'); }
74
74
  if (!stat.isFile() || stat.isSymbolicLink()) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry must be a regular file');
75
- if ((stat.mode & 0o111) === 0) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry must be executable');
75
+ if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry must be executable');
76
76
  const resolved = fs.realpathSync(entry);
77
77
  if (resolved !== entry || !resolved.startsWith(`${root}${path.sep}`)) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate stable entry escapes candidate root');
78
78
  const digest = crypto.createHash('sha256').update(fs.readFileSync(entry)).digest('hex');
@@ -83,14 +83,15 @@ function verifyStableEntry(candidateRoot) {
83
83
  // activation identity is the external-subagentd payload artifact whose digest
84
84
  // verifyPayload already checked against the release manifest.
85
85
  function verifyDaemonArtifact(candidateRoot, payload) {
86
- const record = payload.files.find((file) => file.name === DAEMON_BIN_NAME);
86
+ const record = payload.files.find((file) => file.name === nativeExecutableName(DAEMON_BIN_NAME, payload.platform));
87
87
  if (!record) throw new CliError('PAYLOAD_DAEMON_MISSING', 'candidate payload does not carry the daemon artifact');
88
88
  const root = fs.realpathSync(candidateRoot);
89
89
  const entry = path.join(root, 'npm', NATIVE_DIR_NAME, payload.platform, record.name);
90
90
  let stat;
91
91
  try { stat = fs.lstatSync(entry); } catch { throw new CliError('PAYLOAD_DAEMON_MISSING', 'candidate daemon artifact is missing'); }
92
92
  if (!stat.isFile() || stat.isSymbolicLink()) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate daemon artifact must be a regular file');
93
- if ((stat.mode & 0o111) === 0) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate daemon artifact must be executable');
93
+ // Windows has no POSIX execute bit (the PE loader ignores one).
94
+ if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) throw new CliError('PAYLOAD_ENTRY_INVALID', 'candidate daemon artifact must be executable');
94
95
  const digest = crypto.createHash('sha256').update(fs.readFileSync(entry)).digest('hex');
95
96
  if (digest !== record.sha256) throw new CliError('PAYLOAD_DIGEST_MISMATCH', 'candidate daemon artifact does not match the verified payload digest');
96
97
  return { entry, digest };
package/cli/main.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { BUSINESS_COMMANDS, PRODUCT_NAME, VERSION, zcodeRuntimePath } from './constants.mjs';
5
5
  import { CliError } from './errors.mjs';
6
- import { installHooks, installPlan, runInit } from './install/init.mjs';
6
+ import { installPlan, runInit } from './install/init.mjs';
7
7
  import { nativeBinary } from './install/layout.mjs';
8
8
  import { purge, restoreData, backupData, uninstall } from './commands/maintenance.mjs';
9
9
  import { updateCommand } from './commands/update.mjs';
@@ -17,12 +17,13 @@ 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
- // 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']);
20
+ // Hosts that ship a supported service backend and payload: macOS (launchd),
21
+ // Linux (systemd user services), and Windows (SCM + Named Pipe). The gate is
22
+ // structural, so any other platform is rejected before product paths are
23
+ // resolved or written.
24
+ const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'win32']);
24
25
 
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`;
26
+ 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] [--skip-service-start]\n Install the standalone daemon service only; bind a\n host afterwards with install-plugin or install-mcp\n install-plugin [codex|zcode|dsh] [--dry-run|--uninstall] [--codex-home <path>]\n [--profile <name> [--dsh-home <path>]]\n Install or remove the managed host plugin (MCP + skill);\n the host defaults to codex, --codex-home is codex-only,\n --profile/--dsh-home are dsh-only (the dsh host appends\n one managed mcp-client entry to the profile's\n cordis.patch.yml under $DSH_HOME/profiles)\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/DSH bindings; remove service registration; retain data\n purge --yes Explicitly delete new product data\n`;
26
27
  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`;
27
28
 
28
29
  function structuredInput(args, parser) {
@@ -341,10 +342,10 @@ export async function main(args) {
341
342
  process.stdout.write(`${VERSION}\n`); return;
342
343
  }
343
344
  if (command !== 'profile' && !BUSINESS_COMMANDS.has(command)) throw new CliError('UNKNOWN_COMMAND', `unknown command: ${command}`, 2);
344
- // macOS and Linux are supported hosts; every other platform (Windows, BSD,
345
+ // macOS, Linux, and Windows are supported hosts; every other platform (BSD,
345
346
  // an unknown tuple) is refused structurally before any filesystem side
346
347
  // 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`);
348
+ if (!SUPPORTED_PLATFORMS.has(platform())) throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS, Linux, and Windows`);
348
349
 
349
350
  const paths = productPaths();
350
351
  if (command === 'init') {
@@ -354,7 +355,7 @@ export async function main(args) {
354
355
  // no Codex host, and a host home is chosen by the explicit install
355
356
  // commands instead.
356
357
  const flags = args.slice(1);
357
- const known = ['--dry-run', '--resume', '--install-hooks', '--skip-service-start'];
358
+ const known = ['--dry-run', '--resume', '--skip-service-start'];
358
359
  for (let index = 0; index < flags.length; index += 1) {
359
360
  if (!known.includes(flags[index])) {
360
361
  if (flags[index] === '--skip-runtime-probe' || flags[index] === '--skip-codex-plugin' || flags[index] === '--codex-home') {
@@ -367,15 +368,10 @@ export async function main(args) {
367
368
  paths,
368
369
  dryRun: flags.includes('--dry-run'),
369
370
  resume: flags.includes('--resume'),
370
- installHooks: flags.includes('--install-hooks'),
371
371
  skipServiceStart: flags.includes('--skip-service-start'),
372
372
  }));
373
373
  return;
374
374
  }
375
- if (command === 'hooks') {
376
- if (args[1] !== 'install') throw new CliError('INVALID_ARGUMENT', 'usage: hooks install [--dry-run]', 2);
377
- output(installHooks(paths, { dryRun: args.includes('--dry-run') })); return;
378
- }
379
375
  if (command === 'install-plugin') {
380
376
  output(pluginCommand(paths, args.slice(1))); return;
381
377
  }
@@ -64,6 +64,9 @@ export function restoreData(source, paths = productPaths()) {
64
64
  }
65
65
 
66
66
  function removeOne(target) {
67
+ // Windows has no file-based service definition (paths.launchAgent is null):
68
+ // the SCM registration is the definition and bootout already removed it.
69
+ if (typeof target !== 'string' || target === '') return false;
67
70
  try {
68
71
  const stat = fs.lstatSync(target);
69
72
  if (stat.isDirectory() && !stat.isSymbolicLink()) fs.rmSync(target, { recursive: true });
@@ -88,7 +91,10 @@ function removeOne(target) {
88
91
  // Linux reports the same removal as `removed_service_definition`.
89
92
  export function uninstall(paths = productPaths(), options = {}) {
90
93
  const macos = platform() === 'darwin';
91
- const service = bootoutService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl });
94
+ // Windows has no process.getuid(); every backend treats an undefined uid as
95
+ // "not applicable", so resolve it only where the host defines it.
96
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
97
+ const service = bootoutService(paths, currentUid, { launchctl: options.launchctl, systemctl: options.systemctl, sc: options.sc });
92
98
  const removed = removeOne(paths.launchAgent);
93
99
  return {
94
100
  service_stopped: true,