external-subagent 0.4.1 → 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,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
+ }
@@ -1,14 +1,20 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL, ZCODE_RUNTIME } from '../constants.mjs';
4
+ import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL, zcodeRuntimePath } from '../constants.mjs';
5
5
  import { CliError } from '../errors.mjs';
6
6
  import { atomicWrite } from '../fs-atomic.mjs';
7
7
  import { nativeBinary } from './layout.mjs';
8
8
  import { LAUNCHD_FIXED_PATH } from './path.mjs';
9
9
  import { readConfig } from '../config/read.mjs';
10
+ import { platform } from '../paths.mjs';
11
+ import {
12
+ SYSTEMD_UNIT_NAME, bootstrapServiceSystemd, bootoutServiceSystemd, installServiceUnit,
13
+ systemdServiceRegistrationStatus,
14
+ } from './service-linux.mjs';
10
15
 
11
- // The one macOS service template for the product daemon. The plist pins
16
+ // The macOS service backend for the product daemon, plus the platform
17
+ // dispatch into the systemd user backend (service-linux.mjs). The plist pins
12
18
  // absolute payload paths and a fixed PATH so the GUI/launchd environment can
13
19
  // never depend on the interactive shell. Launchctl activation honors the
14
20
  // EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL seam so fixture runs never load real
@@ -22,7 +28,7 @@ function escapeXml(value) {
22
28
  export function runtimeObservations(paths, options = {}) {
23
29
  const config = readConfig(paths.config);
24
30
  return Object.fromEntries(['zcode', 'dsh', 'codex', 'agy'].map((agent) => {
25
- const runtime = agent === 'zcode' ? (options.zcodeRuntime ?? ZCODE_RUNTIME) : config.subagents[agent].runtime_path;
31
+ const runtime = agent === 'zcode' ? (options.zcodeRuntime ?? zcodeRuntimePath(paths.home)) : config.subagents[agent].runtime_path;
26
32
  return [agent, { path: runtime, present: Boolean(runtime && fs.existsSync(runtime)), enabled: config.subagents[agent].enabled }];
27
33
  }));
28
34
  }
@@ -38,13 +44,14 @@ export function launchAgentPlist(paths, options = {}) {
38
44
  const { runtime_path: agyRuntime } = config.subagents.agy;
39
45
  const configRevision = config.revision;
40
46
  // AUD-005/D1: the standalone service never depends on an unrelated runtime.
41
- // The pinned ZCode runtime is forwarded only when that installation exists;
42
- // without it the daemon still starts and the zcode adapter fails closed at
43
- // spawn ("ZCODE_RUNTIME_PATH is unavailable") instead of the whole service
44
- // dying on launchd because --runtime cannot canonicalize. Installing (or
45
- // removing) ZCode later is picked up by the next init, which rewrites the
46
- // plist. The seam exists so tests can pin both branches deterministically.
47
- const zcodeRuntime = options.zcodeRuntime ?? ZCODE_RUNTIME;
47
+ // The pinned ZCode runtime (platform-discovered, constants.mjs) is forwarded
48
+ // only when that installation exists; without it the daemon still starts and
49
+ // the zcode adapter fails closed at spawn ("ZCODE_RUNTIME_PATH is
50
+ // unavailable") instead of the whole service dying on launchd because
51
+ // --runtime cannot canonicalize. Installing (or removing) ZCode later is
52
+ // picked up by the next init, which rewrites the plist. The seam exists so
53
+ // tests can pin both branches deterministically.
54
+ const zcodeRuntime = options.zcodeRuntime ?? zcodeRuntimePath(paths.home);
48
55
  const programArguments = [
49
56
  `<string>${escapeXml(daemon)}</string>`,
50
57
  '<string>--database</string>',
@@ -103,7 +110,7 @@ export function launchctl(args) {
103
110
  // A repeat init/start must stay idempotent instead of failing the whole run,
104
111
  // so the registration lookup below — the same print/absent discriminator the
105
112
  // update owner uses — decides between "already loaded" and a real failure.
106
- export function serviceRegistrationStatus(uid = process.getuid(), options = {}) {
113
+ export function launchdServiceRegistrationStatus(uid = process.getuid(), options = {}) {
107
114
  const control = options.launchctl || launchctl;
108
115
  const result = control(['print', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]);
109
116
  if (result.skipped) return { query: 'skipped', registered: null, reason: result.reason };
@@ -113,20 +120,20 @@ export function serviceRegistrationStatus(uid = process.getuid(), options = {})
113
120
  return { registered: true, state, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
114
121
  }
115
122
 
116
- export function bootstrapService(paths, uid = process.getuid(), options = {}) {
123
+ export function bootstrapLaunchdService(paths, uid = process.getuid(), options = {}) {
117
124
  const control = options.launchctl || launchctl;
118
125
  const alreadyLoaded = (lookup) => ({
119
126
  action: 'bootstrap', label: LAUNCH_AGENT_LABEL, already_loaded: true,
120
127
  state: lookup.state ?? null, pid: lookup.pid ?? null,
121
128
  });
122
- const existing = serviceRegistrationStatus(uid, { launchctl: control });
129
+ const existing = launchdServiceRegistrationStatus(uid, { launchctl: control });
123
130
  if (existing.registered) return alreadyLoaded(existing);
124
131
  try {
125
132
  return control(['bootstrap', `gui/${uid}`, paths.launchAgent]);
126
133
  } catch (error) {
127
134
  // Another loader (a racing init, or launchd itself re-reading the plist)
128
135
  // may have won after the check above; confirm before reporting failure.
129
- const loaded = serviceRegistrationStatus(uid, { launchctl: control });
136
+ const loaded = launchdServiceRegistrationStatus(uid, { launchctl: control });
130
137
  if (loaded.registered) return alreadyLoaded(loaded);
131
138
  throw error;
132
139
  }
@@ -138,9 +145,9 @@ export function bootstrapService(paths, uid = process.getuid(), options = {}) {
138
145
  // service unregistered roughly one time in three). The stop owner therefore
139
146
  // confirms the job is actually gone — bounded, with the same print/absent
140
147
  // discriminator the update owner's unload uses — before reporting success.
141
- export function bootoutService(paths, uid = process.getuid(), options = {}) {
148
+ export function bootoutLaunchdService(paths, uid = process.getuid(), options = {}) {
142
149
  const control = options.launchctl || launchctl;
143
- const existing = serviceRegistrationStatus(uid, { launchctl: control });
150
+ const existing = launchdServiceRegistrationStatus(uid, { launchctl: control });
144
151
  if (!existing.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
145
152
  let result;
146
153
  try {
@@ -149,15 +156,49 @@ export function bootoutService(paths, uid = process.getuid(), options = {}) {
149
156
  // A racing removal (another stop, or launchd itself) may have taken the
150
157
  // job out between the registration probe and the bootout; confirm before
151
158
  // reporting failure, so a repeated stop stays idempotent.
152
- const settled = serviceRegistrationStatus(uid, { launchctl: control });
159
+ const settled = launchdServiceRegistrationStatus(uid, { launchctl: control });
153
160
  if (!settled.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
154
161
  throw error;
155
162
  }
156
163
  const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
157
164
  for (;;) {
158
- const probe = serviceRegistrationStatus(uid, { launchctl: control });
165
+ const probe = launchdServiceRegistrationStatus(uid, { launchctl: control });
159
166
  if (!probe.registered) return { ...result, label: LAUNCH_AGENT_LABEL, removed: true };
160
167
  if (Date.now() >= deadline) throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'launchd service remained registered after bootout');
161
168
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
162
169
  }
163
170
  }
171
+
172
+ // Platform dispatch (S03): the service backend is selected by the process
173
+ // platform — launchd on macOS, the systemd user manager on Linux — behind the
174
+ // same call surface every caller (init, start/stop, uninstall, status) already
175
+ // uses. The macOS arms below are the verbatim launchd implementations; the
176
+ // Linux arms carry the same idempotence/removal-confirmation contract through
177
+ // `systemctl --user` (options.systemctl is the injectable seam, the twin of
178
+ // options.launchctl).
179
+ export function serviceRegistrationStatus(uid = process.getuid(), options = {}) {
180
+ return platform() === 'darwin'
181
+ ? launchdServiceRegistrationStatus(uid, options)
182
+ : systemdServiceRegistrationStatus(options);
183
+ }
184
+
185
+ export function bootstrapService(paths, uid = process.getuid(), options = {}) {
186
+ return platform() === 'darwin'
187
+ ? bootstrapLaunchdService(paths, uid, options)
188
+ : bootstrapServiceSystemd(paths, uid, options);
189
+ }
190
+
191
+ export function bootoutService(paths, uid = process.getuid(), options = {}) {
192
+ return platform() === 'darwin'
193
+ ? bootoutLaunchdService(paths, uid, options)
194
+ : bootoutServiceSystemd(paths, uid, options);
195
+ }
196
+
197
+ // The service-definition installer behind init's install-launch-agent step:
198
+ // the launchd plist on macOS, the systemd user unit (0600, absolute verified
199
+ // payload, Restart=always) on Linux, both at paths.launchAgent.
200
+ export function installServiceDefinition(paths, options = {}) {
201
+ return platform() === 'darwin' ? installLaunchAgent(paths, options) : installServiceUnit(paths, options);
202
+ }
203
+
204
+ export { SYSTEMD_UNIT_NAME };
@@ -7,7 +7,7 @@ import { CLI_ENTRY_NAME, DAEMON_BIN_NAME, NATIVE_DIR_NAME } from '../constants.m
7
7
  import { reconcileCodexHomes } from './reconcile.mjs';
8
8
  import { packageVersion, packageRoot } from './layout.mjs';
9
9
  import { loadUpdateState } from './recovery.mjs';
10
- import { verifyPayload } from './payload.mjs';
10
+ import { normalizePayloadFiles, verifyPayload } from './payload.mjs';
11
11
 
12
12
  const SCHEMA_VERSION = 2;
13
13
  const lockPath = (paths) => path.join(paths.data, 'install.lock');
@@ -113,6 +113,12 @@ export function preflightUpdate(options = {}) {
113
113
  if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) || !available.includes(version)) {
114
114
  throw new CliError('PAYLOAD_VERSION_UNAVAILABLE', `requested payload version is unavailable: ${version}`);
115
115
  }
116
+ // Repair a candidate payload mode masked by the installing umask before the
117
+ // strict verifier runs; the bytes are untouched, so the digest/arch checks
118
+ // below still bind the candidate to the release manifest.
119
+ for (const entry of normalizePayloadFiles({ root: candidateRoot, platform: options.platform })) {
120
+ process.stderr.write(`normalized payload mode ${entry.from} -> ${entry.to} (${entry.name})\n`);
121
+ }
116
122
  const payload = verifyPayload({ root: candidateRoot, platform: options.platform });
117
123
  if (version !== payload.version) throw new CliError('PAYLOAD_VERSION_MISMATCH', `requested version ${version} differs from candidate ${payload.version}`);
118
124
  // Reject a doomed candidate up front: an unusable stable entry or daemon