external-subagent 0.5.1 → 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.
- package/README.md +38 -7
- package/bin/external-subagent-mcp.mjs +3 -2
- package/cli/commands/agents.mjs +4 -9
- package/cli/commands/config.mjs +1 -1
- package/cli/commands/daemon.mjs +8 -3
- package/cli/commands/maintenance.mjs +158 -1
- package/cli/commands/plugin.mjs +28 -3
- package/cli/commands/tasks.mjs +7 -1
- package/cli/commands/update.mjs +18 -7
- package/cli/config/write.mjs +195 -14
- package/cli/constants.mjs +2 -2
- package/cli/install/codex.mjs +1 -1
- package/cli/install/dsh.mjs +606 -0
- package/cli/install/init.mjs +23 -33
- package/cli/install/layout.mjs +10 -2
- package/cli/install/payload.mjs +59 -12
- package/cli/install/service-activation.mjs +10 -2
- package/cli/install/service-linux.mjs +1 -1
- package/cli/install/service-macos.mjs +40 -14
- package/cli/install/service-windows.mjs +463 -0
- package/cli/install/update.mjs +6 -5
- package/cli/main.mjs +10 -14
- package/cli/maintenance.mjs +7 -1
- package/cli/paths.mjs +152 -8
- package/npm/native/README.md +2 -2
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +5 -5
- package/npm/native/linux-x64/external-subagent-mcp +0 -0
- package/npm/native/linux-x64/external-subagentd +0 -0
- package/npm/native/linux-x64/payload.json +5 -5
- package/package.json +3 -2
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +5 -3
- package/schema/external-subagent-public-api.json +3 -3
- package/scripts/release/build-native-payload.mjs +22 -5
- package/scripts/release/check-native-tarball.mjs +34 -14
- package/scripts/release/stage-debug-plugin.mjs +9 -3
- package/scripts/release/test-installed-tarball.mjs +1 -1
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +0 -55
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +0 -19
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +0 -15
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +0 -206
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +0 -149
- 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
|
+
}
|
package/cli/install/update.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
22
|
-
// platform is rejected before product paths are
|
|
23
|
-
|
|
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] [--
|
|
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
|
|
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
|
|
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', '--
|
|
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
|
}
|
package/cli/maintenance.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|