@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 +13 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/skill-version.json +2 -2
- package/docs/operations/homebrew.md +2 -2
- package/package.json +1 -1
- package/src/cli/mcp/repository-tools.ts +40 -12
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +51 -4
- package/src/runtime/control-plane/execution/work-handle-authority.ts +55 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +11 -7
- package/src/runtime/root/package-runtime-release.ts +2 -1
- package/src/runtime/root/package-runtime-service.ts +15 -1
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
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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.
|
|
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
|
|
|
@@ -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.
|
|
10
|
-
- Git tag and GitHub Release `v1.7.
|
|
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.
|
|
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
|
-
() =>
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
+ `
|
|
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
|
|
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
|
}
|