mtech-ai-local-runner 0.1.77 → 0.1.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/claude-mod/.claude-plugin/marketplace.json +1 -0
- package/claude-mod/.claude-plugin/plugin.json +1 -0
- package/claude-mod/hooks/gateway.ts +47 -0
- package/claude-mod/hooks/hooks.json +1 -0
- package/claude-mod/hooks/model.ts +155 -0
- package/claude-mod/hooks/register.tsx +269 -0
- package/claude-mod/types/index.d.ts +22 -0
- package/dist/src/adapters/claude-code/adapter.js +16 -5
- package/dist/src/adapters/claude-code/prompt-compiler.js +17 -4
- package/dist/src/adapters/claude-code/runtime-options.js +4 -1
- package/dist/src/adapters/codex/config-builder.js +2 -1
- package/dist/src/adapters/codex/exec-invocation.js +6 -1
- package/dist/src/adapters/codex/exec-preflight.js +4 -2
- package/dist/src/adapters/codex/exec-runtime-options.js +3 -3
- package/dist/src/adapters/codex/exec-tool-gate.js +7 -3
- package/dist/src/adapters/codex/native-policy.js +2 -2
- package/dist/src/adapters/codex/native-session.js +5 -4
- package/dist/src/adapters/codex/pre-model-gate.js +7 -3
- package/dist/src/asks/claude-mod.js +163 -0
- package/dist/src/asks/commands.js +26 -6
- package/dist/src/asks/doctor.js +4 -3
- package/dist/src/asks/integrations.js +1 -1
- package/dist/src/cli.js +16 -3
- package/dist/src/daemon/repository-access-monitor.js +66 -0
- package/dist/src/daemon/runtime.js +16 -1
- package/dist/src/gateway-client.js +2 -0
- package/dist/src/safety/claude-production-host.js +20 -2
- package/dist/src/safety/claude-release.js +4 -1
- package/dist/src/safety/claude-review-campaign.js +4 -4
- package/dist/src/safety/claude-review-run.js +7 -4
- package/dist/src/safety/codex-exec-production-host.js +7 -5
- package/dist/src/safety/codex-manual-controller.js +2 -2
- package/dist/src/safety/hook-policy-builder.js +3 -3
- package/dist/src/safety/job-tool-dispatcher.js +14 -3
- package/dist/src/safety/job-tool-session.js +2 -2
- package/dist/src/safety/local-tool-policy.js +229 -3
- package/dist/src/safety/local-tool-self-test.js +44 -0
- package/dist/src/safety/local-tool-server.js +18 -6
- package/dist/src/safety/local-tool-worker.js +43 -0
- package/dist/src/safety/pre-tool-use-hook.js +17 -2
- 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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,10 @@ 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
|
-
|
|
58
|
-
|
|
59
|
+
// Exactly the tools of the inventory the expected fingerprint names (Work Decision f43e7aa0), never the other one.
|
|
60
|
+
const inventory = localToolInventoryFor(expected.toolSchemaFingerprint, fingerprint);
|
|
61
|
+
require(inventory !== undefined && Array.isArray(server.tools) && server.tools.every(tool => typeof tool === 'string')
|
|
62
|
+
&& JSON.stringify([...server.tools].sort()) === JSON.stringify([...localToolNames(inventory)].sort()), 'MCP_TOOLS_MISMATCH');
|
|
59
63
|
}
|
|
60
64
|
return { ready: reasonCodes.length === 0, reasonCodes };
|
|
61
65
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =>
|
|
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")) {
|
package/dist/src/asks/doctor.js
CHANGED
|
@@ -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,12 @@ 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: `Local file tools failed on this Mac (${result.tool ?? "setup"}: ${result.detail ?? "unknown"}), so a job here cannot read source. Send this doctor output to the AI Gateway owner.` };
|
|
46
|
+
}
|
|
41
47
|
function hookAction(result) {
|
|
42
48
|
return result.reason === "invalid_command"
|
|
43
49
|
? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
|
|
@@ -347,6 +353,11 @@ export async function main(argv, env, io = {}) {
|
|
|
347
353
|
]);
|
|
348
354
|
// A messenger runs no executor, so neither an adapter nor the PreToolUse self-test is required of it.
|
|
349
355
|
const hookCheck = messenger ? Promise.resolve(null) : hookSelfTest(local.workspaceRoot);
|
|
356
|
+
// Every file tool call failed in one employee's Fix jobs while a job reports only a policy code
|
|
357
|
+
// (Work Item 6f9e64ae): run the same tools once here and show the real error on this Mac.
|
|
358
|
+
const localToolsCheck = messenger ? Promise.resolve(null)
|
|
359
|
+
: (io.localToolSelfTest ?? localToolSelfTest)(local.workspaceRoot)
|
|
360
|
+
.catch(error => ({ status: "failed", detail: error instanceof Error ? error.message : String(error), node: process.execPath }));
|
|
350
361
|
const asksCheck = asksDoctorReport({ stateRoot: config.stateRoot, env, mode: runnerMode(local), run: io.claudePluginList });
|
|
351
362
|
const guideCheck = guideDoctorReport({ stateRoot: config.stateRoot, env });
|
|
352
363
|
// Refresh rotating credentials once before the Gateway client asks for a token.
|
|
@@ -362,15 +373,17 @@ export async function main(argv, env, io = {}) {
|
|
|
362
373
|
const repositoryAccessPromise = listed === undefined || consented.length === 0 ? Promise.resolve(undefined) : checkRepositoryAccess(consented, listed, io.probeRepository);
|
|
363
374
|
const [service, workspace] = await localChecks;
|
|
364
375
|
const hook = await hookCheck;
|
|
376
|
+
const localTools = await localToolsCheck;
|
|
365
377
|
const asks = await asksCheck;
|
|
366
378
|
const guide = await guideCheck;
|
|
367
379
|
const repositoryAccess = await repositoryAccessPromise;
|
|
368
380
|
const readable = repositoryAccess === undefined || repositoryAccess.every(check => check.status === "readable");
|
|
369
381
|
const checks = [oauth, gateway, service, workspace];
|
|
370
382
|
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
|
-
|
|
373
|
-
|
|
383
|
+
const ok = checks.every(check => check.status === "fulfilled") && loaded && readable && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing))
|
|
384
|
+
&& (localTools === null || localTools.status === "passed");
|
|
385
|
+
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" }) },
|
|
386
|
+
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
387
|
return ok ? 0 : 1;
|
|
375
388
|
}
|
|
376
389
|
if (config.mode === 'manual-codex') {
|