mtech-ai-local-runner 0.1.112 → 0.1.113

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.
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
5
+ const only = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
6
+ /**
7
+ * Consumes a fresh Gateway prepare receipt, never creates its own authority or binds a recipient.
8
+ * Gateway atomically grants a one-use dispatch claim before the external side effect. The local
9
+ * fence additionally blocks duplicate calls on this adapter, including lost claim acknowledgements.
10
+ * No concrete native endpoint is wired here. A caller must supply the supported authenticated host.
11
+ */
12
+ export class CodexAppSetupAdapter {
13
+ deps;
14
+ attempted = new Set();
15
+ hostId;
16
+ projectId;
17
+ constructor(deps) {
18
+ this.deps = deps;
19
+ if (!text(deps.approvedHostId) || !text(deps.approvedProjectId))
20
+ throw new Error("Explicit approved host and project required");
21
+ this.hostId = deps.approvedHostId;
22
+ this.projectId = deps.approvedProjectId;
23
+ }
24
+ async execute(input) {
25
+ if (!this.validReceipt(input))
26
+ return { status: "rejected" };
27
+ if (!input.can_create || this.attempted.has(input.intent_id))
28
+ return { status: "skipped" };
29
+ if (input.setup_outcome !== "reserved" || input.target_ref !== null || input.authority_failure !== null
30
+ || input.thread_id !== undefined || input.pending_handle !== undefined || input.client_link_id !== undefined)
31
+ return { status: "rejected" };
32
+ const args = this.createArguments(input.create_thread);
33
+ if (!args || createHash("sha256").update(args.prompt, "utf8").digest("hex") !== input.setup_prompt_sha256)
34
+ return { status: "rejected" };
35
+ const intentId = input.intent_id;
36
+ const expiresAt = input.expires_at;
37
+ const setupPromptSha256 = input.setup_prompt_sha256;
38
+ // Fence before any await. A discovery failure cannot cause an accidental concurrent retry.
39
+ this.attempted.add(intentId);
40
+ try {
41
+ const projects = await this.deps.host.listProjects();
42
+ if (!Array.isArray(projects) || !projects.some(project => record(project)
43
+ && project.projectId === this.projectId && project.hostId === this.hostId))
44
+ return { status: "rejected" };
45
+ }
46
+ catch {
47
+ return { status: "rejected" };
48
+ }
49
+ if (expiresAt <= Date.now() / 1000)
50
+ return { status: "rejected" };
51
+ let claim;
52
+ try {
53
+ claim = await this.deps.claimDispatch({ intent_id: intentId, host_id: this.hostId, project_id: this.projectId, outcome: "dispatch" });
54
+ }
55
+ catch {
56
+ return { status: "uncertain", reportRecorded: false };
57
+ }
58
+ if (!this.validReceipt(claim) || claim.intent_id !== intentId)
59
+ return { status: "uncertain", reportRecorded: false };
60
+ if (!claim.can_create)
61
+ return { status: "skipped" };
62
+ if (claim.setup_prompt_sha256 !== setupPromptSha256 || createHash("sha256").update(args.prompt, "utf8").digest("hex") !== claim.setup_prompt_sha256
63
+ || claim.setup_outcome !== "dispatching" || claim.target_ref !== null || claim.authority_failure !== null
64
+ || claim.thread_id !== undefined || claim.pending_handle !== undefined || claim.client_link_id !== undefined
65
+ || expiresAt <= Date.now() / 1000)
66
+ return { status: "uncertain", reportRecorded: false };
67
+ const report = { intent_id: intentId, host_id: this.hostId, project_id: this.projectId, outcome: "uncertain" };
68
+ try {
69
+ const result = await this.deps.host.createThread(args);
70
+ if (record(result)) {
71
+ if (text(result.threadId) && UUID.test(result.threadId) && result.hostId === this.hostId && result.clientThreadId === undefined) {
72
+ report.outcome = "created";
73
+ report.thread_id = result.threadId;
74
+ }
75
+ else if (text(result.clientThreadId) && result.threadId === undefined
76
+ && (result.hostId === undefined || result.hostId === this.hostId)) {
77
+ report.outcome = "pending";
78
+ report.pending_handle = result.clientThreadId;
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // The host may have created the thread before losing its acknowledgement. Never replay.
84
+ }
85
+ try {
86
+ await this.deps.report(report);
87
+ return { status: report.outcome, reportRecorded: true };
88
+ }
89
+ catch {
90
+ // Even a successful host response is not reconciled when Gateway's acknowledgement is lost.
91
+ return { status: "uncertain", reportRecorded: false };
92
+ }
93
+ }
94
+ validReceipt(input) {
95
+ if (!record(input) || !only(input, ["intent_id", "host_id", "project_id", "can_create", "create_thread", "setup_outcome", "target_ref", "grants_execution", "expires_at", "authority_failure", "thread_id", "pending_handle", "client_link_id", "setup_prompt_sha256"])
96
+ || !text(input.intent_id) || !UUID.test(input.intent_id) || input.host_id !== this.hostId || input.project_id !== this.projectId
97
+ || typeof input.setup_prompt_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.setup_prompt_sha256)
98
+ || typeof input.can_create !== "boolean" || input.grants_execution !== false
99
+ || typeof input.expires_at !== "number" || !Number.isSafeInteger(input.expires_at) || input.expires_at <= Date.now() / 1000
100
+ || (input.authority_failure !== null && !text(input.authority_failure))
101
+ || !text(input.setup_outcome) || (input.target_ref !== null && !text(input.target_ref)))
102
+ return false;
103
+ for (const key of ["thread_id", "client_link_id"]) {
104
+ if (input[key] !== undefined && (!text(input[key]) || !UUID.test(input[key])))
105
+ return false;
106
+ }
107
+ if (input.pending_handle !== undefined && (!text(input.pending_handle) || !/^[a-zA-Z0-9_:.-]{1,128}$/.test(input.pending_handle)))
108
+ return false;
109
+ return true;
110
+ }
111
+ createArguments(input) {
112
+ if (!record(input) || !only(input, ["prompt", "title", "target"]) || !text(input.prompt)
113
+ || (input.title !== undefined && !text(input.title)))
114
+ return null;
115
+ const target = input.target;
116
+ if (!record(target) || !only(target, ["type", "projectId", "environment"])
117
+ || target.type !== "project" || target.projectId !== this.projectId
118
+ || !record(target.environment) || !only(target.environment, ["type"])
119
+ || target.environment.type !== "local")
120
+ return null;
121
+ // Copy only validated fields before awaiting discovery; do not pass mutable caller objects.
122
+ return { prompt: input.prompt, ...(input.title === undefined ? {} : { title: input.title }),
123
+ target: { type: "project", projectId: this.projectId, environment: { type: "local" } } };
124
+ }
125
+ }
@@ -0,0 +1,82 @@
1
+ import { CodexAppSetupAdapter } from "./codex-app-setup.js";
2
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
3
+ const uuid = (value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
4
+ /** The observed official host envelope has one JSON text block. Fail closed on schema drift. */
5
+ function data(value) {
6
+ if (!object(value) || (value.isError !== undefined && value.isError !== false)
7
+ || !Array.isArray(value.content) || value.content.length !== 1)
8
+ throw new Error("Unsupported app tool envelope");
9
+ const block = value.content[0];
10
+ if (!object(block) || block.type !== "text" || typeof block.text !== "string")
11
+ throw new Error("Missing app tool JSON");
12
+ const parsed = JSON.parse(block.text);
13
+ if (!object(parsed))
14
+ throw new Error("Unsupported app tool result");
15
+ return parsed;
16
+ }
17
+ export class CodexAppToolsHost {
18
+ tools;
19
+ hostId;
20
+ constructor(tools, hostId) {
21
+ this.tools = tools;
22
+ this.hostId = hostId;
23
+ }
24
+ async listProjects() {
25
+ const result = data(await this.tools.listProjects({}));
26
+ if (result.schemaVersion !== 2 || !Array.isArray(result.projects))
27
+ throw new Error("Unsupported project schema");
28
+ return result.projects.filter((project) => object(project)
29
+ && project.projectKind === "local" && typeof project.projectId === "string" && project.projectId.length > 0
30
+ && project.hostId === this.hostId).map(project => ({ projectId: project.projectId, hostId: this.hostId }));
31
+ }
32
+ async createThread(args) {
33
+ // Only the existing adapter calls this after Gateway's durable dispatch claim.
34
+ return data(await this.tools.createThread(args));
35
+ }
36
+ /** Observation only: never binds a session, reports bound, starts a turn or retries creation. */
37
+ async track(receipt) {
38
+ const unverified = { status: "unverified", grantsExecution: false };
39
+ if (!object(receipt) || Object.keys(receipt).some(key => !["threadId", "pendingHandle"].includes(key)))
40
+ return unverified;
41
+ if (receipt.pendingHandle !== undefined) {
42
+ if (receipt.threadId !== undefined || typeof receipt.pendingHandle !== "string"
43
+ || !/^[a-zA-Z0-9_:.-]{1,128}$/.test(receipt.pendingHandle))
44
+ return unverified;
45
+ // No exposed app tool resolves clientThreadId. In particular do not feed it to read_thread.
46
+ return { status: "pending_resolution_unsupported", pendingHandle: receipt.pendingHandle, grantsExecution: false };
47
+ }
48
+ if (!uuid(receipt.threadId))
49
+ return unverified;
50
+ const threadId = receipt.threadId;
51
+ try {
52
+ const result = data(await this.tools.readThread({ threadId, hostId: this.hostId, turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 100 }));
53
+ if (result.schemaVersion !== 1 || !object(result.thread) || result.thread.id !== threadId
54
+ || result.thread.hostId !== this.hostId || result.thread.kind !== "codex")
55
+ return unverified;
56
+ return { status: "identity_observed", threadId, hostId: this.hostId, grantsExecution: false };
57
+ }
58
+ catch {
59
+ return unverified;
60
+ }
61
+ }
62
+ }
63
+ export function connectCodexAppSetup(deps) {
64
+ const host = new CodexAppToolsHost(deps.tools, deps.approvedHostId);
65
+ return { host, adapter: new CodexAppSetupAdapter({ ...deps, host }),
66
+ /** Pass normalized data from Gateway report(outcome: status), not an old prepare response. */
67
+ trackReceipt: async (receipt) => {
68
+ const unverified = { status: "unverified", grantsExecution: false };
69
+ if (!object(receipt) || !uuid(receipt.intent_id) || receipt.host_id !== deps.approvedHostId
70
+ || receipt.project_id !== deps.approvedProjectId || receipt.can_create !== false
71
+ || receipt.grants_execution !== false || receipt.authority_failure !== null
72
+ || receipt.create_thread !== undefined)
73
+ return unverified;
74
+ if (receipt.setup_outcome === "pending" && receipt.thread_id === undefined && typeof receipt.pending_handle === "string") {
75
+ return host.track({ pendingHandle: receipt.pending_handle });
76
+ }
77
+ if (receipt.setup_outcome === "created" && uuid(receipt.thread_id)) {
78
+ return host.track({ threadId: receipt.thread_id });
79
+ }
80
+ return unverified;
81
+ } };
82
+ }
@@ -80,9 +80,19 @@ export async function buildDaemon(input) {
80
80
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
81
81
  // Only this daemon's currently executing claim may revalidate a D4 pre-spawn request.
82
82
  let activeD4Job, activeD4Binding;
83
- let d4Host = input.d4ExecutionHost;
83
+ let d4Host = input.d4ExecutionHost, d4ProductionBinding;
84
84
  if (!d4Host) {
85
- const composition = await composeReviewedD4Host({ binaryIdentity: () => claude.binaryIdentity(), revalidateJob: async (descriptor, context) => {
85
+ const composition = await composeReviewedD4Host({ currentJob: () => {
86
+ const job = activeD4Job;
87
+ if (!job || !activeD4Binding)
88
+ return undefined;
89
+ const pkg = job.taskPackage, repository = pkg.repository;
90
+ return { descriptor: pkg.candidate_review_execution, context: { jobId: job.id, bindingId: activeD4Binding, generation: job.generation,
91
+ launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint },
92
+ request: { adapterId: String(pkg.runtime_adapter), adapterVersion: String(pkg.runtime_adapter_version), jobId: job.id, generation: job.generation,
93
+ modelId: String(pkg.resolved_model_id), sourceSha: String(repository?.base_commit_sha), requirements: pkg.security_requirements,
94
+ launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint } };
95
+ }, binaryIdentity: () => claude.binaryIdentity(), revalidateJob: async (descriptor, context) => {
86
96
  const job = activeD4Job;
87
97
  if (!job || context.jobId !== job.id || context.bindingId !== activeD4Binding || context.generation !== 1 || job.generation !== 1
88
98
  || context.launchFingerprint !== job.launchFingerprint || context.contractFingerprint !== job.gatewayContractFingerprint
@@ -97,6 +107,8 @@ export async function buildDaemon(input) {
97
107
  throw Error('D4_JOB_CONTEXT_REJECTED');
98
108
  } });
99
109
  d4Host = composition.host;
110
+ if (composition.status === 'composed')
111
+ d4ProductionBinding = composition.productionBinding;
100
112
  process.stderr.write(`${JSON.stringify({ event: 'd4_host', status: composition.status })}\n`);
101
113
  }
102
114
  // The menu bar app (Work Item b9218937): counts and clickable notices; osascript stays the fallback.
@@ -144,7 +156,9 @@ export async function buildDaemon(input) {
144
156
  options: async ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
145
157
  worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
146
158
  maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
147
- securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
159
+ securityHost: activeD4Job?.id === jobId && claudeProduction
160
+ ? d4ProductionBinding ? claudeProduction.d4SecurityHost(d4ProductionBinding) : async () => { throw Error('D4_REVIEW_HOST_REQUIRED'); }
161
+ : claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
148
162
  pullRequestEvidence: (args, signal) => gateway.readPullRequestEvidence(args, signal),
149
163
  readCandidateReviewContext: loopId => gateway.readCandidateReviewContext(loopId),
150
164
  d4ExecutionHost: d4Host,
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { assertReviewedD4Request } from './d4-host-composition.js';
2
3
  import { execFile, spawn } from 'node:child_process';
3
4
  import { CLAUDE_TOKEN_FD, CLAUDE_TOKEN_FD_ENV, pathWithKeychainStub } from './claude-runner-token.js';
4
5
  import { lstat, realpath } from 'node:fs/promises';
@@ -256,13 +257,15 @@ export function createClaudeProductionHost(input) {
256
257
  };
257
258
  const blocked = (code, executableVersion) => ({ available: false, authenticated: false, loginStatus: 'unknown', capabilities: [], reason: code,
258
259
  ...(executableVersion ? { executableVersion } : {}), admission: { checks: [{ id: CHECK_IDS[code] ?? 'release_copy', status: 'blocked', code }] } });
259
- const securityHost = async (request) => {
260
+ const createSecurityHost = (d4) => async (request) => {
260
261
  if (closed)
261
262
  throw Error('CLAUDE_HOST_CLOSED');
262
263
  const requirements = requireClaudeRequirements(request.requirements, { adapterId: request.adapterId, adapterVersion: request.adapterVersion }), release = requirements.release;
264
+ if (d4)
265
+ await assertReviewedD4Request(d4, request);
263
266
  const { release: synced, binary } = await verifiedCopy();
264
267
  if (release.version !== synced.version || release.platform !== synced.platform || release.binary_sha256 !== synced.binary_sha256
265
- || !isDeepStrictEqual([...release.model_ids], [...synced.model_ids]) || release.platform !== platform || !release.model_ids.includes(request.modelId))
268
+ || (!d4 && !isDeepStrictEqual([...release.model_ids], [...synced.model_ids])) || release.platform !== platform || !release.model_ids.includes(request.modelId))
266
269
  throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
267
270
  const tools = claudeReleaseToolInventory(release);
268
271
  await verifyClaudeCodeSignature(binary, input.codesign);
@@ -277,6 +280,8 @@ export function createClaudeProductionHost(input) {
277
280
  verify: async (captured) => {
278
281
  if (closed || captured.binarySha256 !== release.binary_sha256 || captured.securityMode !== CLAUDE_RELEASE_MODE)
279
282
  throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
283
+ if (d4)
284
+ await assertReviewedD4Request(d4, request);
280
285
  await verifyClaudeReleaseCopy(input.home, release).catch(error => { probeMemo = undefined; throw error; });
281
286
  return { version: 2, pin: captured, checks: CLAUDE_MANDATORY_GATES.map(gate => ({ gate, status: 'passed' })) };
282
287
  } };
@@ -328,7 +333,13 @@ export function createClaudeProductionHost(input) {
328
333
  const { binary } = await verifiedCopy();
329
334
  return captureClaudeBinaryIdentity({ launchPath: binary, allowedRoots: [claudeReleaseRoot(input.home)] });
330
335
  },
331
- securityHost,
336
+ securityHost: createSecurityHost(),
337
+ /** A separate capability issued by reviewed composition; no task/env field can relax generic model equality. */
338
+ d4SecurityHost(binding) {
339
+ if (!binding)
340
+ throw Error('D4_HOST_INPUT_REJECTED');
341
+ return createSecurityHost(binding);
342
+ },
332
343
  async close() { closed = true; abort.abort(); await pending; },
333
344
  };
334
345
  }
@@ -9,7 +9,15 @@ import { assertCurrentClaudeBinary, claudeBinaryLaunchPath } from './claude-bina
9
9
  import { localToolInventory } from './local-tool-server.js';
10
10
  import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
11
11
  import { assertD4PackageLimitSupport, D4_RECORDED_EXECUTION_SCHEMA, D4_RECORDED_EXECUTION_CAPABILITY } from './d4-execution-limits.js';
12
+ import { requireClaudeRequirements } from './claude-release.js';
12
13
  import { REVIEWED_D4_HOST_REGISTRATIONS } from './d4-reviewed-hosts.js';
14
+ const productionBindings = new WeakMap();
15
+ export async function assertReviewedD4Request(binding, request) {
16
+ const assert = productionBindings.get(binding);
17
+ if (!assert)
18
+ throw denied();
19
+ await assert(request);
20
+ }
13
21
  const denied = () => Error('D4_HOST_INPUT_REJECTED');
14
22
  const hex = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
15
23
  const uuid = (v) => typeof v === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v);
@@ -92,7 +100,38 @@ export async function composeReviewedD4Host(input) {
92
100
  throw denied();
93
101
  }
94
102
  } });
95
- return { status: 'composed', host };
103
+ let productionBinding;
104
+ if (input.currentJob) {
105
+ const currentJob = input.currentJob;
106
+ productionBinding = Object.freeze({ purpose: 'd4-reviewed-production-binding-v1' });
107
+ productionBindings.set(productionBinding, async (request) => {
108
+ try {
109
+ const requirements = requireClaudeRequirements(request.requirements, request), release = requirements.release;
110
+ if (requirements.fingerprint !== p.cli_security_fingerprint || request.modelId !== p.model_id || request.generation !== 1
111
+ || release.binary_sha256 !== p.claude_binary_sha256 || release.tools_sha256 !== p.tools_sha256
112
+ || release.model_ids.length !== 1 || release.model_ids[0] !== p.model_id)
113
+ throw denied();
114
+ const current = currentJob();
115
+ if (!current)
116
+ throw denied();
117
+ const snapshot = structuredClone(current);
118
+ if (protocolHash(snapshot.request) !== protocolHash(request))
119
+ throw denied();
120
+ for (const key of ['jobId', 'generation', 'launchFingerprint', 'contractFingerprint', 'taskPackageFingerprint'])
121
+ if (snapshot.context[key] !== request[key])
122
+ throw denied();
123
+ await host.assertCurrent(snapshot.descriptor, snapshot.context);
124
+ // The current claim, binding, candidate/actor pins and requirements must survive every asynchronous check.
125
+ const after = currentJob();
126
+ if (!after || protocolHash(after) !== protocolHash(snapshot))
127
+ throw denied();
128
+ }
129
+ catch {
130
+ throw denied();
131
+ }
132
+ });
133
+ }
134
+ return { status: 'composed', host, ...(productionBinding ? { productionBinding } : {}) };
96
135
  }
97
136
  catch {
98
137
  return { status: 'mismatch' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -8,8 +8,6 @@
8
8
  },
9
9
  "files": [
10
10
  "dist/src",
11
- "claude-mod",
12
- "!claude-mod/**/*.test.tsx",
13
11
  "README.md",
14
12
  "LICENSE",
15
13
  "native/bin/codex-observe-darwin-arm64",
@@ -1 +0,0 @@
1
- { "name": "mtec-ai-gateway", "owner": { "name": "mtech-ai-local-runner" }, "plugins": [{ "name": "ai-gateway-work-room", "source": "./" }] }
@@ -1 +0,0 @@
1
- { "name": "ai-gateway-work-room", "version": "1.5.0", "description": "Work Room shares and requests from colleagues beside the conversation, installed by mtech-ai-local-runner (Work Item 98da2635).", "types": "./types/index.d.ts" }
@@ -1,47 +0,0 @@
1
- import type { EngineInterface } from 'claude-code'
2
-
3
- import { clean } from './model'
4
-
5
- /*
6
- * Every Gateway call the Work Room pane makes goes through the person's own Claude AI Gateway connector (Work Item
7
- * 98da2635, owner choice 2026-10-30), with no model turn. The connector's server name differs by setup:
8
- * `ai-gateway` from an MCP config, an id in the desktop app; tools are found by their name suffix.
9
- */
10
-
11
- export type GatewayTool = 'work_room_read' | 'work_room_write' | 'work_room_receipt_report' | 'work_inbox' | 'work_request_answer'
12
- export type GatewayAnswer =
13
- | { ok: true; payload: unknown }
14
- | { ok: false; state: 'no-connector' | 'refused' | 'error'; detail: string }
15
-
16
- export function findTool(tools: readonly { name: string; mcp: boolean }[], name: GatewayTool): string | undefined {
17
- const names = tools
18
- .filter(tool => tool.mcp && tool.name.startsWith('mcp__') && tool.name.endsWith(`__${name}`))
19
- .map(tool => tool.name)
20
-
21
- return names.find(found => found === `mcp__ai-gateway__${name}`) ?? names[0]
22
- }
23
-
24
- /** One call. A press passes its consent; a timer passes none. Never throws. */
25
- export async function callGateway($: EngineInterface, name: GatewayTool, input: Record<string, unknown>, consent?: string): Promise<GatewayAnswer> {
26
- try {
27
- const tool = findTool(await $.tool.list(), name)
28
- if (tool === undefined) return { ok: false, state: 'no-connector', detail: name }
29
- // Typed loosely: the MCP tool union is too deep for the type checker. $.tool.call stays spelled at the call site.
30
- const answer = (await $.tool.call({ tool, ...input, ...(consent ? { consent } : {}) } as never)) as {
31
- deny?: string
32
- result?: unknown
33
- text?: string
34
- isError?: boolean
35
- }
36
- if (answer.deny !== undefined) return { ok: false, state: 'refused', detail: clean(answer.deny, 400) }
37
- if (answer.isError) {
38
- const detail = clean(typeof answer.result === 'string' ? answer.result : answer.text, 400)
39
-
40
- return { ok: false, state: 'error', detail: detail || 'Gateway error' }
41
- }
42
-
43
- return { ok: true, payload: { result: answer.result, text: answer.text } }
44
- } catch (error) {
45
- return { ok: false, state: 'error', detail: clean(error instanceof Error ? error.message : 'Gateway call failed', 400) }
46
- }
47
- }
@@ -1,34 +0,0 @@
1
- {
2
- "modules": ["./register.tsx"],
3
- "hooks": {
4
- "PreToolUse": [
5
- {
6
- "matcher": "^mcp__.+__(work_room_read|work_inbox)$",
7
- "hooks": [
8
- {
9
- "type": "command",
10
- "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Work Room pane: reading your own AI Gateway Rooms and requests is allowed.\"}}'"
11
- }
12
- ]
13
- },
14
- {
15
- "matcher": "^mcp__.+__work_room_receipt_report$",
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "if grep -Eq '\"state\"[[:space:]]*:[[:space:]]*\"presented\"'; then printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Work Room pane: recording that it showed you these Room messages is allowed.\"}}'; else printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Work Room: marking Room messages read tells the AI Gateway you read them. Allow it?\"}}'; fi"
20
- }
21
- ]
22
- },
23
- {
24
- "matcher": "^mcp__.+__(work_room_write|work_request_answer)$",
25
- "hooks": [
26
- {
27
- "type": "command",
28
- "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Work Room: this sends what you wrote to colleagues through the AI Gateway. Allow it?\"}}'"
29
- }
30
- ]
31
- }
32
- ]
33
- }
34
- }