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.
@@ -4,11 +4,25 @@ import { CliError } from '../errors.mjs';
4
4
  import { sha256 } from '../fs-atomic.mjs';
5
5
  import { NATIVE_DIR_NAME, PRODUCT_NAME } from '../constants.mjs';
6
6
  import {
7
- NATIVE_PLATFORM, cliVersion, nativePayloadDir, nativePlatform, packageVersion, payloadManifestPath,
7
+ NATIVE_PLATFORMS, cliVersion, nativePayloadDir, nativePlatform, packageVersion, payloadManifestPath,
8
8
  } from './layout.mjs';
9
9
 
10
10
  const MH_MAGIC_64 = 0xfeedfacf;
11
11
  const CPU_TYPE_ARM64 = 0x0100000c;
12
+ // The single executable mode every released payload file must carry.
13
+ const RELEASE_FILE_MODE = 0o755;
14
+
15
+ // ELF64 header identity, read little-endian:
16
+ // e_ident[0..3] = 0x7f 'E' 'L' 'F' (0x464c457f as a LE uint32)
17
+ // e_ident[4] = EI_CLASS (2 = ELFCLASS64)
18
+ // e_ident[5] = EI_DATA (1 = ELFDATA2LSB)
19
+ // e_machine = uint16 at offset 18 (0x3e = EM_X86_64)
20
+ const ELF_MAGIC = 0x464c457f;
21
+ const ELFCLASS64 = 2;
22
+ const ELFDATA2LSB = 1;
23
+ const EM_X86_64 = 0x3e;
24
+ const ELF_E_MACHINE_OFFSET = 18;
25
+ const ELF_HEADER_MIN_BYTES = ELF_E_MACHINE_OFFSET + 2;
12
26
 
13
27
  export function machoArch(bytes) {
14
28
  if (bytes.length < 8) return null;
@@ -16,6 +30,25 @@ export function machoArch(bytes) {
16
30
  return bytes.readUInt32LE(4) === CPU_TYPE_ARM64 ? 'arm64' : null;
17
31
  }
18
32
 
33
+ // The Linux image check mirrors machoArch: only a 64-bit little-endian x86-64
34
+ // ELF is a supported payload. A truncated header (shorter than e_machine), a
35
+ // 32-bit or big-endian image, or any other machine returns null and is
36
+ // rejected as an unsupported architecture.
37
+ export function elfArch(bytes) {
38
+ if (bytes.length < ELF_HEADER_MIN_BYTES) return null;
39
+ if (bytes.readUInt32LE(0) !== ELF_MAGIC) return null;
40
+ if (bytes[4] !== ELFCLASS64 || bytes[5] !== ELFDATA2LSB) return null;
41
+ return bytes.readUInt16LE(ELF_E_MACHINE_OFFSET) === EM_X86_64 ? 'x64' : null;
42
+ }
43
+
44
+ // Each supported platform has exactly one image identity. verifyPayload reads
45
+ // the tuple once and uses the paired probe, so a valid image of the *other*
46
+ // platform can never satisfy this one.
47
+ const IMAGE_CHECKS = Object.freeze({
48
+ 'darwin-arm64': { arch: 'arm64', image: 'Mach-O arm64 image', probe: machoArch },
49
+ 'linux-x64': { arch: 'x64', image: 'ELF x86-64 image', probe: elfArch },
50
+ });
51
+
19
52
  export function readPayloadManifest(options = {}) {
20
53
  const platform = options.platform || nativePlatform();
21
54
  const root = options.root || undefined;
@@ -41,9 +74,10 @@ export function readPayloadManifest(options = {}) {
41
74
  // defect, never a silent downgrade path.
42
75
  export function verifyPayload(options = {}) {
43
76
  const platform = options.platform || nativePlatform();
44
- if (platform === null) {
45
- throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${process.platform}-${process.arch}; supported platforms: ${NATIVE_PLATFORM}`);
77
+ if (!NATIVE_PLATFORMS.includes(platform)) {
78
+ throw new CliError('UNSUPPORTED_PAYLOAD_PLATFORM', `no native payload exists for ${platform ?? `${process.platform}-${process.arch}`}; supported platforms: ${NATIVE_PLATFORMS.join(', ')}`);
46
79
  }
80
+ const image = IMAGE_CHECKS[platform];
47
81
  const manifest = readPayloadManifest({ platform, root: options.root });
48
82
  if (manifest.platform !== platform) {
49
83
  throw new CliError('PAYLOAD_PLATFORM_MISMATCH', `payload manifest declares ${manifest.platform}, expected ${platform}`);
@@ -74,11 +108,57 @@ export function verifyPayload(options = {}) {
74
108
  if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) {
75
109
  throw new CliError('PAYLOAD_DIGEST_MISMATCH', `native payload file ${record.name} does not match the release digest`);
76
110
  }
77
- const arch = machoArch(bytes);
78
- if (arch !== 'arm64') {
79
- throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a Mach-O arm64 image`);
111
+ const arch = image.probe(bytes);
112
+ if (arch !== image.arch) {
113
+ throw new CliError('PAYLOAD_ARCH_UNSUPPORTED', `native payload file ${record.name} is not a ${image.image}`);
80
114
  }
81
115
  return { name: record.name, bytes: bytes.length, mode: '755', sha256: record.sha256, arch };
82
116
  });
83
117
  return { status: 'verified', platform, version: manifest.version, files };
84
118
  }
119
+
120
+ // npm materializes the packaged payload with the permission recorded in the
121
+ // tarball, masked by the installing process's umask. A host running under a
122
+ // permissive umask (e.g. 0002) can therefore extract an archived 0755 binary
123
+ // as 0775, and the strict gate in verifyPayload below rejects it as
124
+ // PAYLOAD_PERMISSIONS_INVALID — correct, since a group/world-writable native
125
+ // binary is an installation defect and the gate must never be loosened. The
126
+ // install paths instead repair the extracted mode before verification: only
127
+ // the permission is restored, the bytes are untouched, and the digest/arch
128
+ // checks still run. Returns true iff the mode actually changed; a missing or
129
+ // non-regular file (directory, symlink) is never chmodded and returns false so
130
+ // verifyPayload keeps owning that rejection.
131
+ export function normalizePayloadMode(target) {
132
+ let stat;
133
+ try { stat = fs.lstatSync(target); } catch { return false; }
134
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
135
+ if ((stat.mode & 0o777) === RELEASE_FILE_MODE) return false;
136
+ fs.chmodSync(target, RELEASE_FILE_MODE);
137
+ return true;
138
+ }
139
+
140
+ // Normalize every payload file the manifest names, exactly the set and order
141
+ // verifyPayload will read. An absent/invalid manifest or an unreadable file is
142
+ // left for verifyPayload to reject with its own code — this helper never turns
143
+ // a rejection into a pass. Returns the repaired entries with their prior octal
144
+ // mode so the install step can report what it changed.
145
+ export function normalizePayloadFiles(options = {}) {
146
+ const platform = options.platform || nativePlatform();
147
+ if (!NATIVE_PLATFORMS.includes(platform)) return [];
148
+ let manifest;
149
+ try { manifest = readPayloadManifest({ platform, root: options.root }); } catch { return []; }
150
+ const dir = options.root
151
+ ? path.join(options.root, 'npm', NATIVE_DIR_NAME, platform)
152
+ : nativePayloadDir(platform);
153
+ const normalized = [];
154
+ for (const record of manifest.files) {
155
+ if (!record || typeof record.name !== 'string' || path.basename(record.name) !== record.name || record.name.includes('..')) continue;
156
+ const target = path.join(dir, record.name);
157
+ let stat;
158
+ try { stat = fs.lstatSync(target); } catch { continue; }
159
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
160
+ const before = stat.mode & 0o777;
161
+ if (normalizePayloadMode(target)) normalized.push({ name: record.name, from: before.toString(8).padStart(3, '0'), to: '755' });
162
+ }
163
+ return normalized;
164
+ }
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
10
12
  const unxml = (s) => s.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
@@ -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
- export async function activateService(paths, candidate, options = {}) {
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
+ }