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.
Files changed (33) hide show
  1. package/README.md +28 -0
  2. package/dist/src/adapters/claude-code/prompt-compiler.js +3 -1
  3. package/dist/src/adapters/codex/exec-invocation.js +24 -0
  4. package/dist/src/adapters/codex/exec-launch-host.js +5 -0
  5. package/dist/src/adapters/codex/exec-runtime-options.js +1 -1
  6. package/dist/src/adapters/codex/prompt-compiler.js +3 -1
  7. package/dist/src/asks/runtime.js +22 -1
  8. package/dist/src/cli.js +4 -0
  9. package/dist/src/commands/fix.js +106 -26
  10. package/dist/src/daemon/execution-coordinator.js +65 -9
  11. package/dist/src/daemon/fix-consent.js +98 -0
  12. package/dist/src/daemon/manual-codex.js +2 -2
  13. package/dist/src/daemon/runtime.js +31 -5
  14. package/dist/src/gateway-client.js +88 -5
  15. package/dist/src/protocol.js +33 -0
  16. package/dist/src/publication/server-ci-host.js +5 -6
  17. package/dist/src/rooms/activity.js +84 -0
  18. package/dist/src/rooms/notifier.js +21 -0
  19. package/dist/src/rooms/store.js +56 -0
  20. package/dist/src/safety/claude-managed-scope.js +7 -19
  21. package/dist/src/safety/claude-release.js +0 -2
  22. package/dist/src/safety/claude-review-evidence.js +2 -2
  23. package/dist/src/safety/codex-exec-production-host.js +15 -2
  24. package/dist/src/safety/job-tool-policy.js +4 -3
  25. package/dist/src/transport/sync-dispatch.js +9 -0
  26. package/dist/src/verification/continuation-runner-contract.js +228 -0
  27. package/dist/src/verification/p1-execution-host.js +25 -6
  28. package/dist/src/verification/p1-host-composition.js +2 -0
  29. package/dist/src/verification/reviewed-runner-contract.js +7 -0
  30. package/dist/src/verification/runner-observed-verifier.js +29 -5
  31. package/dist/src/workspace/registered-submodules.js +204 -0
  32. package/dist/src/workspace/workspace-manager.js +128 -18
  33. 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
- jobs: poller,
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 === 2 && (nested(payload, 'policy.policy_key') !== REVIEWED_POLICY || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
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 useProvidedLease = Boolean(this.initialLeaseToken) && !input.claimNext;
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", { work_loop_id: input.workLoopId });
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
- return this.callTool("local_agent_jobs_wait", {
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
  };
@@ -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
- phase = 'snapshot_observe';
105
- const observed = await host.observe();
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 { createPolicyInventoryClient, verifyPolicyObserver } from './policy-inventory-client.js';
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 pinned installed observer (single-use clients) and the real managed locations. */
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: no configuration profile at all for machine and user (complete, empty, at most 60 s old),
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 installed observer and the managed-path check (claude-managed-scope.ts). */
28
- export const CLAUDE_MANAGED_SCOPE_BASIS = 'observer_inventory_and_managed_paths_per_job';
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])