mtech-ai-local-runner 0.1.73 → 0.1.75

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 (31) hide show
  1. package/README.md +35 -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/daemon/execution-coordinator.js +65 -9
  9. package/dist/src/daemon/manual-codex.js +2 -2
  10. package/dist/src/daemon/runtime.js +31 -10
  11. package/dist/src/daemon/suspended-publication.js +52 -0
  12. package/dist/src/gateway-client.js +79 -5
  13. package/dist/src/protocol.js +33 -0
  14. package/dist/src/publication/installed-host.js +4 -2
  15. package/dist/src/publication/server-ci-host.js +85 -31
  16. package/dist/src/rooms/activity.js +84 -0
  17. package/dist/src/rooms/notifier.js +21 -0
  18. package/dist/src/rooms/store.js +56 -0
  19. package/dist/src/safety/claude-managed-scope.js +7 -19
  20. package/dist/src/safety/claude-release.js +0 -2
  21. package/dist/src/safety/claude-review-evidence.js +2 -2
  22. package/dist/src/safety/codex-exec-production-host.js +15 -2
  23. package/dist/src/safety/job-tool-policy.js +4 -3
  24. package/dist/src/verification/continuation-runner-contract.js +228 -0
  25. package/dist/src/verification/p1-execution-host.js +25 -6
  26. package/dist/src/verification/p1-host-composition.js +2 -0
  27. package/dist/src/verification/reviewed-runner-contract.js +7 -0
  28. package/dist/src/verification/runner-observed-verifier.js +29 -5
  29. package/dist/src/workspace/registered-submodules.js +204 -0
  30. package/dist/src/workspace/workspace-manager.js +128 -18
  31. package/package.json +1 -1
@@ -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,18 @@ 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 SUSPENDED_JOB_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ /** A malformed answer is dropped, never a reason to refuse the sync: the runner then stays suspended. */
24
+ function parseSuspendedJob(value) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return undefined;
27
+ const { job_id: jobId, generation, status, closed } = value;
28
+ if (typeof jobId !== "string" || !SUSPENDED_JOB_ID.test(jobId) || !Number.isSafeInteger(generation) || generation < 1
29
+ || typeof status !== "string" || !/^[a-z_]{1,32}$/.test(status) || typeof closed !== "boolean")
30
+ return undefined;
31
+ return { jobId, generation: generation, status, closed };
32
+ }
20
33
  const FIX_CONSENT_GRANT_ID = /^[a-zA-Z0-9-]{1,64}$/;
21
34
  const FIX_CONSENT_REPOSITORY_KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
22
35
  const FIX_CONSENT_GRANTS_MAX = 20;
@@ -52,6 +65,7 @@ export function parseRunnerSync(result, runnerId) {
52
65
  if (body === undefined || !Array.isArray(body.opens) || malformed("ask_deliveries") || malformed("closed_delivery_ids")) {
53
66
  throw new GatewayToolError("Gateway returned an invalid runner sync result", "local_agent_runner_sync", "GATEWAY_INVALID_TOOL_RESULT");
54
67
  }
68
+ const suspendedJob = parseSuspendedJob(body.suspended_job);
55
69
  return {
56
70
  opens: body.opens,
57
71
  ...(Array.isArray(body.ask_deliveries) ? { askDeliveries: body.ask_deliveries } : {}),
@@ -60,7 +74,9 @@ export function parseRunnerSync(result, runnerId) {
60
74
  ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
61
75
  ...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
62
76
  ...(body.ask_alert === true ? { askAlert: true } : {}),
77
+ ...(body.room_activity_enabled === true ? { roomActivityEnabled: true } : {}),
63
78
  ...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
79
+ ...(suspendedJob ? { suspendedJob } : {}),
64
80
  jobAvailable: body.job_available === true,
65
81
  push: parsePush(body.push, runnerId),
66
82
  syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
@@ -116,7 +132,7 @@ export function shadowTaskPackage(contract) {
116
132
  const verification = hasP1(payload) ? payload.verification : undefined;
117
133
  if (hasP1(payload)) {
118
134
  const resolved = resolveRunnerVerification(verification);
119
- if (resolved.version === 2 && (nested(payload, 'policy.policy_key') !== REVIEWED_POLICY || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
135
+ if (resolved.version !== 1 && (nested(payload, 'policy.policy_key') !== (resolved.version === 3 ? CONTINUATION_POLICY : REVIEWED_POLICY) || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
120
136
  throw Error('P1_CONTRACT_REJECTED');
121
137
  }
122
138
  const repositories = (payload.repositories ?? []).map((repository) => ({
@@ -315,10 +331,14 @@ export class McpGatewayClient {
315
331
  if (!this.checkoutPath) {
316
332
  throw new Error("local checkout path is required before creating an execution binding");
317
333
  }
318
- const useProvidedLease = Boolean(this.initialLeaseToken) && !input.claimNext;
334
+ const qualified = input.expectedJobId !== undefined || input.expectedAttemptNumber !== undefined;
335
+ if (qualified && (typeof input.expectedJobId !== 'string' || !input.expectedJobId.trim() || !Number.isSafeInteger(input.expectedAttemptNumber) || input.expectedAttemptNumber < 1 || input.expectedAttemptNumber > 3))
336
+ throw new GatewayToolError('Qualified next identity is invalid', 'work_loop_next', 'CORRECTION_LINEAGE_MISMATCH');
337
+ const nextArguments = { work_loop_id: input.workLoopId, ...(qualified ? { expected_job_id: input.expectedJobId, expected_attempt_number: input.expectedAttemptNumber } : {}) };
338
+ const useProvidedLease = Boolean(this.initialLeaseToken) && !input.claimNext && !qualified;
319
339
  const claim = useProvidedLease
320
340
  ? await this.callTool("work_loop_show", { work_loop_id: input.workLoopId })
321
- : await this.callTool("work_loop_next", { work_loop_id: input.workLoopId });
341
+ : await this.callTool("work_loop_next", nextArguments);
322
342
  const leaseToken = useProvidedLease
323
343
  ? this.initialLeaseToken
324
344
  : requiredString(claim.lease_token, "lease token");
@@ -505,12 +525,25 @@ export class McpGatewayClient {
505
525
  return { leaseExpiresAt: context.leaseExpiresAt };
506
526
  }
507
527
  async waitForLocalJob(adapterVersions, waitSeconds = 25, jobId) {
508
- return this.callTool("local_agent_jobs_wait", {
528
+ const claim = await this.callTool("local_agent_jobs_wait", {
509
529
  runner_id: this.runnerId,
510
530
  adapter_versions: adapterVersions,
511
531
  ...(jobId ? { job_id: jobId } : {}),
512
532
  wait_seconds: Math.max(0, Math.min(30, waitSeconds)),
513
533
  });
534
+ if (['continuation', 'execution_attempt', 'execution_attempt_fingerprint'].some(key => Object.hasOwn(claim, key)))
535
+ throw new GatewayToolError('Unexpected claimed attempt identity', 'local_agent_jobs_wait', 'GATEWAY_INVALID_TOOL_RESULT');
536
+ if (claim.status === 'claimed' && claim.task_package && claim.job) {
537
+ try {
538
+ 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 });
539
+ }
540
+ catch {
541
+ throw new GatewayToolError('Claimed attempt identity is invalid', 'local_agent_jobs_wait', 'CORRECTION_EXECUTION_UNAVAILABLE');
542
+ }
543
+ }
544
+ else if (claim.attempt_context !== undefined || claim.attempt_context_fingerprint !== undefined)
545
+ throw new GatewayToolError('Unexpected claimed attempt identity', 'local_agent_jobs_wait', 'GATEWAY_INVALID_TOOL_RESULT');
546
+ return claim;
514
547
  }
515
548
  async registerLocalRunner(input) {
516
549
  return this.callTool("local_agent_runner_register", {
@@ -587,9 +620,41 @@ export class McpGatewayClient {
587
620
  reason: input.reason,
588
621
  ...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
589
622
  ...(input.guidancePolicyVersion ? { guidance_policy_version: input.guidancePolicyVersion } : {}),
623
+ ...(input.roomActivityV1 ? { room_activity_v1: true } : {}),
624
+ ...(input.suspendedJob ? { suspended_job: { job_id: input.suspendedJob.jobId, generation: input.suspendedJob.generation } } : {}),
590
625
  }, signal);
591
626
  return parseRunnerSync(result, this.runnerId);
592
627
  }
628
+ async readRoomActivity(afterRoomId = '', limit = 50, signal) {
629
+ if ((afterRoomId !== '' && !ROOM_ID.test(afterRoomId)) || !Number.isInteger(limit) || limit < 1 || limit > 100) {
630
+ throw new GatewayToolError('Invalid Room activity query', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
631
+ }
632
+ const result = await this.callTool('work_room_read', { operation: 'activity', after_room_id: afterRoomId, limit, share_only: true }, signal);
633
+ const body = result && typeof result === 'object' && !Array.isArray(result) ? result : undefined;
634
+ if (!body || !Array.isArray(body.rooms) || body.rooms.length > limit
635
+ || !(body.next_cursor === null || (typeof body.next_cursor === 'string' && ROOM_ID.test(body.next_cursor)))) {
636
+ throw new GatewayToolError('Invalid Room activity page', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
637
+ }
638
+ const rooms = body.rooms.map(row => {
639
+ const item = row && typeof row === 'object' && !Array.isArray(row) ? row : {};
640
+ if (typeof item.room_id !== 'string' || !ROOM_ID.test(item.room_id)
641
+ || !Number.isSafeInteger(item.unread_count) || item.unread_count < 0
642
+ || !Number.isSafeInteger(item.latest_message_sequence) || item.latest_message_sequence < 0) {
643
+ throw new GatewayToolError('Invalid Room activity entry', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
644
+ }
645
+ return { roomId: item.room_id, unreadCount: item.unread_count, latestMessageSequence: item.latest_message_sequence };
646
+ });
647
+ let previous = afterRoomId;
648
+ for (const room of rooms) {
649
+ if (room.roomId <= previous)
650
+ throw new GatewayToolError('Room activity order is invalid', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
651
+ previous = room.roomId;
652
+ }
653
+ if (body.next_cursor !== null && (rooms.length === 0 || body.next_cursor !== previous)) {
654
+ throw new GatewayToolError('Room activity cursor is invalid', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
655
+ }
656
+ return { rooms, nextCursor: body.next_cursor };
657
+ }
593
658
  async authorizeRunnerChannel(socketId, signal) {
594
659
  const result = await this.callTool("local_agent_runner_channel_auth", {
595
660
  runner_id: this.runnerId,
@@ -640,6 +705,15 @@ export class McpGatewayClient {
640
705
  const bindingId = requiredString(result.binding.id, "binding ID");
641
706
  const iterationId = requiredString(result.binding.iteration_id, "iteration ID");
642
707
  signal?.throwIfAborted();
708
+ if (input.claimedJob) {
709
+ 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)
710
+ throw Error('CORRECTION_LINEAGE_MISMATCH');
711
+ assertBoundExecutionAttempt(input.claimedJob, result.bundle, iterationId);
712
+ if (input.claimedJob.executionAttempt && typeof result.binding_replayed !== 'boolean')
713
+ throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
714
+ }
715
+ else if (Object.hasOwn(result.bundle, 'attempt_context') || Object.hasOwn(result.bundle, 'attempt_context_fingerprint') || nested(result.bundle, 'contract.verification.schema_version') === CONTINUATION_SCHEMA)
716
+ throw Error('CORRECTION_LINEAGE_MISMATCH');
643
717
  this.context = {
644
718
  jobFence: { job_id: input.jobId, generation: input.generation, lease_token: input.jobLeaseToken },
645
719
  workLoopId: requiredString(result.work_loop_id, "Work Loop ID"),
@@ -653,7 +727,7 @@ export class McpGatewayClient {
653
727
  runtimeAdapterVersion: requiredString(result.binding.runtime_adapter_version, "runtime adapter version"),
654
728
  };
655
729
  return {
656
- bindingId,
730
+ bindingId, iterationId, ...(input.claimedJob?.executionAttempt ? { bindingReplayed: result.binding_replayed } : {}),
657
731
  taskPackage: result.bundle,
658
732
  resolvedModelId: requiredString(result.binding.resolved_model_id, "resolved model ID"),
659
733
  };
@@ -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
+ }
@@ -42,6 +42,8 @@ export function assertLocalFixConsent(value) {
42
42
  if (!Array.isArray(modes) || !modes.length || new Set(modes).size !== modes.length || modes.some(m => m !== 'gateway_ci_v1' && m !== 'gateway_publish_v1'))
43
43
  throw denied();
44
44
  }
45
+ /** One fixed JSON line per repeated publication call; no payload, path or token. */
46
+ const reportRetry = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); };
45
47
  /** Serves each job with the registration of the mode frozen in its own Fix scope. */
46
48
  function multiModeHost(hosts) {
47
49
  const verificationModes = hosts.map(h => { if (h.verificationMode === undefined)
@@ -94,7 +96,7 @@ export async function loadInstalledLocalFixHost(input) {
94
96
  // The scope's own reviewed policy and this measured release make the registration the server CI host checks the job against.
95
97
  const manifest = { verification_mode: mode, project: key, ci_profile: scope.ci_profile, ci_profile_fingerprint: protocolHash(scope.ci_profile), receipt: { ...measured.receipt } };
96
98
  const registration = { ...manifest, review: { status: 'reviewed', fingerprint: protocolHash(manifest) } };
97
- return createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
99
+ return createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, report: reportRetry, canonicalApprovalId: async (publicationId) => {
98
100
  let approvals;
99
101
  try {
100
102
  approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
@@ -146,7 +148,7 @@ export async function loadInstalledLocalFixHost(input) {
146
148
  const privateRoot = path.join(input.stateRoot, 'local-fix-publication'), s = await lstat(privateRoot);
147
149
  if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()) || await realpath(privateRoot) !== privateRoot)
148
150
  throw denied();
149
- const hosts = registrations.map(registration => createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
151
+ const hosts = registrations.map(registration => createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, report: reportRetry, canonicalApprovalId: async (publicationId) => {
150
152
  let approvals;
151
153
  try {
152
154
  approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
@@ -14,6 +14,13 @@ import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
14
14
  import { PublicationOutcomeUnknownError } from './publisher.js';
15
15
  import { isLocalFixAdapter } from './local-fix-adapters.js';
16
16
  const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
17
+ /** Work Decision b3d7eb22: a failed Gateway publication call is repeated for at most this long; the coordinator heartbeat keeps the lease. */
18
+ export const PUBLICATION_RETRY_WINDOW_MS = 900_000;
19
+ /** 5 s doubling to 130 s, longer than the Gateway's 120-s quiet window, so an absent working branch can be staged again (decision 1bddc563). */
20
+ export const publicationRetryDelay = (failures) => Math.min(5000 * 2 ** (failures - 1), 130_000);
21
+ /** Only the Gateway read of the fence failed; every local check passed, so the call may be repeated. */
22
+ class FenceUnreadable extends Error {
23
+ }
17
24
  const safePath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\\') && !p.includes('\0') && !path.isAbsolute(p) && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s));
18
25
  /** Gateway decision 8e7fff53: under any directory scope, test infrastructure, boot and dependency files stay as the base has them. */
19
26
  const testInfrastructure = (p) => /(^|\/)(phpunit\.xml(\.dist)?|bitbucket-pipelines\.yml|composer\.[^/]+|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml)$|^(scripts|app\/common)(\/|$)|(^|\/)(vendor|node_modules)(\/|$)/.test(p);
@@ -82,8 +89,22 @@ export function createServerCiLocalFixHost(options) {
82
89
  throw denied();
83
90
  let closed = false, settled = false, stageStarted = false, host, phase = 'validation_report', primary, disposing, finalization, seal;
84
91
  const current = async () => { if (closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
85
- throw denied(); await options.assertReleaseCurrent?.(); if (protocolHash(bindingProof((await options.gateway.localPublicationContext(fence)).context)) !== protocolHash(binding) || closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
92
+ throw denied(); await options.assertReleaseCurrent?.(); let context; try {
93
+ context = (await options.gateway.localPublicationContext(fence)).context;
94
+ }
95
+ catch {
96
+ throw new FenceUnreadable();
97
+ } if (protocolHash(bindingProof(context)) !== protocolHash(binding) || closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
86
98
  throw denied(); await seal?.assertUnchanged(); };
99
+ const readable = async () => { try {
100
+ await current();
101
+ return true;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof FenceUnreadable)
105
+ return false;
106
+ throw error;
107
+ } };
87
108
  const dispose = () => disposing ??= (host?.dispose() ?? Promise.resolve());
88
109
  return { close: async () => { closed = true; if (!finalization || settled) {
89
110
  try {
@@ -101,8 +122,11 @@ export function createServerCiLocalFixHost(options) {
101
122
  phase = 'current_fence';
102
123
  await current();
103
124
  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();
125
+ // materialize captures once, checks live source again after copying, and seals every private byte.
126
+ // Use that same observation for limits and upload; a separate observe() repeats two full captures.
127
+ phase = 'snapshot_materialize';
128
+ seal = await host.materialize();
129
+ const observed = structuredClone(seal.observation);
106
130
  phase = 'candidate_limits';
107
131
  let split;
108
132
  if (limits.version === 2) {
@@ -118,10 +142,6 @@ export function createServerCiLocalFixHost(options) {
118
142
  throw publicationError(phase, 'LOCAL_FIX_DIFF_LIMIT_REJECTED');
119
143
  if (!observed.changed_files.length || observed.changed_files.length > limits.max_changed_files)
120
144
  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
145
  // The v1 wire represents ordinary text bytes only. Refuse any change whose
126
146
  // executable/type metadata would be lost by the provider's regular-file write.
127
147
  phase = 'delta_format';
@@ -199,21 +219,51 @@ export function createServerCiLocalFixHost(options) {
199
219
  if (!security)
200
220
  throw denied();
201
221
  let result;
222
+ const sleep = options.sleep ?? delay, now = options.now ?? Date.now;
223
+ // Work Decision b3d7eb22: a string answer names a failed Gateway call, repeated with the same arguments while the
224
+ // fence holds. The streak ends at the first accepted answer; a delay that would outlast the window gives up as before.
225
+ const retrying = async (attempt) => {
226
+ let started;
227
+ for (let failures = 1;; failures++) {
228
+ const answer = await attempt();
229
+ if (typeof answer !== 'string')
230
+ return answer;
231
+ started ??= now();
232
+ const wait = publicationRetryDelay(failures);
233
+ if (now() - started + wait > PUBLICATION_RETRY_WINDOW_MS)
234
+ throw new PublicationOutcomeUnknownError();
235
+ try {
236
+ options.report?.({ event: 'local_fix_publication_retry', operation: answer, failures, delay_ms: wait });
237
+ }
238
+ catch { /* diagnostics only */ }
239
+ await sleep(wait);
240
+ }
241
+ };
202
242
  phase = 'stage';
203
243
  stageStarted = true;
204
- try {
205
- result = (await options.gateway.executeServerCiPublication({ ...fence, operation: 'stage', files, ...(report ? { validationReport: report } : {}), securityReceipt: { ...security }, hostReceipt: { ...registration.receipt } })).result;
206
- }
207
- catch {
208
- throw new PublicationOutcomeUnknownError();
209
- }
210
- const publicationId = result.publication_id;
211
- if (typeof publicationId !== 'string' || !publicationId)
212
- throw new PublicationOutcomeUnknownError();
213
- if (record.publication_id && record.publication_id !== publicationId)
214
- throw new PublicationOutcomeUnknownError();
215
- record.publication_id = publicationId;
216
- await journal.save(key, record);
244
+ let stages = 0;
245
+ const stageInput = { ...fence, operation: 'stage', files, ...(report ? { validationReport: report } : {}), securityReceipt: { ...security }, hostReceipt: { ...registration.receipt } };
246
+ // The same frozen files again: the Gateway reconciles a present branch or stages an absent one again (1bddc563).
247
+ result = await retrying(async () => {
248
+ if (stages++ > 0 && !await readable())
249
+ return 'fence';
250
+ let answer;
251
+ try {
252
+ answer = (await options.gateway.executeServerCiPublication(stageInput)).result;
253
+ }
254
+ catch {
255
+ return 'stage';
256
+ }
257
+ const id = answer.publication_id;
258
+ if (typeof id !== 'string' || !id)
259
+ throw new PublicationOutcomeUnknownError();
260
+ if (record.publication_id && record.publication_id !== id)
261
+ throw new PublicationOutcomeUnknownError();
262
+ record.publication_id = id;
263
+ await journal.save(key, record);
264
+ return answer.status === 'publication_unknown' ? 'stage' : answer;
265
+ });
266
+ const publicationId = String(result.publication_id);
217
267
  const pending = new Set(['staged', 'validating', 'authorized', 'committing', 'committed', 'pushing', 'pushed', 'opening_pr']);
218
268
  for (;;) {
219
269
  phase = 'result';
@@ -222,7 +272,7 @@ export function createServerCiLocalFixHost(options) {
222
272
  if (result.status === 'ci_failed')
223
273
  throw new ServerCiPublicationError(result.ci?.error_code ?? result.failure?.code);
224
274
  if (result.status === 'completed') {
225
- await current();
275
+ await retrying(async () => await readable() ? {} : 'fence');
226
276
  const ci = result.ci;
227
277
  if (result.verification_mode !== mode || (mode === 'gateway_ci_v1' ? ci?.status !== 'passed' || ci.commit_sha !== result.commit_sha : ci?.status !== 'not_run' || result.verification?.status !== 'unverified') || typeof result.commit_sha !== 'string' || !/^[a-f0-9]{40}$/.test(result.commit_sha) || result.working_branch !== binding.working_branch)
228
278
  throw new PublicationOutcomeUnknownError();
@@ -231,16 +281,20 @@ export function createServerCiLocalFixHost(options) {
231
281
  if (result.waiting || !pending.has(String(result.status)))
232
282
  throw new PublicationOutcomeUnknownError();
233
283
  phase = 'advance';
234
- await (options.sleep ?? delay)(5000);
235
- await current();
236
- const approval = await options.canonicalApprovalId?.(publicationId);
237
- await current();
238
- try {
239
- result = (await options.gateway.executeServerCiPublication({ ...fence, operation: 'advance', publicationId, canonicalApprovalId: approval })).result;
240
- }
241
- catch {
242
- throw new PublicationOutcomeUnknownError();
243
- }
284
+ await sleep(5000);
285
+ result = await retrying(async () => {
286
+ if (!await readable())
287
+ return 'fence';
288
+ const approval = await options.canonicalApprovalId?.(publicationId);
289
+ if (!await readable())
290
+ return 'fence';
291
+ try {
292
+ return (await options.gateway.executeServerCiPublication({ ...fence, operation: 'advance', publicationId, canonicalApprovalId: approval })).result;
293
+ }
294
+ catch {
295
+ return 'advance';
296
+ }
297
+ });
244
298
  }
245
299
  });
246
300
  })().catch(error => {
@@ -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`]);