external-subagent 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -19
- package/bin/external-subagent-debug-mcp.mjs +2 -1
- package/bin/external-subagent-mcp.mjs +36 -11
- package/cli/commands/config.mjs +6 -1
- package/cli/commands/daemon.mjs +12 -2
- package/cli/commands/tasks.mjs +9 -0
- package/cli/config/write.mjs +21 -3
- package/cli/constants.mjs +35 -1
- package/cli/install/init.mjs +30 -11
- package/cli/install/layout.mjs +11 -4
- package/cli/install/path.mjs +35 -10
- package/cli/install/payload.mjs +86 -6
- package/cli/install/service-activation.mjs +133 -1
- package/cli/install/service-linux.mjs +327 -0
- package/cli/install/service-macos.mjs +59 -18
- package/cli/install/update.mjs +7 -1
- package/cli/main.mjs +25 -11
- package/cli/maintenance.mjs +16 -11
- package/cli/paths.mjs +57 -8
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +4 -4
- package/npm/native/linux-x64/external-subagent-mcp +0 -0
- package/npm/native/linux-x64/external-subagentd +0 -0
- package/npm/native/linux-x64/payload.json +20 -0
- package/package.json +6 -3
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +1 -1
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +16 -2
- package/scripts/release/build-native-payload.mjs +18 -6
- package/scripts/release/check-native-tarball.mjs +99 -31
- package/scripts/release/test-installed-tarball.mjs +6 -1
package/cli/install/payload.mjs
CHANGED
|
@@ -4,11 +4,25 @@ import { CliError } from '../errors.mjs';
|
|
|
4
4
|
import { sha256 } from '../fs-atomic.mjs';
|
|
5
5
|
import { NATIVE_DIR_NAME, PRODUCT_NAME } from '../constants.mjs';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
NATIVE_PLATFORMS, cliVersion, nativePayloadDir, nativePlatform, packageVersion, payloadManifestPath,
|
|
8
8
|
} from './layout.mjs';
|
|
9
9
|
|
|
10
10
|
const MH_MAGIC_64 = 0xfeedfacf;
|
|
11
11
|
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
12
|
+
// The single executable mode every released payload file must carry.
|
|
13
|
+
const RELEASE_FILE_MODE = 0o755;
|
|
14
|
+
|
|
15
|
+
// ELF64 header identity, read little-endian:
|
|
16
|
+
// e_ident[0..3] = 0x7f 'E' 'L' 'F' (0x464c457f as a LE uint32)
|
|
17
|
+
// e_ident[4] = EI_CLASS (2 = ELFCLASS64)
|
|
18
|
+
// e_ident[5] = EI_DATA (1 = ELFDATA2LSB)
|
|
19
|
+
// e_machine = uint16 at offset 18 (0x3e = EM_X86_64)
|
|
20
|
+
const ELF_MAGIC = 0x464c457f;
|
|
21
|
+
const ELFCLASS64 = 2;
|
|
22
|
+
const ELFDATA2LSB = 1;
|
|
23
|
+
const EM_X86_64 = 0x3e;
|
|
24
|
+
const ELF_E_MACHINE_OFFSET = 18;
|
|
25
|
+
const ELF_HEADER_MIN_BYTES = ELF_E_MACHINE_OFFSET + 2;
|
|
12
26
|
|
|
13
27
|
export function machoArch(bytes) {
|
|
14
28
|
if (bytes.length < 8) return null;
|
|
@@ -16,6 +30,25 @@ export function machoArch(bytes) {
|
|
|
16
30
|
return bytes.readUInt32LE(4) === CPU_TYPE_ARM64 ? 'arm64' : null;
|
|
17
31
|
}
|
|
18
32
|
|
|
33
|
+
// The Linux image check mirrors machoArch: only a 64-bit little-endian x86-64
|
|
34
|
+
// ELF is a supported payload. A truncated header (shorter than e_machine), a
|
|
35
|
+
// 32-bit or big-endian image, or any other machine returns null and is
|
|
36
|
+
// rejected as an unsupported architecture.
|
|
37
|
+
export function elfArch(bytes) {
|
|
38
|
+
if (bytes.length < ELF_HEADER_MIN_BYTES) return null;
|
|
39
|
+
if (bytes.readUInt32LE(0) !== ELF_MAGIC) return null;
|
|
40
|
+
if (bytes[4] !== ELFCLASS64 || bytes[5] !== ELFDATA2LSB) return null;
|
|
41
|
+
return bytes.readUInt16LE(ELF_E_MACHINE_OFFSET) === EM_X86_64 ? 'x64' : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Each supported platform has exactly one image identity. verifyPayload reads
|
|
45
|
+
// the tuple once and uses the paired probe, so a valid image of the *other*
|
|
46
|
+
// platform can never satisfy this one.
|
|
47
|
+
const IMAGE_CHECKS = Object.freeze({
|
|
48
|
+
'darwin-arm64': { arch: 'arm64', image: 'Mach-O arm64 image', probe: machoArch },
|
|
49
|
+
'linux-x64': { arch: 'x64', image: 'ELF x86-64 image', probe: elfArch },
|
|
50
|
+
});
|
|
51
|
+
|
|
19
52
|
export function readPayloadManifest(options = {}) {
|
|
20
53
|
const platform = options.platform || nativePlatform();
|
|
21
54
|
const root = options.root || undefined;
|
|
@@ -41,9 +74,10 @@ export function readPayloadManifest(options = {}) {
|
|
|
41
74
|
// defect, never a silent downgrade path.
|
|
42
75
|
export function verifyPayload(options = {}) {
|
|
43
76
|
const platform = options.platform || nativePlatform();
|
|
44
|
-
if (platform
|
|
45
|
-
throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${process.platform}-${process.arch}; supported platforms: ${
|
|
77
|
+
if (!NATIVE_PLATFORMS.includes(platform)) {
|
|
78
|
+
throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${platform ?? `${process.platform}-${process.arch}`}; supported platforms: ${NATIVE_PLATFORMS.join(', ')}`);
|
|
46
79
|
}
|
|
80
|
+
const image = IMAGE_CHECKS[platform];
|
|
47
81
|
const manifest = readPayloadManifest({ platform, root: options.root });
|
|
48
82
|
if (manifest.platform !== platform) {
|
|
49
83
|
throw new CliError('PAYLOAD_PLATFORM_MISMATCH', `payload manifest declares ${manifest.platform}, expected ${platform}`);
|
|
@@ -74,11 +108,57 @@ export function verifyPayload(options = {}) {
|
|
|
74
108
|
if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) {
|
|
75
109
|
throw new CliError('PAYLOAD_DIGEST_MISMATCH', `native payload file ${record.name} does not match the release digest`);
|
|
76
110
|
}
|
|
77
|
-
const arch =
|
|
78
|
-
if (arch !==
|
|
79
|
-
throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a
|
|
111
|
+
const arch = image.probe(bytes);
|
|
112
|
+
if (arch !== image.arch) {
|
|
113
|
+
throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a ${image.image}`);
|
|
80
114
|
}
|
|
81
115
|
return { name: record.name, bytes: bytes.length, mode: '755', sha256: record.sha256, arch };
|
|
82
116
|
});
|
|
83
117
|
return { status: 'verified', platform, version: manifest.version, files };
|
|
84
118
|
}
|
|
119
|
+
|
|
120
|
+
// npm materializes the packaged payload with the permission recorded in the
|
|
121
|
+
// tarball, masked by the installing process's umask. A host running under a
|
|
122
|
+
// permissive umask (e.g. 0002) can therefore extract an archived 0755 binary
|
|
123
|
+
// as 0775, and the strict gate in verifyPayload below rejects it as
|
|
124
|
+
// PAYLOAD_PERMISSIONS_INVALID — correct, since a group/world-writable native
|
|
125
|
+
// binary is an installation defect and the gate must never be loosened. The
|
|
126
|
+
// install paths instead repair the extracted mode before verification: only
|
|
127
|
+
// the permission is restored, the bytes are untouched, and the digest/arch
|
|
128
|
+
// checks still run. Returns true iff the mode actually changed; a missing or
|
|
129
|
+
// non-regular file (directory, symlink) is never chmodded and returns false so
|
|
130
|
+
// verifyPayload keeps owning that rejection.
|
|
131
|
+
export function normalizePayloadMode(target) {
|
|
132
|
+
let stat;
|
|
133
|
+
try { stat = fs.lstatSync(target); } catch { return false; }
|
|
134
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
135
|
+
if ((stat.mode & 0o777) === RELEASE_FILE_MODE) return false;
|
|
136
|
+
fs.chmodSync(target, RELEASE_FILE_MODE);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Normalize every payload file the manifest names, exactly the set and order
|
|
141
|
+
// verifyPayload will read. An absent/invalid manifest or an unreadable file is
|
|
142
|
+
// left for verifyPayload to reject with its own code — this helper never turns
|
|
143
|
+
// a rejection into a pass. Returns the repaired entries with their prior octal
|
|
144
|
+
// mode so the install step can report what it changed.
|
|
145
|
+
export function normalizePayloadFiles(options = {}) {
|
|
146
|
+
const platform = options.platform || nativePlatform();
|
|
147
|
+
if (!NATIVE_PLATFORMS.includes(platform)) return [];
|
|
148
|
+
let manifest;
|
|
149
|
+
try { manifest = readPayloadManifest({ platform, root: options.root }); } catch { return []; }
|
|
150
|
+
const dir = options.root
|
|
151
|
+
? path.join(options.root, 'npm', NATIVE_DIR_NAME, platform)
|
|
152
|
+
: nativePayloadDir(platform);
|
|
153
|
+
const normalized = [];
|
|
154
|
+
for (const record of manifest.files) {
|
|
155
|
+
if (!record || typeof record.name !== 'string' || path.basename(record.name) !== record.name || record.name.includes('..')) continue;
|
|
156
|
+
const target = path.join(dir, record.name);
|
|
157
|
+
let stat;
|
|
158
|
+
try { stat = fs.lstatSync(target); } catch { continue; }
|
|
159
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
160
|
+
const before = stat.mode & 0o777;
|
|
161
|
+
if (normalizePayloadMode(target)) normalized.push({ name: record.name, from: before.toString(8).padStart(3, '0'), to: '755' });
|
|
162
|
+
}
|
|
163
|
+
return normalized;
|
|
164
|
+
}
|
|
@@ -4,7 +4,9 @@ import { atomicWrite } from '../fs-atomic.mjs';
|
|
|
4
4
|
import { CliError } from '../errors.mjs';
|
|
5
5
|
import { callDaemon } from '../rpc.mjs';
|
|
6
6
|
import { LAUNCH_AGENT_LABEL } from '../constants.mjs';
|
|
7
|
+
import { platform } from '../paths.mjs';
|
|
7
8
|
import { launchAgentPlist, launchctl } from './service-macos.mjs';
|
|
9
|
+
import { SYSTEMD_UNIT_NAME, execStartProgram, replaceExecStartProgram, systemctl, systemdUnit, unitServiceStatus } from './service-linux.mjs';
|
|
8
10
|
|
|
9
11
|
const xml = (s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
10
12
|
const unxml = (s) => s.replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&');
|
|
@@ -15,12 +17,18 @@ export function hasInstalledService(paths) {
|
|
|
15
17
|
return Boolean(paths.launchAgent && fs.existsSync(paths.launchAgent));
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
// Platform dispatch (S03): the same activation health/rollback contract runs
|
|
21
|
+
// over launchd on macOS (verbatim) and the systemd user manager on Linux.
|
|
22
|
+
export async function activateService(paths, candidate, options = {}) {
|
|
23
|
+
return platform() === 'darwin' ? activateLaunchdService(paths, candidate, options) : activateSystemdService(paths, candidate, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
// The service definition is derived from the CURRENT config: activation
|
|
19
27
|
// regenerates the whole plist (environment, revision, runtime forwarding) so a
|
|
20
28
|
// reconfigured installation can never keep serving stale launchd environment.
|
|
21
29
|
// Only rollback restores the previous definition bytes — the exact known state
|
|
22
30
|
// the service ran before — with its executable repointed.
|
|
23
|
-
|
|
31
|
+
async function activateLaunchdService(paths, candidate, options = {}) {
|
|
24
32
|
const control = options.launchctl || launchctl;
|
|
25
33
|
const rpc = options.callDaemon || callDaemon;
|
|
26
34
|
const target = `gui/${process.getuid()}/${LAUNCH_AGENT_LABEL}`;
|
|
@@ -138,3 +146,127 @@ export async function activateService(paths, candidate, options = {}) {
|
|
|
138
146
|
throw error;
|
|
139
147
|
}
|
|
140
148
|
}
|
|
149
|
+
|
|
150
|
+
// The systemd twin of the launchd activation above: same drain/claim prelude,
|
|
151
|
+
// same health verification (a NEW process running the selected artifact's
|
|
152
|
+
// exact bytes and self-reporting its identity), same rollback-to-retained-bytes
|
|
153
|
+
// contract. unload = disable --now + the bounded inactive confirmation;
|
|
154
|
+
// load = daemon-reload + enable --now (the same bootstrap init uses).
|
|
155
|
+
async function activateSystemdService(paths, candidate, options = {}) {
|
|
156
|
+
const control = options.systemctl || systemctl;
|
|
157
|
+
const rpc = options.callDaemon || callDaemon;
|
|
158
|
+
const oldUnit = fs.readFileSync(paths.launchAgent);
|
|
159
|
+
const oldPath = execStartProgram(oldUnit);
|
|
160
|
+
if (!oldPath) throw new CliError('SERVICE_DEFINITION_INVALID', 'installed service has no executable entry');
|
|
161
|
+
const servicePid = async () => {
|
|
162
|
+
const view = unitServiceStatus(control);
|
|
163
|
+
if (view.skipped) throw new CliError('SERVICE_NOT_VERIFIED', 'service control was skipped');
|
|
164
|
+
if (!view.registered) return null;
|
|
165
|
+
return view.pid;
|
|
166
|
+
};
|
|
167
|
+
const oldPid = await servicePid();
|
|
168
|
+
const oldStatus = oldPid ? await rpc(paths.socket, 'status', {}) : null;
|
|
169
|
+
// The running daemon's self-reported digest is the truth about which bytes
|
|
170
|
+
// the service was executing; the file at oldPath may already carry the NEXT
|
|
171
|
+
// version after an npm install replaced the package directory in place.
|
|
172
|
+
const runningArtifact = oldStatus?.identity?.daemon?.artifact;
|
|
173
|
+
const oldSha = (typeof runningArtifact?.sha256 === 'string' && runningArtifact.sha256) || digest(oldPath);
|
|
174
|
+
if (oldPid) {
|
|
175
|
+
const drain = await rpc(paths.socket, 'drain-status', {});
|
|
176
|
+
if (!drain.ready_for_activation) throw new CliError('DAEMON_NOT_DRAINED', 'daemon still owns active or unreaped work');
|
|
177
|
+
const claim = await rpc(paths.socket, 'activate-ready', {});
|
|
178
|
+
if (!claim.activation_claim) throw new CliError('ACTIVATION_NOT_CLAIMED', 'daemon did not grant activation');
|
|
179
|
+
}
|
|
180
|
+
// npm may have replaced the old program path with the new payload: rolling
|
|
181
|
+
// the service back must run the OLD bytes, from the original path when they
|
|
182
|
+
// are still intact or from the retained payload copy the updater staged
|
|
183
|
+
// for exactly this recovery.
|
|
184
|
+
const rollbackExecutable = () => {
|
|
185
|
+
if (fs.existsSync(oldPath) && digest(oldPath) === oldSha) return { path: oldPath, sha256: oldSha };
|
|
186
|
+
const retained = options.rollbackPayload;
|
|
187
|
+
if (retained && typeof retained.path === 'string' && typeof retained.sha256 === 'string'
|
|
188
|
+
&& fs.existsSync(retained.path) && digest(retained.path) === retained.sha256) {
|
|
189
|
+
return { path: retained.path, sha256: retained.sha256 };
|
|
190
|
+
}
|
|
191
|
+
throw new CliError('ROLLBACK_PAYLOAD_UNAVAILABLE', 'previous payload bytes are unavailable; automatic rollback refused');
|
|
192
|
+
};
|
|
193
|
+
const healthy = async (expected, previousPid, previousGeneration) => {
|
|
194
|
+
const deadline = Date.now() + (options.healthTimeoutMs ?? 10_000);
|
|
195
|
+
let last;
|
|
196
|
+
do {
|
|
197
|
+
try {
|
|
198
|
+
const pid = await servicePid();
|
|
199
|
+
if (!pid || pid === previousPid) throw new Error('service has no new process');
|
|
200
|
+
const status = await rpc(paths.socket, 'status', {});
|
|
201
|
+
const identity = status.identity?.daemon;
|
|
202
|
+
// Health verification uses the service generation and the running
|
|
203
|
+
// artifact identity. The diagnostic-only mcp_version never gates
|
|
204
|
+
// admission.
|
|
205
|
+
if (!status.service_generation || status.service_generation === previousGeneration
|
|
206
|
+
|| identity?.artifact?.path !== expected.path || identity?.artifact?.sha256 !== expected.sha256
|
|
207
|
+
|| (expected.version && identity?.version !== expected.version)) throw new Error('running daemon identity does not match selected payload');
|
|
208
|
+
if (await servicePid() !== pid) throw new Error('service process changed during health verification');
|
|
209
|
+
return { pid, version: identity.version, artifact: identity.artifact, service_generation: status.service_generation };
|
|
210
|
+
} catch (error) { last = error; }
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
212
|
+
} while (Date.now() < deadline);
|
|
213
|
+
throw new CliError('SERVICE_HEALTH_FAILED', last?.message || 'service did not become healthy');
|
|
214
|
+
};
|
|
215
|
+
const unload = async () => {
|
|
216
|
+
const view = unitServiceStatus(control);
|
|
217
|
+
if (view.skipped || !view.registered) return;
|
|
218
|
+
await control(['disable', '--now', SYSTEMD_UNIT_NAME]);
|
|
219
|
+
const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
|
|
220
|
+
do {
|
|
221
|
+
const probe = unitServiceStatus(control);
|
|
222
|
+
if (probe.skipped || !probe.registered || (probe.state === 'inactive' && probe.pid === null)) return;
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
224
|
+
} while (Date.now() < deadline);
|
|
225
|
+
throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'systemd service remained active after disable --now');
|
|
226
|
+
};
|
|
227
|
+
const writeDefinition = (entryPath) => atomicWrite(paths.launchAgent, systemdUnit(paths, {
|
|
228
|
+
daemonPath: entryPath,
|
|
229
|
+
...(options.zcodeRuntime !== undefined ? { zcodeRuntime: options.zcodeRuntime } : {}),
|
|
230
|
+
}), 0o600);
|
|
231
|
+
const restoreDefinition = (entryPath) => atomicWrite(paths.launchAgent, replaceExecStartProgram(oldUnit, entryPath), 0o600);
|
|
232
|
+
const load = async () => {
|
|
233
|
+
await control(['daemon-reload']);
|
|
234
|
+
await control(['enable', '--now', SYSTEMD_UNIT_NAME]);
|
|
235
|
+
};
|
|
236
|
+
const rollbackService = async () => {
|
|
237
|
+
await unload();
|
|
238
|
+
const previous = rollbackExecutable();
|
|
239
|
+
restoreDefinition(previous.path);
|
|
240
|
+
await load();
|
|
241
|
+
return healthy({ path: previous.path, sha256: previous.sha256, version: oldStatus?.identity?.daemon?.version }, oldPid, oldStatus?.service_generation);
|
|
242
|
+
};
|
|
243
|
+
try {
|
|
244
|
+
await unload();
|
|
245
|
+
writeDefinition(candidate.path);
|
|
246
|
+
await load();
|
|
247
|
+
const health = await healthy(candidate, oldPid, oldStatus?.service_generation);
|
|
248
|
+
return {
|
|
249
|
+
...health,
|
|
250
|
+
rollback: async () => {
|
|
251
|
+
const restored = await rollbackService();
|
|
252
|
+
if (digest(oldPath) !== oldSha) return { ...restored, restored_from: 'retained_payload' };
|
|
253
|
+
return restored;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!oldPid) {
|
|
258
|
+
try {
|
|
259
|
+
await unload();
|
|
260
|
+
atomicWrite(paths.launchAgent, oldUnit, 0o600);
|
|
261
|
+
error.rollback = { attempted: true, restored: false, unit_restored: true, error: 'no previous service identity to verify' };
|
|
262
|
+
} catch (rollbackError) {
|
|
263
|
+
error.rollback = { attempted: true, restored: false, unit_restored: false, error: rollbackError.message };
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
error.rollback = await rollbackService();
|
|
269
|
+
} catch (rollbackError) { error.rollbackError = rollbackError.message; }
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { DAEMON_BIN_NAME, PRODUCT_NAME, zcodeRuntimePath } from '../constants.mjs';
|
|
5
|
+
import { CliError } from '../errors.mjs';
|
|
6
|
+
import { atomicWrite } from '../fs-atomic.mjs';
|
|
7
|
+
import { nativeBinary } from './layout.mjs';
|
|
8
|
+
import { systemdServicePath } from './path.mjs';
|
|
9
|
+
import { readConfig } from '../config/read.mjs';
|
|
10
|
+
|
|
11
|
+
// The one Linux service backend for the product daemon: a systemd USER unit,
|
|
12
|
+
// never the system manager. The unit pins absolute payload paths and a fixed
|
|
13
|
+
// PATH so the user-manager environment never depends on the interactive shell,
|
|
14
|
+
// exactly the contract the launchd plist owns on macOS (service-macos.mjs is
|
|
15
|
+
// the structural twin; service-macos.mjs also performs the platform dispatch
|
|
16
|
+
// between the two backends). Bootstrap = daemon-reload + enable --now; bootout
|
|
17
|
+
// = disable --now + a bounded inactive confirmation — the same idempotence and
|
|
18
|
+
// removal-confirmation semantics, carried by `systemctl --user` and honoring
|
|
19
|
+
// the EXTERNAL_SUBAGENT_TEST_NO_SYSTEMCTL seam (the structural twin of
|
|
20
|
+
// EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL) so fixture runs never touch the real
|
|
21
|
+
// user manager.
|
|
22
|
+
|
|
23
|
+
export const SYSTEMD_UNIT_NAME = `${PRODUCT_NAME}.service`;
|
|
24
|
+
export const SYSTEMCTL_PATH = '/usr/bin/systemctl';
|
|
25
|
+
|
|
26
|
+
// D-S04: a host without a reachable user manager gets an actionable
|
|
27
|
+
// explanation, never an automatic `loginctl enable-linger` and never a detour
|
|
28
|
+
// through the system manager.
|
|
29
|
+
const NO_SESSION_HINT = 'log in through a real session (which provides $XDG_RUNTIME_DIR), or run \'loginctl enable-linger <user>\' so the user manager stays available without one; this product never enables linger, never modifies unrelated units, and never contacts the system (PID 1) manager';
|
|
30
|
+
|
|
31
|
+
function noSessionError(detail = null) {
|
|
32
|
+
const cause = detail ?? 'neither $XDG_RUNTIME_DIR nor $DBUS_SESSION_BUS_ADDRESS is set';
|
|
33
|
+
return new CliError('NO_USER_SYSTEMD_SESSION', `systemctl --user cannot reach the systemd user manager: ${cause}. ${NO_SESSION_HINT}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hasUserSystemdSession(env = process.env) {
|
|
37
|
+
return Boolean(
|
|
38
|
+
(typeof env.XDG_RUNTIME_DIR === 'string' && env.XDG_RUNTIME_DIR !== '')
|
|
39
|
+
|| (typeof env.DBUS_SESSION_BUS_ADDRESS === 'string' && env.DBUS_SESSION_BUS_ADDRESS !== ''),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// systemd's two expansion grammars differ, and both were verified against the
|
|
44
|
+
// running user manager (systemd 259):
|
|
45
|
+
// Environment= values — NO variable expansion ("the '$' character has no
|
|
46
|
+
// special meaning", man systemd.exec; a literal `$HOME` is passed through
|
|
47
|
+
// verbatim), but %-specifier expansion IS performed (`%h` becomes the
|
|
48
|
+
// home directory; `%%` is a literal `%`).
|
|
49
|
+
// ExecStart arguments — BOTH expansions apply: `$`/`${}` at spawn time
|
|
50
|
+
// (`$$` passes a literal dollar, man systemd.service) and %-specifiers at
|
|
51
|
+
// load time (`%%` literal).
|
|
52
|
+
// Each side therefore escapes only what its grammar consumes; both quote the
|
|
53
|
+
// value with `\"`/`\\` per systemd.syntax (an unrecognized `\x` escape makes
|
|
54
|
+
// systemd DROP the whole Environment assignment, so every backslash is
|
|
55
|
+
// always doubled). The `$`/`%` doublings use replacer functions because a
|
|
56
|
+
// plain `'$$'` replacement string is itself a $-pattern ("insert one $") and
|
|
57
|
+
// would silently cancel the escape.
|
|
58
|
+
function escapeQuotedCharacters(value) {
|
|
59
|
+
return String(value)
|
|
60
|
+
.replaceAll('\\', () => '\\\\')
|
|
61
|
+
.replaceAll('"', () => '\\"');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeEnvironmentValue(value) {
|
|
65
|
+
return escapeQuotedCharacters(value).replaceAll('%', () => '%%');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function escapeExecStartArgument(value) {
|
|
69
|
+
return escapeQuotedCharacters(value).replaceAll('$', () => '$$').replaceAll('%', () => '%%');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A newline inside a unit directive cannot be escaped away: systemd ends the
|
|
73
|
+
// assignment there and parses the remainder as NEW directive lines, so a
|
|
74
|
+
// config value like "acp\nRestart=no\n#" silently overrides the product-fixed
|
|
75
|
+
// Restart=always — and systemd-analyze verify accepts the corrupted unit
|
|
76
|
+
// without a warning (reproduced with the real generator). Both forwarding
|
|
77
|
+
// paths therefore refuse such values loudly instead of writing a corrupted
|
|
78
|
+
// unit; the macOS plist carries the same value as inert XML data and keeps
|
|
79
|
+
// forwarding it (its generator is deliberately untouched).
|
|
80
|
+
function assertUnitLineSafe(value, label) {
|
|
81
|
+
if (/[\n\r]/u.test(String(value))) {
|
|
82
|
+
throw new CliError('SERVICE_DEFINITION_INVALID',
|
|
83
|
+
`the ${label} contains a newline (line feed or carriage return); systemd unit directive lines cannot represent it, and the service definition is refused rather than written corrupted — remove the newline from the configuration value`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function quotedArgument(value) {
|
|
88
|
+
assertUnitLineSafe(value, 'ExecStart argument');
|
|
89
|
+
return `"${escapeExecStartArgument(value)}"`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The assignment is quoted as a whole (`Environment="NAME=value"`): systemd
|
|
93
|
+
// parses one assignment per line, and quotes around only the value would be
|
|
94
|
+
// taken literally.
|
|
95
|
+
function environmentLine(name, value) {
|
|
96
|
+
assertUnitLineSafe(value, `${name} environment value`);
|
|
97
|
+
return `Environment="${name}=${escapeEnvironmentValue(value)}"`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unescapeUnitValue(text) {
|
|
101
|
+
let out = '';
|
|
102
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
103
|
+
const char = text[index];
|
|
104
|
+
if (char === '\\' && index + 1 < text.length) { out += text[index + 1]; index += 1; continue; }
|
|
105
|
+
if (char === '$' && text[index + 1] === '$') { out += '$'; index += 1; continue; }
|
|
106
|
+
if (char === '%' && text[index + 1] === '%') { out += '%'; index += 1; continue; }
|
|
107
|
+
out += char;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function firstExecStartToken(line) {
|
|
113
|
+
const rest = line.replace(/^[+!:-]+/u, ''); // systemd command prefixes (+, -, :, !, !!)
|
|
114
|
+
if (rest.startsWith('"')) {
|
|
115
|
+
for (let index = 1; index < rest.length; index += 1) {
|
|
116
|
+
if (rest[index] === '\\') { index += 1; continue; }
|
|
117
|
+
if (rest[index] === '"') return { token: rest.slice(1, index), after: rest.slice(index + 1) };
|
|
118
|
+
}
|
|
119
|
+
return null; // unterminated quote
|
|
120
|
+
}
|
|
121
|
+
const end = rest.search(/\s/u);
|
|
122
|
+
return { token: end === -1 ? rest : rest.slice(0, end), after: end === -1 ? '' : rest.slice(end) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The executable the unit pins — the systemd twin of the plist's first
|
|
126
|
+
// ProgramArguments entry — used by activation to learn the old program and to
|
|
127
|
+
// restore it byte-for-byte on rollback.
|
|
128
|
+
export function execStartProgram(unitBytes) {
|
|
129
|
+
const match = unitBytes.toString('utf8').match(/^ExecStart=(.*)$/m);
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
const token = firstExecStartToken(match[1]);
|
|
132
|
+
return token ? unescapeUnitValue(token.token) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function replaceExecStartProgram(unitBytes, program) {
|
|
136
|
+
const text = unitBytes.toString('utf8');
|
|
137
|
+
const match = text.match(/^ExecStart=(.*)$/m);
|
|
138
|
+
if (!match) return null;
|
|
139
|
+
const token = firstExecStartToken(match[1]);
|
|
140
|
+
if (!token) return null;
|
|
141
|
+
const prefix = match[1].match(/^[+!:-]*/u)[0];
|
|
142
|
+
const line = `ExecStart=${prefix}${quotedArgument(program)}${token.after}`;
|
|
143
|
+
// Replacer function: a raw replacement string would reinterpret $-patterns
|
|
144
|
+
// inside the restored unit text.
|
|
145
|
+
return Buffer.from(text.replace(/^ExecStart=.*$/m, () => line), 'utf8');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The same parameterization the plist generator carries (one-to-one): the
|
|
149
|
+
// verified daemon artifact, the data paths the daemon takes as arguments, the
|
|
150
|
+
// pinned ZCode runtime forwarded exactly when that installation exists, and
|
|
151
|
+
// the persisted subagent environment. Restart=always mirrors KeepAlive;
|
|
152
|
+
// WantedBy=default.target mirrors RunAtLoad. The daemon's own
|
|
153
|
+
// --diagnostic-log writer puts daemon-error.log under the XDG state log
|
|
154
|
+
// directory; stdout/stderr stay with the user journal, the systemd-native
|
|
155
|
+
// sink (StandardOutput=append: cannot represent paths containing spaces).
|
|
156
|
+
export function systemdUnit(paths, options = {}) {
|
|
157
|
+
const daemon = options.daemonPath || nativeBinary(DAEMON_BIN_NAME);
|
|
158
|
+
const config = readConfig(paths.config);
|
|
159
|
+
const { runtime_path: dshRuntime, home: dshHome, profile: dshProfile, version: dshVersion } = config.subagents.dsh;
|
|
160
|
+
const { runtime_path: codexRuntime, home: codexHome } = config.subagents.codex;
|
|
161
|
+
const { runtime_path: agyRuntime } = config.subagents.agy;
|
|
162
|
+
const configRevision = config.revision;
|
|
163
|
+
const zcodeRuntime = options.zcodeRuntime ?? zcodeRuntimePath(paths.home);
|
|
164
|
+
const execStart = [
|
|
165
|
+
quotedArgument(daemon),
|
|
166
|
+
quotedArgument('--database'),
|
|
167
|
+
quotedArgument(paths.database),
|
|
168
|
+
quotedArgument('--socket'),
|
|
169
|
+
quotedArgument(paths.socket),
|
|
170
|
+
...(fs.existsSync(zcodeRuntime) ? [quotedArgument('--runtime'), quotedArgument(zcodeRuntime)] : []),
|
|
171
|
+
quotedArgument('--diagnostic-log'),
|
|
172
|
+
quotedArgument(path.join(paths.logs, 'daemon-error.log')),
|
|
173
|
+
].join(' ');
|
|
174
|
+
const environment = [
|
|
175
|
+
environmentLine('PATH', systemdServicePath()),
|
|
176
|
+
...(configRevision === null ? [] : [environmentLine('EXTERNAL_SUBAGENT_CONFIG_REVISION', configRevision)]),
|
|
177
|
+
...(dshRuntime ? [environmentLine('DSH_RUNTIME_PATH', dshRuntime)] : []),
|
|
178
|
+
...(dshHome ? [environmentLine('DSH_HOME', dshHome)] : []),
|
|
179
|
+
...(dshProfile ? [environmentLine('DSH_PROFILE', dshProfile)] : []),
|
|
180
|
+
...(dshVersion ? [environmentLine('DSH_VERSION', dshVersion)] : []),
|
|
181
|
+
...(codexRuntime ? [environmentLine('CODEX_RUNTIME_PATH', codexRuntime)] : []),
|
|
182
|
+
...(codexHome ? [environmentLine('CODEX_HOME', codexHome)] : []),
|
|
183
|
+
...(agyRuntime ? [environmentLine('AGY_RUNTIME_PATH', agyRuntime)] : []),
|
|
184
|
+
];
|
|
185
|
+
return Buffer.from([
|
|
186
|
+
`# Managed by ${PRODUCT_NAME}; regenerate with 'external-subagent init'.`,
|
|
187
|
+
'[Unit]',
|
|
188
|
+
`Description=${PRODUCT_NAME} daemon`,
|
|
189
|
+
'',
|
|
190
|
+
'[Service]',
|
|
191
|
+
'Type=simple',
|
|
192
|
+
`ExecStart=${execStart}`,
|
|
193
|
+
...environment,
|
|
194
|
+
'Restart=always',
|
|
195
|
+
'',
|
|
196
|
+
'[Install]',
|
|
197
|
+
'WantedBy=default.target',
|
|
198
|
+
'',
|
|
199
|
+
].join('\n'), 'utf8');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function installServiceUnit(paths, options = {}) {
|
|
203
|
+
const unit = options.unit || systemdUnit(paths);
|
|
204
|
+
atomicWrite(paths.launchAgent, unit, 0o600);
|
|
205
|
+
return { installed: true, path: paths.launchAgent, unit: SYSTEMD_UNIT_NAME };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function systemctl(args) {
|
|
209
|
+
if (process.env.EXTERNAL_SUBAGENT_TEST_NO_SYSTEMCTL === '1') {
|
|
210
|
+
return { action: args[0], skipped: true, reason: 'systemd neutralized by test seam' };
|
|
211
|
+
}
|
|
212
|
+
if (!hasUserSystemdSession()) throw noSessionError();
|
|
213
|
+
const result = spawnSync(SYSTEMCTL_PATH, ['--user', ...args], { encoding: 'utf8' });
|
|
214
|
+
if (result.error) throw new CliError('DAEMON_CONTROL_FAILED', `systemctl is unavailable: ${result.error.message}`);
|
|
215
|
+
if (result.status !== 0 && /Failed to connect to (?:user scope )?bus/iu.test(result.stderr || '')) {
|
|
216
|
+
throw noSessionError((result.stderr || '').trim());
|
|
217
|
+
}
|
|
218
|
+
if (result.status !== 0) {
|
|
219
|
+
throw new CliError('DAEMON_CONTROL_FAILED', (result.stderr || result.stdout || `systemctl ${args[0]} failed`).trim());
|
|
220
|
+
}
|
|
221
|
+
return { action: args[0], status: result.status, stdout: result.stdout };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The single `show`-shaped probe every decision below reuses — the structural
|
|
225
|
+
// twin of the launchctl print/absent discriminator. A unit the user manager
|
|
226
|
+
// has loaded reports its real state (`failed` included), so a status view built
|
|
227
|
+
// on it can never turn a crashed unit into "healthy".
|
|
228
|
+
export function unitServiceStatus(control, unit = SYSTEMD_UNIT_NAME) {
|
|
229
|
+
const result = control(['show', unit]);
|
|
230
|
+
if (result.skipped) return { skipped: true, reason: result.reason };
|
|
231
|
+
const properties = Object.fromEntries(
|
|
232
|
+
(result.stdout || '').split('\n').filter((line) => /^[A-Za-z]+=/.test(line)).map((line) => {
|
|
233
|
+
const separator = line.indexOf('=');
|
|
234
|
+
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
const pid = Number(properties.MainPID);
|
|
238
|
+
return {
|
|
239
|
+
registered: properties.LoadState === 'loaded',
|
|
240
|
+
load_state: properties.LoadState ?? null,
|
|
241
|
+
state: properties.ActiveState ?? null,
|
|
242
|
+
sub_state: properties.SubState ?? null,
|
|
243
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ActiveState values that mean "the service has a live process right now".
|
|
248
|
+
const LIVE_STATES = new Set(['active', 'activating', 'reloading']);
|
|
249
|
+
|
|
250
|
+
// Read-only registration view for `status`. A missing user session degrades
|
|
251
|
+
// into an explicit unavailable view (with the linger hint) instead of failing
|
|
252
|
+
// the whole read-only command; every other control failure still surfaces.
|
|
253
|
+
export function systemdServiceRegistrationStatus(options = {}) {
|
|
254
|
+
const control = options.systemctl || systemctl;
|
|
255
|
+
try {
|
|
256
|
+
const view = unitServiceStatus(control);
|
|
257
|
+
if (view.skipped) return { query: 'skipped', registered: null, reason: view.reason };
|
|
258
|
+
const { registered, state, sub_state: subState, pid } = view;
|
|
259
|
+
return registered ? { registered: true, state, sub_state: subState, pid } : { registered: false };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error.code === 'NO_USER_SYSTEMD_SESSION') {
|
|
262
|
+
return { query: 'unavailable', registered: null, reason: error.message };
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// systemd natively answers a repeat `enable --now` with success even on a
|
|
269
|
+
// running unit, but the product keeps the launchd contract: an already-live
|
|
270
|
+
// service is reported as already_loaded with its real state/pid and is never
|
|
271
|
+
// disturbed, and an enable that loses a race to another starter is settled
|
|
272
|
+
// through the same probe instead of surfacing a phantom failure.
|
|
273
|
+
export function bootstrapServiceSystemd(paths, _uid, options = {}) {
|
|
274
|
+
const control = options.systemctl || systemctl;
|
|
275
|
+
const alreadyLoaded = (view) => ({
|
|
276
|
+
action: 'bootstrap', unit: SYSTEMD_UNIT_NAME, already_loaded: true,
|
|
277
|
+
state: view.state ?? null, pid: view.pid ?? null,
|
|
278
|
+
});
|
|
279
|
+
const isLive = (view) => !view.skipped && view.registered === true && LIVE_STATES.has(view.state);
|
|
280
|
+
const existing = unitServiceStatus(control);
|
|
281
|
+
if (isLive(existing)) return alreadyLoaded(existing);
|
|
282
|
+
try {
|
|
283
|
+
control(['daemon-reload']);
|
|
284
|
+
return { ...control(['enable', '--now', SYSTEMD_UNIT_NAME]), unit: SYSTEMD_UNIT_NAME };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const settled = unitServiceStatus(control);
|
|
287
|
+
if (isLive(settled)) return alreadyLoaded(settled);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A disable --now returns before the unit has necessarily finished its
|
|
293
|
+
// teardown, and a start issued inside that window can race the still-running
|
|
294
|
+
// stop job — the same observed hazard the launchd bootout confirmation loop
|
|
295
|
+
// guards. stop therefore confirms the unit is actually inactive (and its
|
|
296
|
+
// process gone or the socket removed) before reporting success, bounded, and
|
|
297
|
+
// a unit that was never loaded is an idempotent already_stopped.
|
|
298
|
+
export function bootoutServiceSystemd(paths, _uid, options = {}) {
|
|
299
|
+
const control = options.systemctl || systemctl;
|
|
300
|
+
// A skipped probe (test seam) reads as not-registered, exactly like the
|
|
301
|
+
// launchd bootout owner treats a neutralized print: fixture stops are
|
|
302
|
+
// already_stopped, never a real disable.
|
|
303
|
+
const stopped = (view) => view.skipped || view.registered !== true
|
|
304
|
+
|| (view.state === 'inactive' && view.pid === null);
|
|
305
|
+
const existing = unitServiceStatus(control);
|
|
306
|
+
if (stopped(existing)) return { action: 'bootout', unit: SYSTEMD_UNIT_NAME, already_stopped: true };
|
|
307
|
+
let result;
|
|
308
|
+
try {
|
|
309
|
+
result = control(['disable', '--now', SYSTEMD_UNIT_NAME]);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
// A racing removal (another stop, or the user manager itself) may have
|
|
312
|
+
// taken the unit down between the probe and the disable; confirm before
|
|
313
|
+
// reporting failure, so a repeated stop stays idempotent.
|
|
314
|
+
const settled = unitServiceStatus(control);
|
|
315
|
+
if (stopped(settled)) return { action: 'bootout', unit: SYSTEMD_UNIT_NAME, already_stopped: true };
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
|
|
319
|
+
for (;;) {
|
|
320
|
+
const probe = unitServiceStatus(control);
|
|
321
|
+
if (stopped(probe)) {
|
|
322
|
+
return { ...result, unit: SYSTEMD_UNIT_NAME, removed: true, socket_removed: !fs.existsSync(paths.socket) };
|
|
323
|
+
}
|
|
324
|
+
if (Date.now() >= deadline) throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'systemd service remained active after disable --now');
|
|
325
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
326
|
+
}
|
|
327
|
+
}
|