mtech-ai-local-runner 0.1.77 → 0.1.80

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 (42) hide show
  1. package/README.md +33 -0
  2. package/claude-mod/.claude-plugin/marketplace.json +1 -0
  3. package/claude-mod/.claude-plugin/plugin.json +1 -0
  4. package/claude-mod/hooks/gateway.ts +47 -0
  5. package/claude-mod/hooks/hooks.json +16 -0
  6. package/claude-mod/hooks/model.ts +155 -0
  7. package/claude-mod/hooks/register.tsx +276 -0
  8. package/claude-mod/types/index.d.ts +22 -0
  9. package/dist/src/adapters/claude-code/adapter.js +16 -5
  10. package/dist/src/adapters/claude-code/prompt-compiler.js +17 -4
  11. package/dist/src/adapters/claude-code/runtime-options.js +4 -1
  12. package/dist/src/adapters/codex/config-builder.js +2 -1
  13. package/dist/src/adapters/codex/exec-invocation.js +6 -1
  14. package/dist/src/adapters/codex/exec-preflight.js +4 -2
  15. package/dist/src/adapters/codex/exec-runtime-options.js +3 -3
  16. package/dist/src/adapters/codex/exec-tool-gate.js +7 -3
  17. package/dist/src/adapters/codex/native-policy.js +2 -2
  18. package/dist/src/adapters/codex/native-session.js +5 -4
  19. package/dist/src/adapters/codex/pre-model-gate.js +8 -3
  20. package/dist/src/asks/claude-mod.js +163 -0
  21. package/dist/src/asks/commands.js +26 -6
  22. package/dist/src/asks/doctor.js +4 -3
  23. package/dist/src/asks/integrations.js +1 -1
  24. package/dist/src/cli.js +34 -3
  25. package/dist/src/daemon/repository-access-monitor.js +66 -0
  26. package/dist/src/daemon/runtime.js +16 -1
  27. package/dist/src/gateway-client.js +2 -0
  28. package/dist/src/safety/claude-production-host.js +20 -2
  29. package/dist/src/safety/claude-release.js +4 -1
  30. package/dist/src/safety/claude-review-campaign.js +4 -4
  31. package/dist/src/safety/claude-review-run.js +7 -4
  32. package/dist/src/safety/codex-exec-production-host.js +7 -5
  33. package/dist/src/safety/codex-manual-controller.js +2 -2
  34. package/dist/src/safety/hook-policy-builder.js +3 -3
  35. package/dist/src/safety/job-tool-dispatcher.js +14 -3
  36. package/dist/src/safety/job-tool-session.js +2 -2
  37. package/dist/src/safety/local-tool-policy.js +229 -3
  38. package/dist/src/safety/local-tool-self-test.js +44 -0
  39. package/dist/src/safety/local-tool-server.js +18 -6
  40. package/dist/src/safety/local-tool-worker.js +43 -0
  41. package/dist/src/safety/pre-tool-use-hook.js +17 -2
  42. package/package.json +3 -1
@@ -1,14 +1,24 @@
1
1
  import { renderBundleCorrectionBrief } from '../../verification/continuation-runner-contract.js';
2
2
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
3
3
  import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
4
+ import { localToolNames } from "../../safety/local-tool-policy.js";
4
5
  // Claude's working directory is the job's private control directory, so without these rules it sends that directory's
5
6
  // absolute path or shell commands to the ai_local tools (P4 Investigate of Work Item 1c8026f1, 2026-10-28). Without the
6
7
  // search and citation rules it searched several words, found nothing and cited lines counted from a whole-file read.
7
- const CLAUDE_PATH_RULES = "## Local tool path rules\n" +
8
+ // local-tools-v1 has no discovery tool; local-tools-v2 adds local_find (Work Decision f43e7aa0). The v1 text is unchanged.
9
+ const CLAUDE_PATH_DISCOVERY = Object.freeze({
10
+ "local-tools-v1": "local_search searches only inside that one file; there is no directory listing or repository-wide search, so take " +
11
+ "file paths from the objective and from files you have read. ",
12
+ "local-tools-v2": "local_search searches only inside that one file. To find a file, use local_find with name for a literal part of " +
13
+ "its path, such as MigrateChannelLedgerCommand, or text for a literal it contains, such as migrate:channel-ledger, then " +
14
+ "local_search and local_read on the file found. local_find's path is an optional directory inside the approved scope, not a " +
15
+ "file; it skips vendor/ and node_modules/ unless that directory is inside them, and truncated: true means it stopped at a " +
16
+ "bound, so narrow path, name or text. "
17
+ });
18
+ const claudePathRules = (inventory) => "## Local tool path rules\n" +
8
19
  "Every path argument is one checkout-relative file path inside the approved scope, without a leading slash: write " +
9
20
  "/Modules/Example.php as Modules/Example.php. Never pass your working directory or any absolute path. " +
10
- "local_search searches only inside that one file; there is no directory listing or repository-wide search, so take " +
11
- "file paths from the objective and from files you have read. local_search returns the lines that contain the query " +
21
+ CLAUDE_PATH_DISCOVERY[inventory] + "local_search returns the lines that contain the query " +
12
22
  "exactly, case-sensitive, each with its line number, so search for one identifier or literal, such as assertBranchHead, " +
13
23
  "not several words. Cite a line number only from a local_search match or a local_read with start_line and end_line, and " +
14
24
  "never count lines in a whole-file read. local_test runs only a registered command ID, never a " +
@@ -16,7 +26,10 @@ const CLAUDE_PATH_RULES = "## Local tool path rules\n" +
16
26
  "DENIED_FILE for a directory, DENIED_SCOPE for a path outside the approved scope or DENIED_COMMAND for an unregistered " +
17
27
  "command: send a corrected call instead of concluding that source is unavailable. LOCAL_TOOL_FAILED means the call " +
18
28
  "failed for another reason, such as a file that does not exist.";
29
+ /** `toolInventory` is the job's host-picked inventory (absent: local-tools-v1); only the path rules depend on it. */
19
30
  export function compilePrompt(taskPackage, options) {
31
+ const { toolInventory = "local-tools-v1", ...brief } = options;
32
+ localToolNames(toolInventory);
20
33
  const correction = renderBundleCorrectionBrief(taskPackage);
21
- return `${compileBrief(taskPackage, options)}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}\n\n${CLAUDE_PATH_RULES}${correction}`;
34
+ return `${compileBrief(taskPackage, brief)}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}\n\n${claudePathRules(toolInventory)}${correction}`;
22
35
  }
@@ -9,6 +9,7 @@ import { NodeProcessGroupHost } from '../../process/node-process-group-host.js';
9
9
  import { JobToolSession } from '../../safety/job-tool-session.js';
10
10
  import { jobToolPolicyOptions } from '../../safety/job-tool-policy.js';
11
11
  import { requireJobSecurity } from '../../safety/job-security-policy.js';
12
+ import { claudeJobToolInventory } from './adapter.js';
12
13
  const inside = (root, file) => file === root || file.startsWith(root + path.sep);
13
14
  /** Resolve the existing prefix without creating a path through an account alias. */
14
15
  async function canonicalFuture(file) {
@@ -98,7 +99,9 @@ export function createClaudeCodeAdapterOptions(input) {
98
99
  createTools: async (ctx, pkg, approved) => {
99
100
  if (disposed)
100
101
  throw Error('JOB_SECURITY_SESSION_REJECTED');
101
- const policyOptions = jobToolPolicyOptions(checkout, pkg, approved, input.securityHost ? security.commands() : input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' });
102
+ // The job's tool inventory is the one its security pin names; tools made before any pin serve local-tools-v1.
103
+ const inventory = securityPolicy ? claudeJobToolInventory(securityPolicy) : 'local-tools-v1';
104
+ const policyOptions = { ...jobToolPolicyOptions(checkout, pkg, approved, input.securityHost ? security.commands() : input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' }), inventory };
102
105
  const canonicalAccount = account ? await canonicalFuture(account) : undefined;
103
106
  assertOutsideAccount(await canonicalFuture(state), await canonicalFuture(checkout), canonicalAccount);
104
107
  assertOutsideAccount(await canonicalFuture(control), await canonicalFuture(checkout), canonicalAccount);
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import { CODEX_ISOLATION_EXEC_FLAGS, CODEX_ISOLATION_CONFIG_OVERRIDES, CODEX_JOB_MCP_OMIT_SURFACES } from "./compatibility.js";
3
3
  import { codexControlDenials } from './scratch.js';
4
+ import { localToolNames } from "../../safety/local-tool-policy.js";
4
5
  /** Candidate invocation; execution still requires independent compatibility admission. */
5
6
  export function buildCodexInvocation(input) {
6
7
  if (!input.modelId.trim() || /[\r\n\0]/.test(input.modelId)
@@ -22,7 +23,7 @@ export function buildCodexInvocation(input) {
22
23
  `permissions.local_job.filesystem={":minimal"="read",${deniedFiles}}`,
23
24
  "permissions.local_job.network.enabled=false",
24
25
  // Only this job server is preauthorized: every operation is checked by Runner policy.
25
- `mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=["local_read","local_search","local_edit","local_replace","local_test"],omit_tools_from=${JSON.stringify(CODEX_JOB_MCP_OMIT_SURFACES)}}}`,
26
+ `mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=${JSON.stringify(localToolNames(input.toolInventory))},omit_tools_from=${JSON.stringify(CODEX_JOB_MCP_OMIT_SURFACES)}}}`,
26
27
  ];
27
28
  const env = { HOME: input.env.HOME, AI_GATEWAY_JOB_MCP_TOKEN: input.endpoint.token };
28
29
  for (const key of ["PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "CODEX_HOME"]) {
@@ -5,6 +5,7 @@ import { CODEX_GLOBAL_INSTRUCTION_WRAPPER, globalInstructionProfile } from './gl
5
5
  import { randomBytes } from 'node:crypto';
6
6
  import { buildCodexInvocation } from './config-builder.js';
7
7
  import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
8
+ import { localToolInventoryFor } from '../../safety/local-tool-server.js';
8
9
  export const EXEC_TRANSPORT = 'official_exec_v1';
9
10
  export const EXEC_RELEASE_MODE = 'supported_cli_exec_release_policy_v2';
10
11
  export const EXEC_MODE = 'supported_cli_exec_policy_v1';
@@ -39,7 +40,11 @@ export function buildOfficialExecPlan(input) {
39
40
  const cwd = path.resolve(inv.worktreeRoot), source = path.resolve(inv.sourceRoot);
40
41
  if (cwd === source || cwd.startsWith(source + path.sep) || source.startsWith(cwd + path.sep))
41
42
  throw fail();
42
- const generated = buildCodexInvocation(inv), accountRoot = path.resolve(inv.env.CODEX_HOME ?? path.join(inv.env.HOME, '.codex'));
43
+ // The CLI enables exactly the tools of the inventory the identity's tool fingerprint names (Work Decision f43e7aa0).
44
+ const toolInventory = localToolInventoryFor(i.toolSchemaFingerprint, hash);
45
+ if (!toolInventory)
46
+ throw fail();
47
+ const generated = buildCodexInvocation({ ...inv, toolInventory }), accountRoot = path.resolve(inv.env.CODEX_HOME ?? path.join(inv.env.HOME, '.codex'));
43
48
  // Preserve the original backend explicitly; this is not proof of credentials.
44
49
  generated.args.splice(generated.args.indexOf('--model'), 0, '-c', `cli_auth_credentials_store=${JSON.stringify(input.credentialBackend)}`);
45
50
  const actual = isReleaseExecMode(input.policyMode) ? { binary: CODEX_GLOBAL_INSTRUCTION_WRAPPER, args: ['-p', globalInstructionProfile(accountRoot), input.binary, ...generated.args], env: generated.env } : { binary: input.binary, ...generated };
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { SUPPORTED_CLI_GATES } from '../../safety/cli-security-modes.js';
3
- import { localToolInventory } from '../../safety/local-tool-server.js';
3
+ import { localToolInventory, localToolInventoryFor } from '../../safety/local-tool-server.js';
4
+ import { securityRequirementsFingerprint } from '../../safety/security-requirements.js';
4
5
  import { EXEC_TRANSPORT, requireOfficialExecPlan } from './exec-invocation.js';
5
6
  export const EXEC_GATES = Object.freeze([...SUPPORTED_CLI_GATES, 'effective_policy_observation', 'managed_policy_observation', 'required_mcp_inference_barrier']);
6
7
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -36,6 +37,7 @@ export function assessExecPreflight(value, plan) {
36
37
  return { ready: reasonCodes.length === 0, reasonCodes };
37
38
  }
38
39
  export function requireExecHandshake(h, process, plan) {
39
- if (!object(h) || h.process !== process || h.executionNonce !== plan.executionNonce || h.endpoint !== plan.endpoint || h.authenticated !== true || h.ready !== true || h.toolSchemaFingerprint !== plan.identity.toolSchemaFingerprint || !isDeepStrictEqual(h.tools, localToolInventory().map(t => t.name)) || typeof h.isCurrent !== 'function' || h.isCurrent() !== true)
40
+ const inventory = localToolInventoryFor(plan.identity.toolSchemaFingerprint, securityRequirementsFingerprint);
41
+ if (!inventory || !object(h) || h.process !== process || h.executionNonce !== plan.executionNonce || h.endpoint !== plan.endpoint || h.authenticated !== true || h.ready !== true || h.toolSchemaFingerprint !== plan.identity.toolSchemaFingerprint || !isDeepStrictEqual(h.tools, localToolInventory(inventory).map(t => t.name)) || typeof h.isCurrent !== 'function' || h.isCurrent() !== true)
40
42
  throw Error('CODEX_EXEC_MCP_REJECTED');
41
43
  }
@@ -92,14 +92,14 @@ export function buildCodexExecJobOptions(input) {
92
92
  throw reject();
93
93
  const approved = ctx.approvedTaskPackage, requirements = approved.security_requirements;
94
94
  stage = 'codex_tool_policy';
95
- policy = new LocalToolPolicy(jobToolPolicyOptions(source, ctx.taskPackage, approved, admitted.release.commands, { runtimeAdapter: 'codex_cli', runtimeAdapterVersion: '1' }));
95
+ policy = new LocalToolPolicy({ ...jobToolPolicyOptions(source, ctx.taskPackage, approved, admitted.release.commands, { runtimeAdapter: 'codex_cli', runtimeAdapterVersion: '1' }), inventory: admitted.tools });
96
96
  stage = 'codex_tool_server';
97
- server = new LocalToolServer({ policy, authorizeCall: () => gate?.allowsCall() === true, onDiscovery: (event, scope) => gate?.discovery(event, scope) });
97
+ server = new LocalToolServer({ policy, inventory: admitted.tools, authorizeCall: () => gate?.allowsCall() === true, onDiscovery: (event, scope) => gate?.discovery(event, scope) });
98
98
  const endpoint = await server.start();
99
99
  if (closed || signal.aborted)
100
100
  throw reject();
101
101
  stage = 'codex_invocation';
102
- const plan = buildOfficialExecPlan({ executionAttempt: ctx.executionAttempt, executionAttemptFingerprint: ctx.executionAttemptFingerprint, ...(ctx.executionAttempt ? { executionBindingId: ctx.bindingId } : {}), policyMode: admitted.release.securityMode, binary: admitted.binary, credentialBackend: admitted.release.credentialBackend, identity: { jobId: input.jobId, generation: ctx.generation, actorId: admitted.actorId, runnerId: admitted.runnerId, sourceSha: String(approved.repository.base_commit_sha), binarySha256: admitted.release.binarySha256, releaseManifestFingerprint: String(requirements.release_manifest_sha256), requirementsFingerprint: String(requirements.fingerprint), contractFingerprint: ctx.securityBinding.contractFingerprint, taskPackageFingerprint: ctx.securityBinding.taskPackageFingerprint, launchFingerprint: ctx.securityBinding.launchFingerprint, commandsFingerprint: commandRegistryFingerprint(admitted.release.commands), toolSchemaFingerprint: fingerprint({ tools: localToolInventory() }), barrierFingerprint: admitted.release.fixturesFingerprint }, invocation: { modelId: ctx.resolvedModelId, worktreeRoot: cwd, sourceRoot: source, schemaPath: schema, endpoint, env, scratch } });
102
+ const plan = buildOfficialExecPlan({ executionAttempt: ctx.executionAttempt, executionAttemptFingerprint: ctx.executionAttemptFingerprint, ...(ctx.executionAttempt ? { executionBindingId: ctx.bindingId } : {}), policyMode: admitted.release.securityMode, binary: admitted.binary, credentialBackend: admitted.release.credentialBackend, identity: { jobId: input.jobId, generation: ctx.generation, actorId: admitted.actorId, runnerId: admitted.runnerId, sourceSha: String(approved.repository.base_commit_sha), binarySha256: admitted.release.binarySha256, releaseManifestFingerprint: String(requirements.release_manifest_sha256), requirementsFingerprint: String(requirements.fingerprint), contractFingerprint: ctx.securityBinding.contractFingerprint, taskPackageFingerprint: ctx.securityBinding.taskPackageFingerprint, launchFingerprint: ctx.securityBinding.launchFingerprint, commandsFingerprint: commandRegistryFingerprint(admitted.release.commands), toolSchemaFingerprint: fingerprint({ tools: localToolInventory(admitted.tools) }), barrierFingerprint: admitted.release.fixturesFingerprint }, invocation: { modelId: ctx.resolvedModelId, worktreeRoot: cwd, sourceRoot: source, schemaPath: schema, endpoint, env, scratch } });
103
103
  gate = new ExecToolGate(plan);
104
104
  const host = new CodexExecLaunchHost(plan, { processHost, inspectRelease: p => job.inspect(p), execTools: gate, revokeTools: () => { gate?.revoke(); server?.revoke(); policy?.revoke(); job?.revoke(); }, closeTools: dispose });
105
105
  launchHost = host;
@@ -1,5 +1,5 @@
1
1
  import { isReleaseExecMode, requireOfficialExecPlan } from './exec-invocation.js';
2
- import { localDiscoveryScopeMatches, localToolInventory } from '../../safety/local-tool-server.js';
2
+ import { localDiscoveryScopeMatches, localToolInventory, localToolInventoryFor } from '../../safety/local-tool-server.js';
3
3
  import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
4
4
  import { executionError } from '../../safety/execution-failure.js';
5
5
  const denied = () => Error('EXEC_TOOL_GATE_REJECTED');
@@ -15,11 +15,15 @@ export class ExecToolGate {
15
15
  stage = 0;
16
16
  settle;
17
17
  reject;
18
+ tools;
19
+ /** The plan's tool fingerprint must name one inventory of this Runner (Work Decision f43e7aa0); the handshake reports its tools. */
18
20
  constructor(plan) {
19
21
  this.plan = plan;
20
22
  requireOfficialExecPlan(plan);
21
- if (!isReleaseExecMode(plan.mode) || plan.identity.toolSchemaFingerprint !== hash({ tools: localToolInventory() }))
23
+ const inventory = localToolInventoryFor(plan.identity.toolSchemaFingerprint, hash);
24
+ if (!isReleaseExecMode(plan.mode) || !inventory)
22
25
  throw denied();
26
+ this.tools = localToolInventory(inventory).map(t => t.name);
23
27
  }
24
28
  matches(plan) { return this.plan === plan && !this.process && !this.revoked; }
25
29
  bind(process, current) { if (this.process || this.revoked)
@@ -75,7 +79,7 @@ export class ExecToolGate {
75
79
  });
76
80
  if (!this.active() || signal.aborted || this.stage !== 3)
77
81
  throw this.readinessError();
78
- return { process, executionNonce: this.plan.executionNonce, endpoint: this.plan.endpoint, authenticated: true, ready: true, toolSchemaFingerprint: this.plan.identity.toolSchemaFingerprint, tools: localToolInventory().map(t => t.name), isCurrent: () => this.active() && this.stage === 3 };
82
+ return { process, executionNonce: this.plan.executionNonce, endpoint: this.plan.endpoint, authenticated: true, ready: true, toolSchemaFingerprint: this.plan.identity.toolSchemaFingerprint, tools: [...this.tools], isCurrent: () => this.active() && this.stage === 3 };
79
83
  }
80
84
  authorize(process) { if (process !== this.process || !this.active() || this.stage !== 3)
81
85
  throw denied(); this.authorized = true; }
@@ -103,7 +103,7 @@ export function validateNativeConfig(value, requirements, pin) {
103
103
  if (server.url !== pin.endpoint || server.bearer_token_env_var !== 'AI_GATEWAY_JOB_MCP_TOKEN'
104
104
  || server.required !== true || server.enabled !== true || server.default_tools_approval_mode !== 'approve'
105
105
  || canonical(server.omit_tools_from) !== canonical(CODEX_JOB_MCP_OMIT_SURFACES)
106
- || canonical([...(server.enabled_tools ?? [])].sort()) !== canonical(localToolInventory().map(t => t.name).sort()))
106
+ || canonical([...(server.enabled_tools ?? [])].sort()) !== canonical(localToolInventory(pin.toolInventory).map(t => t.name).sort()))
107
107
  reject();
108
108
  for (const k of Object.keys(server))
109
109
  if (!['url', 'bearer_token_env_var', 'environment_id', 'enabled', 'required', 'tool_timeout_sec', 'default_tools_approval_mode', 'enabled_tools', 'omit_tools_from'].includes(k))
@@ -119,7 +119,7 @@ export function validateNativeTools(value, pin) {
119
119
  || server.httpOrigin !== new URL(pin.endpoint).origin || server.pluginId !== null || server.toolsError !== null
120
120
  || canonical(server.resources) !== '[]' || canonical(server.resourceTemplates) !== '[]')
121
121
  reject();
122
- const tools = record(server.tools), expected = localToolInventory();
122
+ const tools = record(server.tools), expected = localToolInventory(pin.toolInventory);
123
123
  if (Object.keys(tools).length !== expected.length)
124
124
  reject();
125
125
  for (const tool of expected)
@@ -126,7 +126,7 @@ export class CodexNativeSession {
126
126
  // Matching snapshots cannot certify policy immutability through turn/start.
127
127
  this.snapshot = { model: this.pin.model, provider: 'openai', cwd: this.pin.cwd, approvalPolicy: 'never', permissionProfile: 'local_job',
128
128
  configFingerprint: after, nativeToolsIsolated: false, policyStable: false, rpcBoundary: 'owned_exact_rpc_v1', environments: [], warnings: [], servers: [{ name: 'ai_local', endpoint: this.pin.endpoint, status: 'ready', authenticated: true,
129
- tools: localToolInventory().map(t => t.name), toolSchemaFingerprint: tools }] };
129
+ tools: localToolInventory(this.pin.toolInventory).map(t => t.name), toolSchemaFingerprint: tools }] };
130
130
  return structuredClone(this.snapshot);
131
131
  }
132
132
  catch {
@@ -277,7 +277,8 @@ export function buildNativeInvocation(invocation, options) {
277
277
  }
278
278
  const pin = { ...(options.sourceRoot !== undefined ? { sourceRoot: options.sourceRoot } : {}), model, provider: options.testTransport ? 'offline_fixture' : 'openai', cwd: options.cwd, home: options.home, schemaPath: options.schemaPath, endpoint,
279
279
  ...(env.CODEX_HOME ? { codexHome: env.CODEX_HOME } : {}), ...(options.scratch ? { scratch: { ...options.scratch } } : {}),
280
- loginMethod: options.testTransport?.loginMethod ?? 'chatgpt', providerConfig: options.testTransport?.providerConfig ?? {} };
280
+ loginMethod: options.testTransport?.loginMethod ?? 'chatgpt', providerConfig: options.testTransport?.providerConfig ?? {},
281
+ ...(options.toolInventory ? { toolInventory: options.toolInventory } : {}) };
281
282
  return { args, env, pin };
282
283
  }
283
284
  export async function startNativeCodexSession(options, invocation, prompt, signal, assertAdmission = () => { }) {
@@ -324,7 +325,7 @@ export async function startNativeCodexSession(options, invocation, prompt, signa
324
325
  const stopped = new Promise((_, reject) => { rejectStopped = reject; timer = setTimeout(aborted, Math.max(1, preparationDeadline - Date.now())); });
325
326
  // Preparation deadline only; no deadline on a retained, admitted model turn.
326
327
  const observed = await Promise.race([session.inspect(), stopped]);
327
- const expected = { model: native.pin.model, provider: 'openai', cwd: options.cwd, endpoint: native.pin.endpoint, configFingerprint: observed.configFingerprint, toolSchemaFingerprint: fingerprint(localToolInventory()) };
328
+ const expected = { model: native.pin.model, provider: 'openai', cwd: options.cwd, endpoint: native.pin.endpoint, configFingerprint: observed.configFingerprint, toolSchemaFingerprint: fingerprint(localToolInventory(native.pin.toolInventory)) };
328
329
  inspectedExpectation = Object.freeze({ ...expected });
329
330
  if (options.prepareSupportedAdmission || options.prepareProductionAdmission) {
330
331
  const preparation = (options.prepareProductionAdmission ? options.prepareProductionAdmission(session.proof()) : options.prepareSupportedAdmission(inspectedExpectation)).then(context => {
@@ -341,7 +342,7 @@ export async function startNativeCodexSession(options, invocation, prompt, signa
341
342
  clearTimeout(timer);
342
343
  rejectStopped = undefined;
343
344
  return await startCodexAfterPreflight(session, { model: native.pin.model, provider: 'openai', cwd: options.cwd, endpoint: native.pin.endpoint,
344
- configFingerprint: observed.configFingerprint, toolSchemaFingerprint: fingerprint(localToolInventory()) }, prompt, signal, options.onAssessment, supported);
345
+ configFingerprint: observed.configFingerprint, toolSchemaFingerprint: fingerprint(localToolInventory(native.pin.toolInventory)) }, prompt, signal, options.onAssessment, supported);
345
346
  }
346
347
  catch {
347
348
  supported?.policy.revoke();
@@ -1,8 +1,10 @@
1
1
  import { isAbsolute } from 'node:path';
2
2
  import { requireSupportedCodexPolicy } from './supported-admission.js';
3
+ import { localToolInventoryFor } from '../../safety/local-tool-server.js';
4
+ import { localToolNames } from '../../safety/local-tool-policy.js';
5
+ import { fingerprint } from './native-policy.js';
3
6
  const REJECTED = 'CODEX_PRE_MODEL_REJECTED';
4
7
  const usedSessions = new WeakSet();
5
- const REQUIRED_TOOLS = ['local_edit', 'local_read', 'local_replace', 'local_search', 'local_test'];
6
8
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
7
9
  /** Bounded, value-free diagnostics for ALL candidate prerequisites. This is not
8
10
  * native release certification: only trusted retained observations may reach it,
@@ -54,8 +56,11 @@ export function assessCodexPreModel(actual, expected, supported) {
54
56
  require(server.authenticated === true, 'MCP_AUTHENTICATION_UNVERIFIED');
55
57
  require(server.status === 'ready', 'MCP_NOT_READY');
56
58
  require(server.toolSchemaFingerprint === expected.toolSchemaFingerprint, 'MCP_SCHEMA_MISMATCH');
57
- require(Array.isArray(server.tools) && server.tools.every(tool => typeof tool === 'string')
58
- && JSON.stringify([...server.tools].sort()) === JSON.stringify(REQUIRED_TOOLS), 'MCP_TOOLS_MISMATCH');
59
+ // Exactly the tools of the inventory the expected fingerprint names (Work Decision f43e7aa0), never the other one.
60
+ // The native session fingerprints the bare tool array (validateNativeTools), not the descriptor's {tools} form.
61
+ const inventory = localToolInventoryFor(expected.toolSchemaFingerprint, value => fingerprint(value.tools));
62
+ require(inventory !== undefined && Array.isArray(server.tools) && server.tools.every(tool => typeof tool === 'string')
63
+ && JSON.stringify([...server.tools].sort()) === JSON.stringify([...localToolNames(inventory)].sort()), 'MCP_TOOLS_MISMATCH');
59
64
  }
60
65
  return { ready: reasonCodes.length === 0, reasonCodes };
61
66
  }
@@ -0,0 +1,163 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
4
+ import { basename, delimiter, isAbsolute, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { packageVersion } from "../package-version.js";
7
+ /*
8
+ * The Work Room pane mod (Work Item 98da2635), shipped in this package under claude-mod/ and installed through the
9
+ * Claude CLI as a local marketplace, the way Plan A proved on 2026-10-30: `plugin marketplace add <folder>` and
10
+ * `plugin install <mod>@<marketplace> --scope user`, both without a prompt. Claude reads the mod from that folder, so
11
+ * it lives under <stateRoot>/claude-mod/<runner version>/ and an older folder is removed only after a newer install.
12
+ *
13
+ * Only a CLI that loads `modules` hooks can install it. On 2026-10-30 the terminal `claude` 2.1.212 could not, and the
14
+ * desktop-bundled 2.1.299 and 2.1.308 could; the desktop app bundles one even when the terminal `claude` is older.
15
+ * A mod the person disabled stays disabled: setup reinstalls only a missing or older one.
16
+ */
17
+ export const CLAUDE_MOD_NAME = "ai-gateway-work-room";
18
+ export const CLAUDE_MOD_MARKETPLACE = "mtec-ai-gateway";
19
+ const ID = `${CLAUDE_MOD_NAME}@${CLAUDE_MOD_MARKETPLACE}`;
20
+ const MIN_VERSION = [2, 1, 299];
21
+ const NO_CLI = "no Claude CLI 2.1.299 or newer (run claude update or install the Claude desktop app)";
22
+ const BUNDLED = ["Library", "Application Support", "Claude", "claude-code"];
23
+ const RUN_TIMEOUT_MS = 60_000;
24
+ function parseVersion(text) {
25
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(text);
26
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
27
+ }
28
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
29
+ async function isExecutable(file) {
30
+ try {
31
+ await access(file, constants.X_OK);
32
+ return (await stat(file)).isFile();
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** `claude` on PATH when it is new enough, else the newest desktop-bundled CLI that is; null when none is. */
39
+ export async function findClaudeCli(deps) {
40
+ const versionOf = async (file) => {
41
+ const out = await deps.run(file, ["--version"]);
42
+ const version = out.code === 0 ? parseVersion(out.stdout) : null;
43
+ return version && compare(version, MIN_VERSION) >= 0 ? version : null;
44
+ };
45
+ // The first executable `claude` on PATH is the one the person's shell runs; a later one is not looked at.
46
+ for (const dir of (deps.path ?? "").split(delimiter).filter(part => isAbsolute(part))) {
47
+ const file = join(dir, "claude");
48
+ if (!await isExecutable(file))
49
+ continue;
50
+ if (await versionOf(file))
51
+ return file;
52
+ break;
53
+ }
54
+ const root = join(deps.home, ...BUNDLED);
55
+ let best = null;
56
+ for (const release of await readdir(root).catch(() => [])) {
57
+ for (const build of await readdir(join(root, release)).catch(() => [])) {
58
+ const file = join(root, release, build, "claude.app", "Contents", "MacOS", "claude");
59
+ if (!await isExecutable(file))
60
+ continue;
61
+ const version = await versionOf(file);
62
+ if (version && (best === null || compare(version, best.version) > 0))
63
+ best = { file, version };
64
+ }
65
+ }
66
+ return best?.file ?? null;
67
+ }
68
+ /** This runner's mod as `plugin list --json` reports it, or null when it is not listed. */
69
+ async function listed(deps, cli) {
70
+ const out = await deps.run(cli, ["plugin", "list", "--json"]);
71
+ if (out.code !== 0)
72
+ return null;
73
+ try {
74
+ const rows = JSON.parse(out.stdout);
75
+ const row = Array.isArray(rows) ? rows.find(entry => entry.id === ID) : undefined;
76
+ if (!row)
77
+ return null;
78
+ return { enabled: row.enabled === true, ...(typeof row.readFromFolder === "string" ? { folder: row.readFromFolder } : {}) };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ // The mod's own tests and the types the engine lays while developing are not part of what Claude loads.
85
+ const shipped = (source) => !/\.test\.tsx?$/.test(source) && !(basename(source) === "types" && source.includes(".claude-plugin"));
86
+ /** Installs the mod for the person, or leaves it as it is when this release's copy is installed or the person disabled it. Never throws. */
87
+ export async function installClaudeMod(deps) {
88
+ const cli = await findClaudeCli(deps);
89
+ if (cli === null)
90
+ return { state: "skipped", reason: NO_CLI };
91
+ const base = join(deps.stateRoot, "claude-mod");
92
+ const dir = join(base, deps.version);
93
+ try {
94
+ const current = await listed(deps, cli);
95
+ if (current && !current.enabled)
96
+ return { state: "disabled", cli };
97
+ const copied = await stat(join(dir, "hooks", "hooks.json")).then(entry => entry.isFile(), () => false);
98
+ if (current?.folder === dir && copied)
99
+ return { state: "installed", cli };
100
+ await rm(dir, { recursive: true, force: true });
101
+ await mkdir(base, { recursive: true });
102
+ await cp(deps.sourceDir, dir, { recursive: true, filter: shipped });
103
+ // Both fail harmlessly when nothing is installed yet.
104
+ await deps.run(cli, ["plugin", "uninstall", ID, "--scope", "user"]);
105
+ await deps.run(cli, ["plugin", "marketplace", "remove", CLAUDE_MOD_MARKETPLACE]);
106
+ if ((await deps.run(cli, ["plugin", "marketplace", "add", dir])).code !== 0)
107
+ return { state: "failed", cli, reason: "plugin marketplace add failed" };
108
+ if ((await deps.run(cli, ["plugin", "install", ID, "--scope", "user", "--json"])).code !== 0)
109
+ return { state: "failed", cli, reason: "plugin install failed" };
110
+ for (const old of await readdir(base))
111
+ if (old !== deps.version)
112
+ await rm(join(base, old), { recursive: true, force: true });
113
+ return { state: "installed", cli };
114
+ }
115
+ catch (error) {
116
+ return { state: "failed", cli, reason: error.code ?? "install failed" };
117
+ }
118
+ }
119
+ /** Uninstalls the mod and its marketplace, then deletes the runner's copies. Never throws. */
120
+ export async function removeClaudeMod(deps) {
121
+ const cli = await findClaudeCli(deps);
122
+ if (cli !== null) {
123
+ await deps.run(cli, ["plugin", "uninstall", ID, "--scope", "user"]);
124
+ await deps.run(cli, ["plugin", "marketplace", "remove", CLAUDE_MOD_MARKETPLACE]);
125
+ }
126
+ await rm(join(deps.stateRoot, "claude-mod"), { recursive: true, force: true }).catch(() => undefined);
127
+ return { state: "removed", ...(cli ? { cli } : {}) };
128
+ }
129
+ /** What `plugin list` says of the mod. Never throws. */
130
+ export async function claudeModStatus(deps) {
131
+ const cli = await findClaudeCli(deps);
132
+ if (cli === null)
133
+ return { state: "skipped", reason: NO_CLI };
134
+ const current = await listed(deps, cli).catch(() => null);
135
+ return { state: current === null ? "not_installed" : current.enabled ? "installed" : "disabled", cli };
136
+ }
137
+ /** The package's claude-mod/: two folders up from src/asks/, three from dist/src/asks/. */
138
+ function packagedModDir(moduleUrl = import.meta.url) {
139
+ return fileURLToPath(new URL(moduleUrl.toString().includes("/dist/src/") ? "../../../claude-mod/" : "../../claude-mod/", moduleUrl));
140
+ }
141
+ /**
142
+ * The deps setup, integrations and uninstall use. Each CLI call gets a fixed environment, as the Claude login probe
143
+ * does: the person's HOME, USER and LOGNAME, CLAUDE_CONFIG_DIR only when they set it, and the documented opt-outs
144
+ * that keep the CLI from updating itself or sending other traffic.
145
+ */
146
+ export function claudeModDeps(env, stateRoot, home) {
147
+ const childEnv = { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C", HOME: home,
148
+ DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" };
149
+ for (const key of ["CLAUDE_CONFIG_DIR", "USER", "LOGNAME"]) {
150
+ const value = env[key];
151
+ if (value !== undefined && (key !== "CLAUDE_CONFIG_DIR" || isAbsolute(value)))
152
+ childEnv[key] = value;
153
+ }
154
+ return {
155
+ home, path: env.PATH, stateRoot, version: packageVersion(), sourceDir: packagedModDir(),
156
+ run: (file, args) => new Promise(resolve => {
157
+ execFile(file, args, { encoding: "utf8", timeout: RUN_TIMEOUT_MS, maxBuffer: 1_048_576, env: childEnv }, (error, stdout) => {
158
+ const code = error ? (typeof error.code === "number" ? error.code : 1) : 0;
159
+ resolve({ code, stdout: typeof stdout === "string" ? stdout : "" });
160
+ });
161
+ }),
162
+ };
163
+ }
@@ -1,12 +1,13 @@
1
1
  import { readdir, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ASKS_FILE } from "./store.js";
4
- import { installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
4
+ import { claudeModDeps, claudeModStatus, installClaudeMod, removeClaudeMod } from "./claude-mod.js";
5
+ import { homeDirectory, installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
5
6
  import { APPS, isApp } from "./types.js";
6
7
  import { deleteGuideState } from "../guide/files.js";
7
8
  import { guideStatus, installGuide, removeGuide, removeGuideShimIfUnused } from "../guide/integrations.js";
8
9
  const NEXT_STEP = {
9
- claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
10
+ claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir and the Work Room pane (/work-room).",
10
11
  // /hooks is a command of the Codex CLI in a terminal only: the Codex app sends it to the model as text.
11
12
  codex: "Codex runs the four AI Gateway hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
12
13
  + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks. Until then the Slack message covers each ask.",
@@ -79,6 +80,17 @@ export async function statusOf(app, env, stateRoot, error) {
79
80
  return { app, state: "unreadable", path: "", error: error ?? errorCode(failure) };
80
81
  }
81
82
  }
83
+ /** One act on the Work Room mod. It never fails the hooks: a mod that cannot be handled is reported on its own. */
84
+ async function claudeMod(env, stateRoot, act) {
85
+ try {
86
+ return await act(claudeModDeps(env, stateRoot, homeDirectory(env)));
87
+ }
88
+ catch (error) {
89
+ return { state: "failed", reason: errorCode(error) };
90
+ }
91
+ }
92
+ /** The Work Room mod's state, for integrations status and doctor. It runs the Claude CLI, so the daemon never calls it. */
93
+ export const claudeModStatusOf = (env, stateRoot) => claudeMod(env, stateRoot, claudeModStatus);
82
94
  async function attempt(app, env, stateRoot, change) {
83
95
  try {
84
96
  return await change();
@@ -91,10 +103,17 @@ export async function installIntegrations(apps, env, stateRoot) {
91
103
  const results = [];
92
104
  for (const app of apps) {
93
105
  const result = await attempt(app, env, stateRoot, () => installIntegration(app, env, stateRoot));
94
- results.push(result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result);
106
+ const done = result.error === undefined ? { ...result, action: NEXT_STEP[app] } : result;
107
+ // The hooks first: they work without the mod, and a Claude CLI too old for the mod leaves them in place.
108
+ results.push(app === "claude" ? { ...done, mod: await claudeMod(env, stateRoot, installClaudeMod) } : done);
95
109
  }
96
110
  return results;
97
111
  }
112
+ /** Removes one app's asks hooks and, for Claude, the Work Room mod. */
113
+ async function removeAsks(app, env, stateRoot) {
114
+ const result = await attempt(app, env, stateRoot, () => removeIntegration(app, env, stateRoot));
115
+ return app === "claude" ? { ...result, mod: await claudeMod(env, stateRoot, removeClaudeMod) } : result;
116
+ }
98
117
  /** The guide state of one app; a status that cannot be read is reported with its error, never thrown. */
99
118
  export async function guideStatusOf(app, env, stateRoot, error) {
100
119
  try {
@@ -143,7 +162,7 @@ export async function removeGuideInstall(env, stateRoot) {
143
162
  export async function removeAsksInstall(env, stateRoot) {
144
163
  const results = [];
145
164
  for (const app of APPS)
146
- results.push(await attempt(app, env, stateRoot, () => removeIntegration(app, env, stateRoot)));
165
+ results.push(await removeAsks(app, env, stateRoot));
147
166
  // A hook left behind (an unreadable Codex hooks file) keeps its shim, which then prints nothing and exits 0.
148
167
  await removeShimIfUnused(env, stateRoot).catch(() => false);
149
168
  await rm(join(stateRoot, ASKS_FILE), { force: true });
@@ -167,11 +186,12 @@ export async function runIntegrationsCommand(argv, io) {
167
186
  }
168
187
  else if (parsed.action === "remove") {
169
188
  for (const app of parsed.apps)
170
- results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
189
+ results.push(await removeAsks(app, io.env, io.stateRoot));
171
190
  await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
172
191
  }
173
192
  else {
174
- results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
193
+ results = await Promise.all(parsed.apps.map(async (app) => app === "claude"
194
+ ? { ...await statusOf(app, io.env, io.stateRoot), mod: await claudeModStatusOf(io.env, io.stateRoot) } : statusOf(app, io.env, io.stateRoot)));
175
195
  }
176
196
  }
177
197
  if (!features.includes("guide")) {
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { connect } from "node:net";
3
- import { statusOf } from "./commands.js";
3
+ import { claudeModStatusOf, statusOf } from "./commands.js";
4
4
  import { hubSocketPath } from "./hub.js";
5
5
  import { MAX_LINE_BYTES, encodeLine } from "./hub-protocol.js";
6
6
  import { CLAUDE_PLUGIN_NAME } from "./integrations.js";
@@ -93,8 +93,9 @@ export async function asksDoctorReport(input) {
93
93
  const [status, statuses] = await Promise.all([hubStatus(input.stateRoot), Promise.all(APPS.map(app => statusOf(app, input.env, input.stateRoot)))]);
94
94
  const integrations = [];
95
95
  for (const report of statuses) {
96
+ const mod = report.app === "claude" ? { mod: await claudeModStatusOf(input.env, input.stateRoot) } : {};
96
97
  if (report.state !== "installed") {
97
- integrations.push(report);
98
+ integrations.push({ ...report, ...mod });
98
99
  continue;
99
100
  }
100
101
  if (report.app === "codex") {
@@ -102,7 +103,7 @@ export async function asksDoctorReport(input) {
102
103
  continue;
103
104
  }
104
105
  const loaded = await claudePluginLoaded(input.env, input.run);
105
- integrations.push({ ...report, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
106
+ integrations.push({ ...report, ...mod, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
106
107
  }
107
108
  return { mode: input.mode, hub: status === null ? { status: "not_running" } : { status: "running", ...status }, integrations };
108
109
  }
@@ -30,7 +30,7 @@ const RUNNER_CODEX_COMMAND = /^"[^"]*\/mtec-ai-local-runner\/bin\/gateway-hook"
30
30
  export const INTEGRATIONS_FILE = "integrations.json";
31
31
  const RECORD_KEYS = { claude: "claude_config_dir", codex: "codex_home" };
32
32
  /** The person's home: HOME, else the account's home directory. Never a relative path. */
33
- function homeDirectory(env) {
33
+ export function homeDirectory(env) {
34
34
  let account = "";
35
35
  try {
36
36
  account = os.homedir();
package/dist/src/cli.js CHANGED
@@ -38,6 +38,21 @@ import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
38
38
  import { runClaudeReleaseCommand } from "./commands/claude-release.js";
39
39
  import { runFixCommand } from "./commands/fix.js";
40
40
  import { checkRepositoryAccess, fixRepositoryList } from "./workspace/repository-access.js";
41
+ import { localToolSelfTest } from "./safety/local-tool-self-test.js";
42
+ function localToolsReport(result) {
43
+ if (result.status === "passed")
44
+ return { ...result };
45
+ return { ...result, action: localToolsAction(result) };
46
+ }
47
+ function localToolsAction(result) {
48
+ const failed = `Local file tools failed on this Mac (${result.tool ?? "setup"}: ${result.detail ?? "unknown"}), so a job here cannot read source.`;
49
+ // A bare restricted node that also fails cannot start in the sandbox at all. On 2026-10-31 Homebrew's node did this on
50
+ // an employee Mac: it maps libraries from /opt/homebrew, and the sandbox maps only /usr/lib and /System/Library.
51
+ if (result.sandbox?.status === "failed") {
52
+ return `${failed} The Node at ${result.node} cannot start inside the runner's restricted sandbox, which loads libraries only from /usr/lib and /System/Library; a Node built against libraries elsewhere, such as Homebrew's, stops there. Install an official Node.js build from https://nodejs.org, then rerun setup and doctor with its bin directory first on PATH, for example: PATH="<official Node>/bin:$PATH" npx mtech-ai-local-runner@latest setup`;
53
+ }
54
+ return `${failed} Send this doctor output to the AI Gateway owner.`;
55
+ }
41
56
  function hookAction(result) {
42
57
  return result.reason === "invalid_command"
43
58
  ? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
@@ -246,6 +261,15 @@ export async function main(argv, env, io = {}) {
246
261
  stdout(JSON.stringify({ ok: false, configured: false, hook: hookReport(hook), action: hookAction(hook) }));
247
262
  throw new Error(`The PreToolUse hook did not deny the probe call (hook_not_enforcing: ${hook.reason})`);
248
263
  }
264
+ // A full runner whose job file tools cannot read here would take jobs and stop executor_blocked on every one
265
+ // (Work Item 6f9e64ae), so setup refuses before login, as for the hook.
266
+ const tools = await (io.localToolSelfTest ?? localToolSelfTest)(config.workspaceRoot)
267
+ .catch(error => ({ status: "failed", detail: error instanceof Error ? error.message : String(error), node: process.execPath }));
268
+ if (tools.status !== "passed") {
269
+ const report = localToolsReport(tools);
270
+ stdout(JSON.stringify({ ok: false, configured: false, local_tools: report, action: report.action }));
271
+ throw new Error(`The job file tools failed on this Mac (local_tools_failed: ${tools.tool ?? "setup"}: ${tools.detail ?? "unknown"})`);
272
+ }
249
273
  }
250
274
  const loginCode = await handleLogin(config, stdout, stderr, fetchFn, io.browserOpener);
251
275
  if (loginCode !== 0)
@@ -347,6 +371,11 @@ export async function main(argv, env, io = {}) {
347
371
  ]);
348
372
  // A messenger runs no executor, so neither an adapter nor the PreToolUse self-test is required of it.
349
373
  const hookCheck = messenger ? Promise.resolve(null) : hookSelfTest(local.workspaceRoot);
374
+ // Every file tool call failed in one employee's Fix jobs while a job reports only a policy code
375
+ // (Work Item 6f9e64ae): run the same tools once here and show the real error on this Mac.
376
+ const localToolsCheck = messenger ? Promise.resolve(null)
377
+ : (io.localToolSelfTest ?? localToolSelfTest)(local.workspaceRoot)
378
+ .catch(error => ({ status: "failed", detail: error instanceof Error ? error.message : String(error), node: process.execPath }));
350
379
  const asksCheck = asksDoctorReport({ stateRoot: config.stateRoot, env, mode: runnerMode(local), run: io.claudePluginList });
351
380
  const guideCheck = guideDoctorReport({ stateRoot: config.stateRoot, env });
352
381
  // Refresh rotating credentials once before the Gateway client asks for a token.
@@ -362,15 +391,17 @@ export async function main(argv, env, io = {}) {
362
391
  const repositoryAccessPromise = listed === undefined || consented.length === 0 ? Promise.resolve(undefined) : checkRepositoryAccess(consented, listed, io.probeRepository);
363
392
  const [service, workspace] = await localChecks;
364
393
  const hook = await hookCheck;
394
+ const localTools = await localToolsCheck;
365
395
  const asks = await asksCheck;
366
396
  const guide = await guideCheck;
367
397
  const repositoryAccess = await repositoryAccessPromise;
368
398
  const readable = repositoryAccess === undefined || repositoryAccess.every(check => check.status === "readable");
369
399
  const checks = [oauth, gateway, service, workspace];
370
400
  const loaded = service.status === "fulfilled" && service.value.loaded;
371
- const ok = checks.every(check => check.status === "fulfilled") && loaded && readable && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing));
372
- stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded, ...(hook === null ? {} : { hook: hook.enforcing }), ...(repositoryAccess === undefined ? {} : { repository_access: readable }) },
373
- hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(guideReportShown(guide) ? { guide } : {}), ...(repositoryAccess === undefined ? {} : { repository_access: repositoryAccess }), ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
401
+ const ok = checks.every(check => check.status === "fulfilled") && loaded && readable && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing))
402
+ && (localTools === null || localTools.status === "passed");
403
+ stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded, ...(hook === null ? {} : { hook: hook.enforcing }), ...(repositoryAccess === undefined ? {} : { repository_access: readable }), ...(localTools === null ? {} : { local_tools: localTools.status === "passed" }) },
404
+ hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(guideReportShown(guide) ? { guide } : {}), ...(repositoryAccess === undefined ? {} : { repository_access: repositoryAccess }), ...(localTools === null ? {} : { local_tools: localToolsReport(localTools) }), ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
374
405
  return ok ? 0 : 1;
375
406
  }
376
407
  if (config.mode === 'manual-codex') {