@moretea-labs/forge 1.7.1 → 1.7.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable public Forge changes are recorded here. Release entries are tied to
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 1.7.2 - 2026-08-30
8
+
9
+ ### Runtime lifecycle reliability
10
+
11
+ - Restart an already-active Linux/systemd Runtime after immutable package activation so the running process cannot remain fenced behind a newer release-authority revision.
12
+ - Avoid duplicate systemd process-group shutdown signals from the package launcher, allowing controlled Runtime restarts to terminate cleanly.
13
+
14
+ ### Work and Controller authority
15
+
16
+ - Promote effect Work to repository-change authority before governed source mutation and add narrowly fenced reconciliation for already-delivered historical effect Work.
17
+ - Preserve authenticated explicit Controller session capability across MCP transport rotation while keeping same-principal conversations isolated.
18
+ - Make the Windows worker-stderr path assertion platform-correct without changing production path behavior.
19
+
7
20
  ## 1.7.1 - 2026-08-30
8
21
 
9
22
  ### Runtime and lifecycle stabilization
@@ -1,6 +1,6 @@
1
1
  # Third-party notices
2
2
 
3
- This inventory covers the direct runtime and development dependencies declared by `@moretea-labs/forge@1.7.1`.
3
+ This inventory covers the direct runtime and development dependencies declared by `@moretea-labs/forge@1.7.2`.
4
4
 
5
5
  License values come from `package-lock.json` where available. For lock entries without a license field, the declared license was verified from package metadata during release preparation. This file is informational and does not replace the license text distributed by each dependency.
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.7.1",
3
- "templateVersion": "1.7.1",
2
+ "version": "1.7.2",
3
+ "templateVersion": "1.7.2",
4
4
  "skillName": "forge",
5
5
  "contractId": "tasks-first-harness-v1",
6
6
  "compatibility": {
@@ -6,8 +6,8 @@ Homebrew is planned as a convenience layer after the first stable npm/GitHub rel
6
6
 
7
7
  Do not publish a formula until all of the following are true:
8
8
 
9
- - `@moretea-labs/forge@1.7.1` exists on npm;
10
- - Git tag and GitHub Release `v1.7.1` exist and point to the tested commit;
9
+ - `@moretea-labs/forge@1.7.2` exists on npm;
10
+ - Git tag and GitHub Release `v1.7.2` exist and point to the tested commit;
11
11
  - the npm tarball has an immutable checksum;
12
12
  - macOS installation and `forge --version` / `forge doctor` tests pass;
13
13
  - upgrade and uninstall behavior is documented.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moretea-labs/forge",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "Local-first action assistant for software work with resumable execution, bounded tools, and reviewable evidence",
5
5
  "license": "MIT",
6
6
  "author": "Moretea Labs contributors",
@@ -5,6 +5,7 @@ import type { ResolvedExecutionIdentity } from '../../runtime/control-plane/exec
5
5
  import { assertNoBoundExecutionSessionMutation, resolveClaimedRepositoryWorkId, resolveExplicitClaimedRepositoryWork, type RepositoryWorkAttributionCaller } from '../../runtime/control-plane/execution/repository-work-attribution';
6
6
  import { getWorkContract } from '../../runtime/control-plane/facade/work-contract-store';
7
7
  import { assertWorkPathsWithinScope } from '../../runtime/control-plane/execution/work-path-scope';
8
+ import { ensureRepositoryMutationWorkHandle } from '../../runtime/control-plane/execution/work-handle-authority';
8
9
  import { isTerminalWorkContractStatus } from '../../runtime/control-plane/facade/types';
9
10
  import { executeRepositoryCommand, previewRepositoryCommandExecution } from '../repositories/command-executor';
10
11
  import { withControllerLock } from '../repositories/locks';
@@ -990,18 +991,28 @@ export async function callRepositoryTool(
990
991
  controllerHome,
991
992
  { scope: 'repository', repoId: repository.repoId },
992
993
  'mcp:repository_safe_patch_apply',
993
- () => applySafePatch(repository, {
994
- sessionId: args.session_id,
995
- purpose: args.purpose,
996
- operations: args.operations,
997
- chunkSize: args.chunk_size,
998
- expectedRevision: args.expected_revision,
999
- allowedPaths: args.allowed_paths,
1000
- continueOnError: args.continue_on_error,
1001
- refreshFingerprints: args.refresh_fingerprints,
1002
- recoverStaleSession: args.recover_stale_session,
1003
- binding,
1004
- }),
994
+ () => {
995
+ if (binding?.workId) {
996
+ ensureRepositoryMutationWorkHandle({
997
+ controllerHome,
998
+ repository,
999
+ workId: binding.workId,
1000
+ principalId: binding.principalId ?? caller?.principalId ?? '',
1001
+ });
1002
+ }
1003
+ return applySafePatch(repository, {
1004
+ sessionId: args.session_id,
1005
+ purpose: args.purpose,
1006
+ operations: args.operations,
1007
+ chunkSize: args.chunk_size,
1008
+ expectedRevision: args.expected_revision,
1009
+ allowedPaths: args.allowed_paths,
1010
+ continueOnError: args.continue_on_error,
1011
+ refreshFingerprints: args.refresh_fingerprints,
1012
+ recoverStaleSession: args.recover_stale_session,
1013
+ binding,
1014
+ });
1015
+ },
1005
1016
  60_000,
1006
1017
  );
1007
1018
  const changedFiles = [
@@ -1095,6 +1106,23 @@ export async function callRepositoryTool(
1095
1106
  if (deliveryWorkId) {
1096
1107
  throw new Error(`WORK_DELIVERY_REQUIRES_FINALIZE: ${deliveryWorkId} must pass Work verification and use rh_work finalize before default-branch integration`);
1097
1108
  }
1109
+ if (executionIdentity.workId) {
1110
+ const mutationClassification = classifyRepositoryCommand(args.command as string | string[], repository.defaultBranch);
1111
+ if (mutationClassification.risk === 'workspace_write' || mutationClassification.risk === 'destructive') {
1112
+ withControllerLock(
1113
+ controllerHome,
1114
+ { scope: 'repository', repoId: repository.repoId },
1115
+ 'mcp:repository_command_execute:mutation-authority',
1116
+ () => ensureRepositoryMutationWorkHandle({
1117
+ controllerHome,
1118
+ repository,
1119
+ workId: executionIdentity.workId!,
1120
+ principalId: caller?.principalId ?? '',
1121
+ }),
1122
+ 60_000,
1123
+ );
1124
+ }
1125
+ }
1098
1126
  const timeoutMs = typeof args.timeout_ms === 'number'
1099
1127
  ? args.timeout_ms
1100
1128
  : typeof args.timeout_ms === 'string'
@@ -1,4 +1,6 @@
1
1
  import { createHash } from 'crypto';
2
+ import { existsSync } from 'fs';
3
+ import { resolve } from 'path';
2
4
  import { getEditSession, listEditSessions, type EditSession } from '../../../cli/editing/edit-session';
3
5
  import { runProcess } from '../../../effects/process-runner';
4
6
  import { getWorkContract, updateWorkContract } from '../facade/work-contract-store';
@@ -184,6 +186,21 @@ function exactCommit(repoRoot: string, revision: string, label: string): string
184
186
  return result.stdout.trim();
185
187
  }
186
188
 
189
+ function assertRemoteContainmentIfApplicable(repoRoot: string, targetBranch: string, targetRevision: string): void {
190
+ const remoteResult = git(repoRoot, ['config', '--get', `branch.${targetBranch}.remote`]);
191
+ const remote = remoteResult.ok ? remoteResult.stdout.trim() : '';
192
+ if (!remote || remote === '.') return;
193
+ const mergeResult = git(repoRoot, ['config', '--get', `branch.${targetBranch}.merge`]);
194
+ const mergeRef = mergeResult.ok ? mergeResult.stdout.trim() : '';
195
+ if (!mergeRef.startsWith('refs/heads/')) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_REMOTE_TRACKING_REQUIRED');
196
+ const remoteRef = `refs/remotes/${remote}/${mergeRef.slice('refs/heads/'.length)}`;
197
+ const remoteRevision = git(repoRoot, ['rev-parse', '--verify', `${remoteRef}^{commit}`]);
198
+ if (!remoteRevision.ok || !remoteRevision.stdout.trim()) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_REMOTE_TRACKING_REQUIRED');
199
+ if (!git(repoRoot, ['merge-base', '--is-ancestor', targetRevision, remoteRevision.stdout.trim()]).ok) {
200
+ throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_REMOTE_CONTAINMENT_REQUIRED');
201
+ }
202
+ }
203
+
187
204
  function comparedRevisionPaths(repoRoot: string, baseRevision: string, targetRevision: string): string[] {
188
205
  const result = git(repoRoot, ['diff', '--name-only', '-z', baseRevision, targetRevision]);
189
206
  if (!result.ok) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_CHANGED_PATHS_UNAVAILABLE');
@@ -198,6 +215,20 @@ function normalizedComparedPaths(paths: string[]): string[] {
198
215
  return normalized;
199
216
  }
200
217
 
218
+ function unresolvedRepositorySourcePaths(repoRoot: string): string[] {
219
+ const tracked = [
220
+ git(repoRoot, ['diff', '--name-only', '-z']),
221
+ git(repoRoot, ['diff', '--cached', '--name-only', '-z']),
222
+ ];
223
+ if (tracked.some((result) => !result.ok)) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_EFFECT_SOURCE_STATUS_UNAVAILABLE');
224
+ const untracked = git(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']);
225
+ if (!untracked.ok) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_EFFECT_SOURCE_STATUS_UNAVAILABLE');
226
+ return [...new Set([
227
+ ...tracked.flatMap((result) => result.stdout.split('\0').filter(Boolean)),
228
+ ...untracked.stdout.split('\0').filter((path) => path && !path.startsWith('.ai/harness/')),
229
+ ])].sort((left, right) => left.localeCompare(right));
230
+ }
231
+
201
232
  /**
202
233
  * Explicitly close a historically delivered Direct Edit Work whose original
203
234
  * edit-session binding was missed. This is never inferred: the caller must
@@ -219,18 +250,24 @@ export function acceptReviewedDirectEditWorkReconciliation(input: ReviewedDirect
219
250
  }
220
251
  throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_WORK_TERMINAL: ${input.workId}`);
221
252
  }
222
- const failedHandle = work.status === 'failed'
223
- ? readWorkHandle(input.controllerHome, input.repoId, input.workId)
224
- : undefined;
253
+ const currentHandle = readWorkHandle(input.controllerHome, input.repoId, input.workId);
254
+ const failedHandle = work.status === 'failed' ? currentHandle : undefined;
225
255
  const failedReviewedRecovery = work.workKind === 'repository_change'
226
256
  && work.status === 'failed'
227
257
  && failedHandle?.managedWorktree === false
228
258
  && failedHandle.state === 'failed'
229
259
  && failedHandle.finalization.validation === 'failed'
230
260
  && String(failedHandle.finalization.lastError ?? failedHandle.failureReason ?? '').includes('WORK_HANDLE_HEAD_CHANGED');
231
- if ((isTerminalWorkContractStatus(work.status) && !failedReviewedRecovery) || work.workKind !== 'repository_change') {
261
+ const historicalEffectRecovery = !currentHandle
262
+ && !isTerminalWorkContractStatus(work.status)
263
+ && (work.workKind === 'local_effect' || work.workKind === 'remote_effect');
264
+ if ((isTerminalWorkContractStatus(work.status) && !failedReviewedRecovery)
265
+ || (work.workKind !== 'repository_change' && !historicalEffectRecovery)) {
232
266
  throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_WORK_NOT_ELIGIBLE: ${input.workId}`);
233
267
  }
268
+ if (historicalEffectRecovery && work.checks.length === 0) {
269
+ throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_CHECK_EVIDENCE_REQUIRED');
270
+ }
234
271
  if (!work.baseRevision?.trim()) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_BASE_REVISION_MISSING');
235
272
 
236
273
  const baseRevision = exactCommit(input.repoRoot, work.baseRevision, 'BASE_REVISION');
@@ -243,6 +280,7 @@ export function acceptReviewedDirectEditWorkReconciliation(input: ReviewedDirect
243
280
  if (!git(input.repoRoot, ['merge-base', '--is-ancestor', targetRevision, targetBranch]).ok) {
244
281
  throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_TARGET_UNREACHABLE');
245
282
  }
283
+ assertRemoteContainmentIfApplicable(input.repoRoot, targetBranch, targetRevision);
246
284
 
247
285
  const comparedPaths = normalizedComparedPaths(input.comparedPaths);
248
286
  let comparisonBaseRevision = baseRevision;
@@ -263,6 +301,15 @@ export function acceptReviewedDirectEditWorkReconciliation(input: ReviewedDirect
263
301
  });
264
302
  const dirty = git(input.repoRoot, ['status', '--porcelain=v1', '--', ...comparedPaths]);
265
303
  if (!dirty.ok || dirty.stdout.trim()) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_OWNED_PATHS_DIRTY');
304
+ if (historicalEffectRecovery) {
305
+ const separateOwnedWorktree = Boolean(work.worktreeRef?.trim())
306
+ && resolve(work.worktreeRef!) !== resolve(input.repoRoot)
307
+ && existsSync(work.worktreeRef!);
308
+ if (separateOwnedWorktree) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_EFFECT_CLEANUP_REQUIRED');
309
+ if (unresolvedRepositorySourcePaths(input.repoRoot).length > 0) {
310
+ throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_EFFECT_SOURCE_DELTA_UNRESOLVED');
311
+ }
312
+ }
266
313
 
267
314
  let verifiedAt = new Date().toISOString();
268
315
  for (const checkId of work.checks) {
@@ -2,7 +2,9 @@ import { resolve } from 'path';
2
2
  import type { RepositoryRecord } from '../../../cli/repositories/types';
3
3
  import { resolveRepositorySelection, selectRepositoryCheckout } from '../../../cli/repositories/registry';
4
4
  import { repositoryGitStatus } from '../../../cli/repositories/structured-git';
5
- import { getWorkContract } from '../facade/work-contract-store';
5
+ import { getWorkContract, updateWorkContract } from '../facade/work-contract-store';
6
+ import { controllerSessionPrincipalId, getControllerSession } from '../facade/controller-session-store';
7
+ import { isTerminalWorkContractStatus } from '../facade/types';
6
8
  import { currentPermissionSnapshotVersion } from './validation';
7
9
  import { readWorkHandle, writeWorkHandle, type WorkHandleState } from './work-handle-store';
8
10
 
@@ -74,6 +76,58 @@ export function ensureRepositoryWorkHandle(input: {
74
76
  });
75
77
  }
76
78
 
79
+ /**
80
+ * Upgrades an effect-only Work before the first governed repository mutation
81
+ * and materializes the existing repository delivery authority. The durable
82
+ * ControllerSession is the ownership source; the MCP transport session is not
83
+ * allowed to become a second mutation authority.
84
+ */
85
+ export function ensureRepositoryMutationWorkHandle(input: {
86
+ controllerHome: string;
87
+ repository: RepositoryRecord;
88
+ workId: string;
89
+ principalId: string;
90
+ }): { handle: WorkHandleState; promotedFrom?: 'local_effect' | 'remote_effect' } {
91
+ const store = { controllerHome: input.controllerHome, repoId: input.repository.repoId };
92
+ let contract = getWorkContract(store, input.workId);
93
+ if (!contract) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
94
+ if (isTerminalWorkContractStatus(contract.status) || contract.completionReceipt) {
95
+ throw new Error(`WORK_REPOSITORY_MUTATION_TERMINAL: ${input.workId}`);
96
+ }
97
+ const principalId = input.principalId.trim();
98
+ if (!principalId) throw new Error(`WORK_CONTROLLER_AUTHENTICATED_PRINCIPAL_REQUIRED: ${input.workId}`);
99
+ const owner = getControllerSession(store, input.workId);
100
+ if (!owner) throw new Error(`WORK_CONTROLLER_CLAIM_REQUIRED: ${input.workId}`);
101
+ if (controllerSessionPrincipalId(owner) !== principalId) {
102
+ throw new Error(`WORK_CONTROLLER_OWNERSHIP_MISMATCH: ${input.workId}`);
103
+ }
104
+
105
+ if (!contract.checkoutId) {
106
+ if (contract.worktreeRef?.trim()) {
107
+ throw new Error(`WORK_REPOSITORY_MUTATION_CHECKOUT_REQUIRED: ${input.workId}`);
108
+ }
109
+ contract = updateWorkContract(store, input.workId, { checkoutId: input.repository.activeCheckoutId });
110
+ }
111
+
112
+ let promotedFrom: 'local_effect' | 'remote_effect' | undefined;
113
+ if (contract.workKind === 'local_effect' || contract.workKind === 'remote_effect') {
114
+ promotedFrom = contract.workKind;
115
+ contract = updateWorkContract(store, input.workId, { workKind: 'repository_change' });
116
+ }
117
+ if (contract.workKind !== 'repository_change') {
118
+ throw new Error(`WORK_REPOSITORY_MUTATION_KIND_INVALID: ${input.workId}:${contract.workKind}`);
119
+ }
120
+
121
+ const handle = ensureRepositoryWorkHandle({
122
+ controllerHome: input.controllerHome,
123
+ repository: input.repository,
124
+ workId: input.workId,
125
+ identity: { sessionId: owner.sessionId, principalId },
126
+ });
127
+ if (!handle) throw new Error(`WORK_REPOSITORY_MUTATION_HANDLE_REQUIRED: ${input.workId}`);
128
+ return { handle, ...(promotedFrom ? { promotedFrom } : {}) };
129
+ }
130
+
77
131
  /**
78
132
  * Rebinds the durable WorkHandle to the Controller session that has already
79
133
  * been admitted by the ControllerSession authority. The compare-and-swap
@@ -1106,18 +1106,22 @@ function authenticatedFacadeControllerIdentity(
1106
1106
  options: { allowTransportSessionRollover?: boolean } = {},
1107
1107
  ): { controllerId: string; principalId: string; sessionId: string; controllerInstanceId: string; controllerType: 'chatgpt' | 'codex' | 'claude' | 'grok' | 'human' } {
1108
1108
  const principalId = ctx.principalId?.trim();
1109
- const sessionId = ctx.sessionId?.trim();
1110
- if (!principalId || !sessionId) {
1111
- throw new Error('CONTROLLER_AUTHENTICATED_SESSION_REQUIRED: reconnect or call session_start through the authenticated MCP transport');
1112
- }
1109
+ const transportSessionId = ctx.sessionId?.trim();
1113
1110
  const requestedControllerId = typeof args.controller_id === 'string' ? args.controller_id.trim() : '';
1114
1111
  const requestedSessionId = typeof args.session_id === 'string' ? args.session_id.trim() : '';
1112
+ if (!principalId || (!transportSessionId && !requestedSessionId)) {
1113
+ throw new Error('CONTROLLER_AUTHENTICATED_SESSION_REQUIRED: reconnect or provide session_id through the authenticated MCP transport');
1114
+ }
1115
+ // The authenticated principal proves who is calling; session_id is the
1116
+ // Controller-owned opaque lease capability that keeps one conversation/round
1117
+ // stable across replaceable MCP transport sessions. Prefer that explicit
1118
+ // capability whenever supplied. A foreign same-principal conversation still
1119
+ // cannot rebind an existing Work unless it possesses the exact opaque session
1120
+ // (or the stronger relay-round capability checked by the caller).
1121
+ const sessionId = requestedSessionId || transportSessionId!;
1115
1122
  if (requestedControllerId && requestedControllerId !== principalId) {
1116
1123
  throw new Error('CONTROLLER_ID_CONTEXT_MISMATCH: controller_id must match the authenticated principal');
1117
1124
  }
1118
- if (!options.allowTransportSessionRollover && requestedSessionId && requestedSessionId !== sessionId) {
1119
- throw new Error('CONTROLLER_SESSION_CONTEXT_MISMATCH: session_id must match the authenticated MCP session');
1120
- }
1121
1125
  const requestedControllerType = typeof args.controller_type === 'string' && ['chatgpt', 'codex', 'claude', 'grok', 'human'].includes(args.controller_type)
1122
1126
  ? args.controller_type as 'chatgpt' | 'codex' | 'claude' | 'grok' | 'human'
1123
1127
  : undefined;
@@ -156,7 +156,8 @@ function launcherSource(input: { packageRoot: string; indexPath: string; indexSh
156
156
  + `const loader=join(packageRoot,'src','runtime','shared','node-ts-loader.mjs');\n`
157
157
  + `const args=process.versions?.bun?[entry,...process.argv.slice(2)]:['--loader',loader,entry,...process.argv.slice(2)];\n`
158
158
  + `const child=spawn(process.execPath,args,{stdio:'inherit',env:process.env});\n`
159
- + `for(const signal of ['SIGINT','SIGTERM','SIGHUP']){try{process.on(signal,()=>child.kill(signal));}catch{}}\n`
159
+ + `const managerSignalsProcessGroup=process.platform==='linux'&&Boolean(process.env.INVOCATION_ID);\n`
160
+ + `for(const signal of ['SIGINT','SIGTERM','SIGHUP']){try{process.on(signal,()=>{if(!managerSignalsProcessGroup)child.kill(signal);});}catch{}}\n`
160
161
  + `child.on('error',(error)=>{console.error('FORGE_PACKAGE_RUNTIME_LAUNCH_FAILED: '+error.message);process.exit(1);});\n`
161
162
  + `child.on('exit',(code)=>process.exit(code??1));\n`;
162
163
  }
@@ -176,6 +176,20 @@ function cleanRuntimeInstallerEnvironment(env: NodeJS.ProcessEnv, releaseEnviron
176
176
  return { ...next, ...releaseEnvironment };
177
177
  }
178
178
 
179
+ export function systemdRuntimeInstallCommands(unitName: string): string[][] {
180
+ // `enable --now` only starts an inactive unit. When Forge is already running,
181
+ // replacing the unit file and calling enable --now leaves the old ExecStart
182
+ // process alive even though release authority has advanced, fencing that
183
+ // process from writes. Enable and restart are intentionally separate so both
184
+ // first install and in-place immutable-release activation converge on the
185
+ // just-written unit while systemd remains the sole lifecycle owner.
186
+ return [
187
+ ['--user', 'daemon-reload'],
188
+ ['--user', 'enable', unitName],
189
+ ['--user', 'restart', unitName],
190
+ ];
191
+ }
192
+
179
193
  function installSystemdUserService(controllerHome: string, env: NodeJS.ProcessEnv): string {
180
194
  const entrypoint = activeRuntimeEntrypoint(controllerHome);
181
195
  const launch = activeRuntimeLaunchSpec(controllerHome);
@@ -184,7 +198,7 @@ function installSystemdUserService(controllerHome: string, env: NodeJS.ProcessEn
184
198
  const unitName = `${label}.service`;
185
199
  const unitPath = join(env.HOME ?? homedir(), '.config', 'systemd', 'user', unitName);
186
200
  atomicWrite(unitPath, renderForgeRuntimeSystemdUserUnit({ executable: entrypoint, args: launch.args, environment: launch.environment }), 0o644);
187
- for (const args of [['--user', 'daemon-reload'], ['--user', 'enable', '--now', unitName]]) {
201
+ for (const args of systemdRuntimeInstallCommands(unitName)) {
188
202
  const result = commandSucceeded('systemctl', args, env);
189
203
  if (result.status !== 0) throw new Error(`FORGE_RUNTIME_SYSTEMD_INSTALL_FAILED: systemctl ${args.join(' ')}: ${(result.stderr || result.stdout || '').trim()}`);
190
204
  }