mtech-ai-local-runner 0.1.72 → 0.1.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/src/adapters/claude-code/prompt-compiler.js +3 -1
- package/dist/src/adapters/codex/exec-invocation.js +24 -0
- package/dist/src/adapters/codex/exec-launch-host.js +5 -0
- package/dist/src/adapters/codex/exec-runtime-options.js +1 -1
- package/dist/src/adapters/codex/prompt-compiler.js +3 -1
- package/dist/src/asks/runtime.js +22 -1
- package/dist/src/cli.js +4 -0
- package/dist/src/commands/fix.js +106 -26
- package/dist/src/daemon/execution-coordinator.js +65 -9
- package/dist/src/daemon/fix-consent.js +98 -0
- package/dist/src/daemon/manual-codex.js +2 -2
- package/dist/src/daemon/runtime.js +31 -5
- package/dist/src/gateway-client.js +88 -5
- package/dist/src/protocol.js +33 -0
- package/dist/src/publication/server-ci-host.js +5 -6
- package/dist/src/rooms/activity.js +84 -0
- package/dist/src/rooms/notifier.js +21 -0
- package/dist/src/rooms/store.js +56 -0
- package/dist/src/safety/claude-managed-scope.js +7 -19
- package/dist/src/safety/claude-release.js +0 -2
- package/dist/src/safety/claude-review-evidence.js +2 -2
- package/dist/src/safety/codex-exec-production-host.js +15 -2
- package/dist/src/safety/job-tool-policy.js +4 -3
- package/dist/src/transport/sync-dispatch.js +9 -0
- package/dist/src/verification/continuation-runner-contract.js +228 -0
- package/dist/src/verification/p1-execution-host.js +25 -6
- package/dist/src/verification/p1-host-composition.js +2 -0
- package/dist/src/verification/reviewed-runner-contract.js +7 -0
- package/dist/src/verification/runner-observed-verifier.js +29 -5
- package/dist/src/workspace/registered-submodules.js +204 -0
- package/dist/src/workspace/workspace-manager.js +128 -18
- package/package.json +1 -1
|
@@ -34,6 +34,9 @@ import { packagedHookCommandParts } from "../safety/hook-command.js";
|
|
|
34
34
|
import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
|
|
35
35
|
import { AskRuntime, withAsks } from '../asks/runtime.js';
|
|
36
36
|
import { GuideRuntime, withGuide } from '../guide/runtime.js';
|
|
37
|
+
import { FixConsentRuntime } from './fix-consent.js';
|
|
38
|
+
import { RoomActivityRuntime } from '../rooms/activity.js';
|
|
39
|
+
import { notifyRoomActivity } from '../rooms/notifier.js';
|
|
37
40
|
export async function buildDaemon(input) {
|
|
38
41
|
const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
|
|
39
42
|
if (claudeParts !== 0 && claudeParts !== 3)
|
|
@@ -62,8 +65,11 @@ export async function buildDaemon(input) {
|
|
|
62
65
|
pendingCodexClaim.job.lease_expires_at = expiresAt;
|
|
63
66
|
};
|
|
64
67
|
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
68
|
+
const roomActivity = new RoomActivityRuntime({ stateRoot: input.stateRoot,
|
|
69
|
+
read: (cursor, limit) => gateway.readRoomActivity(cursor, limit), notify: notifyRoomActivity });
|
|
65
70
|
// AI-first ask delivery (Work Item e1c08b33) rides on the session-open loop below; it starts no executor.
|
|
66
71
|
const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env,
|
|
72
|
+
claimRoomNotice: () => roomActivity.claimNotice(),
|
|
67
73
|
report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal),
|
|
68
74
|
log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
|
|
69
75
|
// Guide hooks (Work Item d38d60f9) ride on the same sync and heartbeat; they start no executor.
|
|
@@ -120,7 +126,7 @@ export async function buildDaemon(input) {
|
|
|
120
126
|
taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
|
|
121
127
|
createBinding: async (job, signal) => gateway.createLocalJobBinding({
|
|
122
128
|
jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
|
|
123
|
-
launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
|
|
129
|
+
launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
|
|
124
130
|
}, signal),
|
|
125
131
|
publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
|
|
126
132
|
syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
|
|
@@ -144,7 +150,7 @@ export async function buildDaemon(input) {
|
|
|
144
150
|
}
|
|
145
151
|
return registry.select(id, version, options);
|
|
146
152
|
} },
|
|
147
|
-
workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
|
|
153
|
+
workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient(), registeredSubmodules: p1Host?.registeredSubmodules }),
|
|
148
154
|
state,
|
|
149
155
|
});
|
|
150
156
|
let transport;
|
|
@@ -166,6 +172,7 @@ export async function buildDaemon(input) {
|
|
|
166
172
|
launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
|
|
167
173
|
gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
|
|
168
174
|
leaseExpiresAt: claim.job.lease_expires_at,
|
|
175
|
+
executionAttempt: claim.attempt_context, executionAttemptFingerprint: claim.attempt_context_fingerprint,
|
|
169
176
|
};
|
|
170
177
|
await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
|
|
171
178
|
return { status: "claimed", job };
|
|
@@ -254,6 +261,12 @@ export async function buildDaemon(input) {
|
|
|
254
261
|
});
|
|
255
262
|
const stopping = new AbortController();
|
|
256
263
|
const closureAcks = new PendingClosureAcks();
|
|
264
|
+
// Slack Fix consent (Work Item 6f9e64ae, decision baf0cd89): applied for this runner only while idle; a changed host
|
|
265
|
+
// restarts the daemon through its graceful stop, and launchd (KeepAlive) starts it again with the new host.
|
|
266
|
+
const fixConsent = new FixConsentRuntime({ stateRoot: input.stateRoot, runnerId: input.runnerId,
|
|
267
|
+
report: () => localFixHost?.report, jobRunning: () => poller.running,
|
|
268
|
+
restart: () => { daemon.stop(); },
|
|
269
|
+
log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
|
|
257
270
|
const opener = new SessionOpener({ home: input.env.HOME ?? "" });
|
|
258
271
|
const dispatch = createSyncDispatcher({
|
|
259
272
|
open: request => opener.open(request),
|
|
@@ -263,9 +276,12 @@ export async function buildDaemon(input) {
|
|
|
263
276
|
},
|
|
264
277
|
guide,
|
|
265
278
|
...(claudeProduction ? { claude: claudeProduction } : {}),
|
|
279
|
+
fixConsent,
|
|
266
280
|
asks,
|
|
267
281
|
closureAcks,
|
|
268
|
-
|
|
282
|
+
// While a changed Fix host waits for its restart, the next runner claims the job instead.
|
|
283
|
+
jobs: { notify: () => { if (!fixConsent.restartDue)
|
|
284
|
+
poller.notify(); } },
|
|
269
285
|
delay: abortableDelay,
|
|
270
286
|
signal: stopping.signal,
|
|
271
287
|
reportError: diagnostic => { process.stderr.write(`${JSON.stringify({ event: "session_open_poller_failed", ...diagnostic })}\n`); },
|
|
@@ -275,7 +291,7 @@ export async function buildDaemon(input) {
|
|
|
275
291
|
const acknowledged = closureAcks.take();
|
|
276
292
|
// A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
|
|
277
293
|
try {
|
|
278
|
-
return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion() }, signal);
|
|
294
|
+
return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion(), roomActivityV1: true }, signal);
|
|
279
295
|
}
|
|
280
296
|
catch (error) {
|
|
281
297
|
closureAcks.restore(acknowledged);
|
|
@@ -283,7 +299,17 @@ export async function buildDaemon(input) {
|
|
|
283
299
|
}
|
|
284
300
|
},
|
|
285
301
|
authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
|
|
286
|
-
dispatch
|
|
302
|
+
dispatch: async (result) => {
|
|
303
|
+
void roomActivity.handleSync(result.roomActivityEnabled === true).catch(() => {
|
|
304
|
+
process.stderr.write(`${JSON.stringify({ event: 'room_activity_scan_failed' })}\n`);
|
|
305
|
+
});
|
|
306
|
+
try {
|
|
307
|
+
await dispatch(result);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await fixConsent.afterSync(result);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
287
313
|
openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
|
|
288
314
|
report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
|
|
289
315
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt, CONTINUATION_SCHEMA, CONTINUATION_POLICY } from './verification/continuation-runner-contract.js';
|
|
1
2
|
import { publicationError } from './publication/failure.js';
|
|
2
3
|
import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
|
|
3
4
|
import { hasP1 } from './verification/p1-execution-host.js';
|
|
@@ -17,6 +18,29 @@ export class GatewayToolError extends Error {
|
|
|
17
18
|
this.code = code;
|
|
18
19
|
}
|
|
19
20
|
}
|
|
21
|
+
const ROOM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
const FIX_CONSENT_GRANT_ID = /^[a-zA-Z0-9-]{1,64}$/;
|
|
23
|
+
const FIX_CONSENT_REPOSITORY_KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
24
|
+
const FIX_CONSENT_GRANTS_MAX = 20;
|
|
25
|
+
/** Keeps the well-formed grants, at most 20; a malformed entry is dropped, never a reason to refuse the sync. */
|
|
26
|
+
function parseFixConsentGrants(value) {
|
|
27
|
+
const grants = [];
|
|
28
|
+
for (const item of value) {
|
|
29
|
+
if (grants.length >= FIX_CONSENT_GRANTS_MAX)
|
|
30
|
+
break;
|
|
31
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
32
|
+
continue;
|
|
33
|
+
const { id, repository_key: repositoryKey, verification_mode: verificationMode } = item;
|
|
34
|
+
if (typeof id !== "string" || !FIX_CONSENT_GRANT_ID.test(id))
|
|
35
|
+
continue;
|
|
36
|
+
if (typeof repositoryKey !== "string" || !FIX_CONSENT_REPOSITORY_KEY.test(repositoryKey))
|
|
37
|
+
continue;
|
|
38
|
+
if (verificationMode !== "gateway_publish_v1" && verificationMode !== "gateway_ci_v1")
|
|
39
|
+
continue;
|
|
40
|
+
grants.push({ id, repositoryKey, verificationMode });
|
|
41
|
+
}
|
|
42
|
+
return grants;
|
|
43
|
+
}
|
|
20
44
|
const PUSH_CHANNEL = /^private-ai-gateway\.[a-z0-9_-]{1,32}\.runner\.[a-z0-9:_-]{1,128}$/;
|
|
21
45
|
const PUSH_APP_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
22
46
|
const CHANNEL_AUTH = /^[^:\s]{1,128}:[a-f0-9]{64}$/;
|
|
@@ -38,6 +62,8 @@ export function parseRunnerSync(result, runnerId) {
|
|
|
38
62
|
...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
|
|
39
63
|
...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
|
|
40
64
|
...(body.ask_alert === true ? { askAlert: true } : {}),
|
|
65
|
+
...(body.room_activity_enabled === true ? { roomActivityEnabled: true } : {}),
|
|
66
|
+
...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
|
|
41
67
|
jobAvailable: body.job_available === true,
|
|
42
68
|
push: parsePush(body.push, runnerId),
|
|
43
69
|
syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
|
|
@@ -93,7 +119,7 @@ export function shadowTaskPackage(contract) {
|
|
|
93
119
|
const verification = hasP1(payload) ? payload.verification : undefined;
|
|
94
120
|
if (hasP1(payload)) {
|
|
95
121
|
const resolved = resolveRunnerVerification(verification);
|
|
96
|
-
if (resolved.version
|
|
122
|
+
if (resolved.version !== 1 && (nested(payload, 'policy.policy_key') !== (resolved.version === 3 ? CONTINUATION_POLICY : REVIEWED_POLICY) || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
|
|
97
123
|
throw Error('P1_CONTRACT_REJECTED');
|
|
98
124
|
}
|
|
99
125
|
const repositories = (payload.repositories ?? []).map((repository) => ({
|
|
@@ -292,10 +318,14 @@ export class McpGatewayClient {
|
|
|
292
318
|
if (!this.checkoutPath) {
|
|
293
319
|
throw new Error("local checkout path is required before creating an execution binding");
|
|
294
320
|
}
|
|
295
|
-
const
|
|
321
|
+
const qualified = input.expectedJobId !== undefined || input.expectedAttemptNumber !== undefined;
|
|
322
|
+
if (qualified && (typeof input.expectedJobId !== 'string' || !input.expectedJobId.trim() || !Number.isSafeInteger(input.expectedAttemptNumber) || input.expectedAttemptNumber < 1 || input.expectedAttemptNumber > 3))
|
|
323
|
+
throw new GatewayToolError('Qualified next identity is invalid', 'work_loop_next', 'CORRECTION_LINEAGE_MISMATCH');
|
|
324
|
+
const nextArguments = { work_loop_id: input.workLoopId, ...(qualified ? { expected_job_id: input.expectedJobId, expected_attempt_number: input.expectedAttemptNumber } : {}) };
|
|
325
|
+
const useProvidedLease = Boolean(this.initialLeaseToken) && !input.claimNext && !qualified;
|
|
296
326
|
const claim = useProvidedLease
|
|
297
327
|
? await this.callTool("work_loop_show", { work_loop_id: input.workLoopId })
|
|
298
|
-
: await this.callTool("work_loop_next",
|
|
328
|
+
: await this.callTool("work_loop_next", nextArguments);
|
|
299
329
|
const leaseToken = useProvidedLease
|
|
300
330
|
? this.initialLeaseToken
|
|
301
331
|
: requiredString(claim.lease_token, "lease token");
|
|
@@ -482,12 +512,25 @@ export class McpGatewayClient {
|
|
|
482
512
|
return { leaseExpiresAt: context.leaseExpiresAt };
|
|
483
513
|
}
|
|
484
514
|
async waitForLocalJob(adapterVersions, waitSeconds = 25, jobId) {
|
|
485
|
-
|
|
515
|
+
const claim = await this.callTool("local_agent_jobs_wait", {
|
|
486
516
|
runner_id: this.runnerId,
|
|
487
517
|
adapter_versions: adapterVersions,
|
|
488
518
|
...(jobId ? { job_id: jobId } : {}),
|
|
489
519
|
wait_seconds: Math.max(0, Math.min(30, waitSeconds)),
|
|
490
520
|
});
|
|
521
|
+
if (['continuation', 'execution_attempt', 'execution_attempt_fingerprint'].some(key => Object.hasOwn(claim, key)))
|
|
522
|
+
throw new GatewayToolError('Unexpected claimed attempt identity', 'local_agent_jobs_wait', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
523
|
+
if (claim.status === 'claimed' && claim.task_package && claim.job) {
|
|
524
|
+
try {
|
|
525
|
+
assertClaimedExecutionAttempt({ id: claim.job.id, generation: claim.job.generation, workLoopId: claim.job.work_loop_id, launchFingerprint: claim.job.launch_fingerprint, launchGrant: claim.launch_grant ?? '', leaseToken: claim.lease_token ?? '', taskPackage: claim.task_package, executionAttempt: claim.attempt_context, executionAttemptFingerprint: claim.attempt_context_fingerprint });
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
throw new GatewayToolError('Claimed attempt identity is invalid', 'local_agent_jobs_wait', 'CORRECTION_EXECUTION_UNAVAILABLE');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else if (claim.attempt_context !== undefined || claim.attempt_context_fingerprint !== undefined)
|
|
532
|
+
throw new GatewayToolError('Unexpected claimed attempt identity', 'local_agent_jobs_wait', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
533
|
+
return claim;
|
|
491
534
|
}
|
|
492
535
|
async registerLocalRunner(input) {
|
|
493
536
|
return this.callTool("local_agent_runner_register", {
|
|
@@ -564,9 +607,40 @@ export class McpGatewayClient {
|
|
|
564
607
|
reason: input.reason,
|
|
565
608
|
...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
|
|
566
609
|
...(input.guidancePolicyVersion ? { guidance_policy_version: input.guidancePolicyVersion } : {}),
|
|
610
|
+
...(input.roomActivityV1 ? { room_activity_v1: true } : {}),
|
|
567
611
|
}, signal);
|
|
568
612
|
return parseRunnerSync(result, this.runnerId);
|
|
569
613
|
}
|
|
614
|
+
async readRoomActivity(afterRoomId = '', limit = 50, signal) {
|
|
615
|
+
if ((afterRoomId !== '' && !ROOM_ID.test(afterRoomId)) || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
616
|
+
throw new GatewayToolError('Invalid Room activity query', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
617
|
+
}
|
|
618
|
+
const result = await this.callTool('work_room_read', { operation: 'activity', after_room_id: afterRoomId, limit, share_only: true }, signal);
|
|
619
|
+
const body = result && typeof result === 'object' && !Array.isArray(result) ? result : undefined;
|
|
620
|
+
if (!body || !Array.isArray(body.rooms) || body.rooms.length > limit
|
|
621
|
+
|| !(body.next_cursor === null || (typeof body.next_cursor === 'string' && ROOM_ID.test(body.next_cursor)))) {
|
|
622
|
+
throw new GatewayToolError('Invalid Room activity page', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
623
|
+
}
|
|
624
|
+
const rooms = body.rooms.map(row => {
|
|
625
|
+
const item = row && typeof row === 'object' && !Array.isArray(row) ? row : {};
|
|
626
|
+
if (typeof item.room_id !== 'string' || !ROOM_ID.test(item.room_id)
|
|
627
|
+
|| !Number.isSafeInteger(item.unread_count) || item.unread_count < 0
|
|
628
|
+
|| !Number.isSafeInteger(item.latest_message_sequence) || item.latest_message_sequence < 0) {
|
|
629
|
+
throw new GatewayToolError('Invalid Room activity entry', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
630
|
+
}
|
|
631
|
+
return { roomId: item.room_id, unreadCount: item.unread_count, latestMessageSequence: item.latest_message_sequence };
|
|
632
|
+
});
|
|
633
|
+
let previous = afterRoomId;
|
|
634
|
+
for (const room of rooms) {
|
|
635
|
+
if (room.roomId <= previous)
|
|
636
|
+
throw new GatewayToolError('Room activity order is invalid', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
637
|
+
previous = room.roomId;
|
|
638
|
+
}
|
|
639
|
+
if (body.next_cursor !== null && (rooms.length === 0 || body.next_cursor !== previous)) {
|
|
640
|
+
throw new GatewayToolError('Room activity cursor is invalid', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
|
|
641
|
+
}
|
|
642
|
+
return { rooms, nextCursor: body.next_cursor };
|
|
643
|
+
}
|
|
570
644
|
async authorizeRunnerChannel(socketId, signal) {
|
|
571
645
|
const result = await this.callTool("local_agent_runner_channel_auth", {
|
|
572
646
|
runner_id: this.runnerId,
|
|
@@ -617,6 +691,15 @@ export class McpGatewayClient {
|
|
|
617
691
|
const bindingId = requiredString(result.binding.id, "binding ID");
|
|
618
692
|
const iterationId = requiredString(result.binding.iteration_id, "iteration ID");
|
|
619
693
|
signal?.throwIfAborted();
|
|
694
|
+
if (input.claimedJob) {
|
|
695
|
+
if (input.claimedJob.id !== input.jobId || input.claimedJob.generation !== input.generation || input.claimedJob.workLoopId !== input.workLoopId || input.claimedJob.leaseToken !== input.jobLeaseToken || input.claimedJob.launchGrant !== input.launchGrant || result.work_loop_id !== input.workLoopId)
|
|
696
|
+
throw Error('CORRECTION_LINEAGE_MISMATCH');
|
|
697
|
+
assertBoundExecutionAttempt(input.claimedJob, result.bundle, iterationId);
|
|
698
|
+
if (input.claimedJob.executionAttempt && typeof result.binding_replayed !== 'boolean')
|
|
699
|
+
throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
|
|
700
|
+
}
|
|
701
|
+
else if (Object.hasOwn(result.bundle, 'attempt_context') || Object.hasOwn(result.bundle, 'attempt_context_fingerprint') || nested(result.bundle, 'contract.verification.schema_version') === CONTINUATION_SCHEMA)
|
|
702
|
+
throw Error('CORRECTION_LINEAGE_MISMATCH');
|
|
620
703
|
this.context = {
|
|
621
704
|
jobFence: { job_id: input.jobId, generation: input.generation, lease_token: input.jobLeaseToken },
|
|
622
705
|
workLoopId: requiredString(result.work_loop_id, "Work Loop ID"),
|
|
@@ -630,7 +713,7 @@ export class McpGatewayClient {
|
|
|
630
713
|
runtimeAdapterVersion: requiredString(result.binding.runtime_adapter_version, "runtime adapter version"),
|
|
631
714
|
};
|
|
632
715
|
return {
|
|
633
|
-
bindingId,
|
|
716
|
+
bindingId, iterationId, ...(input.claimedJob?.executionAttempt ? { bindingReplayed: result.binding_replayed } : {}),
|
|
634
717
|
taskPackage: result.bundle,
|
|
635
718
|
resolvedModelId: requiredString(result.binding.resolved_model_id, "resolved model ID"),
|
|
636
719
|
};
|
package/dist/src/protocol.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateAttemptContext, attemptContextFingerprint } from './verification/continuation-runner-contract.js';
|
|
2
|
+
import { protocolHash } from './publication/candidate.js';
|
|
1
3
|
export const PROTOCOL_VERSION = "agent-execution-v1";
|
|
2
4
|
export const ROLES = ["planner", "supervisor", "executor", "reviewer"];
|
|
3
5
|
export const EVENT_TYPES = [
|
|
@@ -73,3 +75,34 @@ export class TerminalGuard {
|
|
|
73
75
|
return this.terminalSeen !== null;
|
|
74
76
|
}
|
|
75
77
|
}
|
|
78
|
+
/** Wire-domain/identity check only. Scope, measured command results and grant authority
|
|
79
|
+
* are verified by their owners; a syntactically valid payload is not completion proof. */
|
|
80
|
+
export function validateAttemptEvidence(value, expected) {
|
|
81
|
+
const c = validateAttemptContext(expected), fp = attemptContextFingerprint(c);
|
|
82
|
+
const reject = () => { throw Error('CORRECTION_LINEAGE_MISMATCH'); };
|
|
83
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
84
|
+
reject();
|
|
85
|
+
const p = value, m = p.changed_paths, receipts = p.receipts;
|
|
86
|
+
const hex = (v, n) => typeof v === 'string' && new RegExp(`^[a-f0-9]{${n}}$`).test(v);
|
|
87
|
+
if (!hex(p.candidate_commit_sha, 40) || !hex(p.snapshot_fingerprint, 64) || !m || m.schema_version !== 'runner-changed-paths-v2' || m.digest_domain !== 'p1-changed-paths-v2' || m.attempt_context_fingerprint !== fp || m.base_commit_sha !== c.attempt_base_commit_sha || m.candidate_commit_sha !== p.candidate_commit_sha || !Array.isArray(m.entries) || !m.entries.length || !Array.isArray(receipts) || !receipts.length)
|
|
88
|
+
reject();
|
|
89
|
+
const { manifest_sha256, ...unsigned } = m;
|
|
90
|
+
if (manifest_sha256 !== protocolHash(unsigned))
|
|
91
|
+
reject();
|
|
92
|
+
for (const value of receipts) {
|
|
93
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
94
|
+
reject();
|
|
95
|
+
const r = value;
|
|
96
|
+
if (r.schema_version !== 'runner-command-receipt-v2' || r.attempt_context_fingerprint !== fp || r.job_id !== c.job_id || r.generation !== c.generation || r.contract_fingerprint !== c.contract_fingerprint || r.candidate_commit_sha !== p.candidate_commit_sha || r.snapshot_fingerprint !== p.snapshot_fingerprint)
|
|
97
|
+
reject();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Versioned host handoff check, distinct from ordinary terminal event transport. */
|
|
101
|
+
export function validateAttemptHandoff(value, expected) {
|
|
102
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
103
|
+
throw Error('CORRECTION_LINEAGE_MISMATCH');
|
|
104
|
+
const h = value, fp = attemptContextFingerprint(expected);
|
|
105
|
+
if (h.schema_version !== 'gateway-candidate-handoff-v2' || h.attempt_context_fingerprint !== fp || attemptContextFingerprint(h.attempt_context) !== fp)
|
|
106
|
+
throw Error('CORRECTION_LINEAGE_MISMATCH');
|
|
107
|
+
validateAttemptEvidence(h, expected);
|
|
108
|
+
}
|
|
@@ -101,8 +101,11 @@ export function createServerCiLocalFixHost(options) {
|
|
|
101
101
|
phase = 'current_fence';
|
|
102
102
|
await current();
|
|
103
103
|
host = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes: SNAPSHOT_TREE_MAX_BYTES, maxTotalBytes: SNAPSHOT_TREE_MAX_BYTES });
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// materialize captures once, checks live source again after copying, and seals every private byte.
|
|
105
|
+
// Use that same observation for limits and upload; a separate observe() repeats two full captures.
|
|
106
|
+
phase = 'snapshot_materialize';
|
|
107
|
+
seal = await host.materialize();
|
|
108
|
+
const observed = structuredClone(seal.observation);
|
|
106
109
|
phase = 'candidate_limits';
|
|
107
110
|
let split;
|
|
108
111
|
if (limits.version === 2) {
|
|
@@ -118,10 +121,6 @@ export function createServerCiLocalFixHost(options) {
|
|
|
118
121
|
throw publicationError(phase, 'LOCAL_FIX_DIFF_LIMIT_REJECTED');
|
|
119
122
|
if (!observed.changed_files.length || observed.changed_files.length > limits.max_changed_files)
|
|
120
123
|
throw publicationError(phase, 'LOCAL_FIX_FILE_COUNT_REJECTED');
|
|
121
|
-
phase = 'snapshot_materialize';
|
|
122
|
-
seal = await host.materialize();
|
|
123
|
-
if (protocolHash(observed) !== protocolHash(seal.observation))
|
|
124
|
-
throw denied();
|
|
125
124
|
// The v1 wire represents ordinary text bytes only. Refuse any change whose
|
|
126
125
|
// executable/type metadata would be lost by the provider's regular-file write.
|
|
127
126
|
phase = 'delta_format';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { RoomActivityStore } from './store.js';
|
|
2
|
+
export const ROOM_CHAT_LINE = 'AI Gateway notice: Tell the person new Work Room activity is available. Read the Room only if they ask; do not reply automatically.';
|
|
3
|
+
/** One actor-owned projection. Scans serialize so a signal and periodic sync cannot race the watermark file. */
|
|
4
|
+
export class RoomActivityRuntime {
|
|
5
|
+
deps;
|
|
6
|
+
store;
|
|
7
|
+
queue = Promise.resolve();
|
|
8
|
+
constructor(deps) {
|
|
9
|
+
this.deps = deps;
|
|
10
|
+
this.store = new RoomActivityStore(deps.stateRoot);
|
|
11
|
+
}
|
|
12
|
+
exclusive(work) {
|
|
13
|
+
const result = this.queue.then(work);
|
|
14
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
async handleSync(enabled) {
|
|
18
|
+
await this.exclusive(async () => {
|
|
19
|
+
const state = await this.store.load();
|
|
20
|
+
if (!enabled) {
|
|
21
|
+
if (state.pendingChat.length || state.pendingOs.length) {
|
|
22
|
+
state.pendingChat = [];
|
|
23
|
+
state.pendingOs = [];
|
|
24
|
+
await this.store.save(state);
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const rows = [];
|
|
29
|
+
const cursors = new Set();
|
|
30
|
+
let cursor = state.scanCursor ?? '';
|
|
31
|
+
let complete = false;
|
|
32
|
+
for (let page = 0; page < 20; page++) {
|
|
33
|
+
if (cursors.has(cursor))
|
|
34
|
+
throw new Error('ROOM_ACTIVITY_CURSOR_LOOP');
|
|
35
|
+
cursors.add(cursor);
|
|
36
|
+
const result = await this.deps.read(cursor, 50);
|
|
37
|
+
rows.push(...result.rooms);
|
|
38
|
+
if (result.nextCursor === null) {
|
|
39
|
+
cursor = '';
|
|
40
|
+
complete = true;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
cursor = result.nextCursor;
|
|
44
|
+
}
|
|
45
|
+
const visible = new Set([...(state.scanVisible ?? []), ...rows.map(row => row.roomId)]);
|
|
46
|
+
const next = {
|
|
47
|
+
version: 1, seen: complete ? {} : { ...state.seen },
|
|
48
|
+
pendingOs: complete ? state.pendingOs.filter(id => visible.has(id)) : [...state.pendingOs],
|
|
49
|
+
pendingChat: complete ? state.pendingChat.filter(id => visible.has(id)) : [...state.pendingChat],
|
|
50
|
+
...(complete ? {} : { scanCursor: cursor, scanVisible: [...visible] }),
|
|
51
|
+
};
|
|
52
|
+
if (complete) {
|
|
53
|
+
for (const id of visible)
|
|
54
|
+
if (id in state.seen)
|
|
55
|
+
next.seen[id] = state.seen[id];
|
|
56
|
+
}
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const previous = state.seen[row.roomId] ?? 0;
|
|
59
|
+
next.seen[row.roomId] = Math.max(previous, row.latestMessageSequence);
|
|
60
|
+
if (row.unreadCount > 0 && row.latestMessageSequence > previous) {
|
|
61
|
+
if (!next.pendingOs.includes(row.roomId))
|
|
62
|
+
next.pendingOs.push(row.roomId);
|
|
63
|
+
if (!next.pendingChat.includes(row.roomId))
|
|
64
|
+
next.pendingChat.push(row.roomId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await this.store.save(next);
|
|
68
|
+
if (next.pendingOs.length && await this.deps.notify()) {
|
|
69
|
+
next.pendingOs = [];
|
|
70
|
+
await this.store.save(next);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async claimNotice() {
|
|
75
|
+
return this.exclusive(async () => {
|
|
76
|
+
const state = await this.store.load();
|
|
77
|
+
if (!state.pendingChat.length)
|
|
78
|
+
return null;
|
|
79
|
+
state.pendingChat = [];
|
|
80
|
+
await this.store.save(state);
|
|
81
|
+
return ROOM_CHAT_LINE;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export const ROOM_OS_LINE = 'AI Gateway: new Work Room activity. Open your AI chat to review it.';
|
|
3
|
+
const SCRIPT = ['on run argv', 'display notification (item 1 of argv) with title "AI Gateway"', 'end run'];
|
|
4
|
+
export function createRoomNotifier(deps = {}) {
|
|
5
|
+
const run = deps.run ?? ((file, args) => new Promise((resolve, reject) => {
|
|
6
|
+
execFile(file, args, { timeout: 3000, killSignal: 'SIGKILL' }, error => error ? reject(error) : resolve());
|
|
7
|
+
}));
|
|
8
|
+
return async () => {
|
|
9
|
+
if ((deps.platform ?? process.platform) !== 'darwin')
|
|
10
|
+
return false;
|
|
11
|
+
try {
|
|
12
|
+
await run('/usr/bin/osascript', [...SCRIPT.flatMap(line => ['-e', line]), ROOM_OS_LINE]);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Generic macOS notice. No Room metadata enters the command line or error log. */
|
|
21
|
+
export const notifyRoomActivity = createRoomNotifier();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const EMPTY = () => ({ version: 1, seen: {}, pendingOs: [], pendingChat: [] });
|
|
5
|
+
const ROOM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
export class RoomActivityStore {
|
|
7
|
+
stateRoot;
|
|
8
|
+
path;
|
|
9
|
+
constructor(stateRoot) {
|
|
10
|
+
this.stateRoot = stateRoot;
|
|
11
|
+
this.path = join(stateRoot, 'room-activity.json');
|
|
12
|
+
}
|
|
13
|
+
async load() {
|
|
14
|
+
let content;
|
|
15
|
+
try {
|
|
16
|
+
content = await fs.readFile(this.path, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT')
|
|
20
|
+
return EMPTY();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
const raw = JSON.parse(content);
|
|
24
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
25
|
+
throw new Error('ROOM_ACTIVITY_STATE_INVALID');
|
|
26
|
+
const state = raw;
|
|
27
|
+
if (state.version !== 1 || !state.seen || typeof state.seen !== 'object' || Array.isArray(state.seen)
|
|
28
|
+
|| !Array.isArray(state.pendingOs) || !Array.isArray(state.pendingChat))
|
|
29
|
+
throw new Error('ROOM_ACTIVITY_STATE_INVALID');
|
|
30
|
+
for (const [id, sequence] of Object.entries(state.seen)) {
|
|
31
|
+
if (!ROOM_ID.test(id) || !Number.isSafeInteger(sequence) || sequence < 0)
|
|
32
|
+
throw new Error('ROOM_ACTIVITY_STATE_INVALID');
|
|
33
|
+
}
|
|
34
|
+
for (const id of [...state.pendingOs, ...state.pendingChat]) {
|
|
35
|
+
if (typeof id !== 'string' || !ROOM_ID.test(id) || !(id in state.seen))
|
|
36
|
+
throw new Error('ROOM_ACTIVITY_STATE_INVALID');
|
|
37
|
+
}
|
|
38
|
+
if (state.scanCursor !== undefined && (typeof state.scanCursor !== 'string' || !ROOM_ID.test(state.scanCursor)))
|
|
39
|
+
throw new Error('ROOM_ACTIVITY_STATE_INVALID');
|
|
40
|
+
if (state.scanVisible !== undefined && (!Array.isArray(state.scanVisible)
|
|
41
|
+
|| state.scanVisible.some(id => typeof id !== 'string' || !ROOM_ID.test(id))))
|
|
42
|
+
throw new Error('ROOM_ACTIVITY_STATE_INVALID');
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
async save(state) {
|
|
46
|
+
await fs.mkdir(this.stateRoot, { recursive: true, mode: 0o700 });
|
|
47
|
+
const temp = `${this.path}.${randomUUID()}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
await fs.writeFile(temp, JSON.stringify(state), { mode: 0o600, flag: 'wx' });
|
|
50
|
+
await fs.rename(temp, this.path);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await fs.rm(temp, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { lstat, readdir } from 'node:fs/promises';
|
|
4
|
-
import {
|
|
5
|
-
import { CLAUDE_MANAGED_PREFERENCE_DOMAIN, CLAUDE_MANAGED_SETTINGS_DIRECTORY, CLAUDE_MANAGED_SETTINGS_FILES, CLAUDE_POLICY_OBSERVER, claudeCanonicalSha256 } from './claude-release.js';
|
|
4
|
+
import { CLAUDE_MANAGED_PREFERENCE_DOMAIN, CLAUDE_MANAGED_SETTINGS_DIRECTORY, CLAUDE_MANAGED_SETTINGS_FILES, claudeCanonicalSha256 } from './claude-release.js';
|
|
6
5
|
const unverified = () => Error('CLAUDE_MANAGED_SCOPE_UNVERIFIED');
|
|
7
6
|
/** Claude managed sources the Runner checks on macOS (gate managed_scope_exclusion, scope unmanaged_personal_only). */
|
|
8
7
|
export function claudeManagedPaths(user, root = '/') {
|
|
@@ -50,38 +49,27 @@ export async function assertClaudeManagedPathsAbsent(user, root = '/') {
|
|
|
50
49
|
if (entries.length !== 0)
|
|
51
50
|
throw unverified();
|
|
52
51
|
}
|
|
53
|
-
/** Compiled defaults: the
|
|
52
|
+
/** Compiled defaults: the real managed locations. */
|
|
54
53
|
export function claudeManagedScopeDeps(root = '/') {
|
|
55
|
-
return {
|
|
56
|
-
checkReady: async () => { verifyPolicyObserver(CLAUDE_POLICY_OBSERVER); await createPolicyInventoryClient(CLAUDE_POLICY_OBSERVER).checkReady(); },
|
|
57
|
-
collect: () => createPolicyInventoryClient(CLAUDE_POLICY_OBSERVER).collectOnce(),
|
|
58
|
-
managedPathsAbsent: () => assertClaudeManagedPathsAbsent(os.userInfo().username, root),
|
|
59
|
-
};
|
|
54
|
+
return { managedPathsAbsent: () => assertClaudeManagedPathsAbsent(os.userInfo().username, root) };
|
|
60
55
|
}
|
|
61
56
|
export async function claudeManagedScopeReady(deps) {
|
|
62
57
|
try {
|
|
63
|
-
await deps.checkReady();
|
|
64
58
|
await deps.managedPathsAbsent();
|
|
65
59
|
}
|
|
66
60
|
catch {
|
|
67
61
|
throw unverified();
|
|
68
62
|
}
|
|
69
63
|
}
|
|
70
|
-
/** Per-job managed scope:
|
|
71
|
-
* plus absent Claude managed files. Managed, partial, unknown or stale input blocks. */
|
|
64
|
+
/** Per-job managed scope: absent Claude managed files and managed preferences. Present or unreadable input blocks. */
|
|
72
65
|
export async function collectClaudeManagedScope(deps) {
|
|
73
66
|
try {
|
|
74
|
-
const value = await deps.collect(), now = (deps.now ?? Date.now)();
|
|
75
|
-
const r = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
76
|
-
const observedAt = r.observedAt;
|
|
77
|
-
if (r.complete !== true || r.empty !== true || r.scope !== 'machine_and_current_user' || typeof observedAt !== 'number' || !Number.isSafeInteger(observedAt)
|
|
78
|
-
|| observedAt > now || now - observedAt > 60_000 || typeof r.inventoryFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(r.inventoryFingerprint))
|
|
79
|
-
throw unverified();
|
|
80
67
|
await deps.managedPathsAbsent();
|
|
81
|
-
const bound = { observer_sha256: CLAUDE_POLICY_OBSERVER.sha256, inventory_fingerprint: r.inventoryFingerprint, managed_paths: 'absent' };
|
|
82
|
-
return Object.freeze({ observerSha256: CLAUDE_POLICY_OBSERVER.sha256, inventoryFingerprint: r.inventoryFingerprint, fingerprint: claudeCanonicalSha256(bound) });
|
|
83
68
|
}
|
|
84
69
|
catch {
|
|
85
70
|
throw unverified();
|
|
86
71
|
}
|
|
72
|
+
const bound = { basis: 'managed_paths_as_user', managed_paths: 'absent', directory: CLAUDE_MANAGED_SETTINGS_DIRECTORY,
|
|
73
|
+
files: [...CLAUDE_MANAGED_SETTINGS_FILES], preference_domain: CLAUDE_MANAGED_PREFERENCE_DOMAIN };
|
|
74
|
+
return Object.freeze({ fingerprint: claudeCanonicalSha256(bound) });
|
|
87
75
|
}
|
|
@@ -22,8 +22,6 @@ export const CLAUDE_TEAM_ID = 'Q6L2SF6YDW';
|
|
|
22
22
|
export const CLAUDE_SIGNING_IDENTIFIER = 'com.anthropic.claude-code';
|
|
23
23
|
/** Official native-build distribution. Never taken from the Gateway, a task, config or env. */
|
|
24
24
|
export const CLAUDE_DOWNLOAD_BASE = 'https://downloads.claude.ai/claude-code-releases';
|
|
25
|
-
/** The installed privileged observer; the same reviewed binary the Codex daemon release pins. */
|
|
26
|
-
export const CLAUDE_POLICY_OBSERVER = Object.freeze({ protocol: 'policy-inventory-v1', sha256: '2a07812cd47782826a359b749096b681a6ee49e91f990d48abdc60f2d46e4c9c' });
|
|
27
25
|
/** macOS managed sources (docs/superpowers/specs/2026-10-16-cli-managed-auth-lab-scope.json) and the MDM preference domain. */
|
|
28
26
|
export const CLAUDE_MANAGED_SETTINGS_DIRECTORY = '/Library/Application Support/ClaudeCode';
|
|
29
27
|
export const CLAUDE_MANAGED_SETTINGS_FILES = Object.freeze([`${CLAUDE_MANAGED_SETTINGS_DIRECTORY}/managed-settings.json`, `${CLAUDE_MANAGED_SETTINGS_DIRECTORY}/managed-mcp.json`]);
|
|
@@ -24,8 +24,8 @@ export const CLAUDE_REVIEW_GATE_SCENARIOS = Object.freeze({
|
|
|
24
24
|
required_mcp_startup: Object.freeze(['mcp_startup', 'tools_offered', 'allowed_read']),
|
|
25
25
|
source_exclusion: Object.freeze(['allowed_read', 'source_exclusion', 'fake_credentials', 'communication_boundary']),
|
|
26
26
|
});
|
|
27
|
-
/** managed_scope_exclusion is checked per job by the
|
|
28
|
-
export const CLAUDE_MANAGED_SCOPE_BASIS = '
|
|
27
|
+
/** managed_scope_exclusion is checked per job by the managed-path check as the user (claude-managed-scope.ts, decision 3dde6d80). */
|
|
28
|
+
export const CLAUDE_MANAGED_SCOPE_BASIS = 'managed_paths_as_user_per_job';
|
|
29
29
|
const gated = (gate) => Object.hasOwn(CLAUDE_REVIEW_GATE_SCENARIOS, gate);
|
|
30
30
|
export function buildClaudeReviewEvidence(input) {
|
|
31
31
|
if (!isDeepStrictEqual(input.scenarios.map(s => s.id), [...CLAUDE_REVIEW_SCENARIOS])
|