mtech-ai-local-runner 0.1.3 → 0.1.5

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 CHANGED
@@ -7,3 +7,28 @@ npx mtech-ai-local-runner setup
7
7
  ```
8
8
 
9
9
  The package stores safe runner configuration locally and keeps OAuth refresh credentials in macOS Keychain when available.
10
+
11
+ Public npm installation requires no npm account or publishing OTP. Setup requires Gateway OAuth login, and local Claude Code must already be authenticated (`claude auth status`).
12
+
13
+ ## 0.1.5
14
+
15
+ - Authenticate local MCP relay callers with a random per-job bearer capability.
16
+ - Forward only the job-selected Gateway tools; filter tool discovery and reject other MCP methods, batches, browser origins and foreign Host headers.
17
+ - Revoke capabilities and remove private MCP configs when jobs settle or the runtime closes normally.
18
+ - Update uuid to patched version 11.1.1.
19
+
20
+ The relay binds only to loopback. Local capabilities are stored in private mode-0600 config files, never URLs or command arguments. Gateway OAuth tokens stay in the runner. Abrupt process termination may leave a private config file; its capability is inert after the relay dies and is never reused by a new process. These controls do not sandbox malicious programs already running as the same macOS user.
21
+
22
+ ## 0.1.4
23
+
24
+ - Independently verify the approved contract payload and task-package fingerprints before creating a runtime binding.
25
+ - Include actual diff and changed-file manifest hashes in terminal events, with a final checkpoint before completion.
26
+ - Fail closed on missing terminal events, failed launches, unavailable evidence, or rejected event reports.
27
+ - Read and acknowledge supervisor controls; preserve the terminal status when reconciling a daemon restart.
28
+
29
+ Existing employees can upgrade their installed daemon with:
30
+
31
+ ```bash
32
+ npx mtech-ai-local-runner@0.1.5 setup
33
+ npx mtech-ai-local-runner@0.1.5 doctor
34
+ ```
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ /** Private local capabilities only. Gateway OAuth credentials never enter this file. */
5
+ export class McpRelayConfig {
6
+ stateRoot;
7
+ relay;
8
+ url;
9
+ path = null;
10
+ constructor(stateRoot, relay, url) {
11
+ this.stateRoot = stateRoot;
12
+ this.relay = relay;
13
+ this.url = url;
14
+ }
15
+ write(config) {
16
+ this.clear();
17
+ const directory = join(this.stateRoot, "mcp-config");
18
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
19
+ chmodSync(directory, 0o700);
20
+ const path = join(directory, `mcp-${randomUUID()}.json`);
21
+ const mcpServers = config.tools.allow.length ? {
22
+ ai_gateway: { type: "http", url: this.url, headers: this.relay.authorizeTools(config.tools.allow) },
23
+ } : {};
24
+ try {
25
+ writeFileSync(path, JSON.stringify({ mcpServers }), { mode: 0o600, flag: "wx" });
26
+ this.path = path;
27
+ return path;
28
+ }
29
+ catch (error) {
30
+ this.relay.revokeAuthorization();
31
+ throw error;
32
+ }
33
+ }
34
+ clear() {
35
+ this.relay.revokeAuthorization();
36
+ if (this.path) {
37
+ try {
38
+ unlinkSync(this.path);
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "ENOENT")
42
+ throw error;
43
+ }
44
+ this.path = null;
45
+ }
46
+ }
47
+ }
@@ -1,9 +1,16 @@
1
1
  import { createServer } from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
3
  const MAX_BODY_SIZE = 1024 * 1024; // 1 MiB
3
4
  export class McpRelay {
4
5
  opts;
5
6
  server = null;
6
7
  url = null;
8
+ authorization = null;
9
+ authorizeTools(tools) {
10
+ this.authorization = { secret: randomBytes(32).toString("hex"), tools: new Set(tools) };
11
+ return { Authorization: `Bearer ${this.authorization.secret}` };
12
+ }
13
+ revokeAuthorization() { this.authorization = null; }
7
14
  constructor(opts) {
8
15
  this.opts = opts;
9
16
  // Validate upstreamMcpUrl is HTTPS and a valid URL
@@ -27,8 +34,21 @@ export class McpRelay {
27
34
  res.end("Not found");
28
35
  return;
29
36
  }
37
+ if (req.headers.origin !== undefined || req.headers.host !== new URL(this.url).host) {
38
+ res.writeHead(403);
39
+ res.end("Forbidden");
40
+ return;
41
+ }
42
+ const authorization = this.authorization;
43
+ const supplied = Buffer.from(req.headers.authorization ?? "");
44
+ const expected = Buffer.from(authorization ? `Bearer ${authorization.secret}` : "");
45
+ if (!authorization || supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
46
+ res.writeHead(401);
47
+ res.end("Unauthorized");
48
+ return;
49
+ }
30
50
  // Collect body with size limit
31
- let body = "";
51
+ const chunks = [];
32
52
  let size = 0;
33
53
  let tooLarge = false;
34
54
  let aborted = false;
@@ -43,7 +63,7 @@ export class McpRelay {
43
63
  req.destroy();
44
64
  return;
45
65
  }
46
- body += chunk.toString();
66
+ chunks.push(chunk);
47
67
  });
48
68
  req.on("end", async () => {
49
69
  if (tooLarge || aborted)
@@ -52,18 +72,33 @@ export class McpRelay {
52
72
  // Validate JSON
53
73
  let payload;
54
74
  try {
55
- payload = JSON.parse(body);
75
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
56
76
  }
57
77
  catch {
58
78
  res.writeHead(400, { "content-type": "application/json" });
59
79
  res.end(JSON.stringify({ error: "Invalid JSON" }));
60
80
  return;
61
81
  }
82
+ if (!this.allowedRequest(payload, authorization.tools)) {
83
+ res.writeHead(403);
84
+ res.end("Forbidden MCP request");
85
+ return;
86
+ }
62
87
  // Check one more time that we can respond (guard against race)
63
88
  if (tooLarge || aborted)
64
89
  return;
90
+ if (this.authorization !== authorization) {
91
+ res.writeHead(401);
92
+ res.end("Authorization revoked");
93
+ return;
94
+ }
65
95
  // Get current token
66
96
  const token = await this.opts.tokenProvider.getToken();
97
+ if (this.authorization !== authorization) {
98
+ res.writeHead(401);
99
+ res.end("Authorization revoked");
100
+ return;
101
+ }
67
102
  // Forward to upstream with token injection
68
103
  // Ensure exact URL match to prevent redirection to unintended endpoints
69
104
  const upstreamUrl = new URL(this.opts.upstreamMcpUrl);
@@ -72,12 +107,20 @@ export class McpRelay {
72
107
  headers: {
73
108
  Authorization: `Bearer ${token}`,
74
109
  "content-type": "application/json",
110
+ Accept: "application/json",
75
111
  },
76
- body,
112
+ body: JSON.stringify(payload),
77
113
  redirect: "error",
78
114
  });
79
115
  // Read upstream response
80
- const upstreamText = await upstreamResponse.text();
116
+ let upstreamText = await upstreamResponse.text();
117
+ if (payload.method === "tools/list" && upstreamResponse.ok) {
118
+ const listed = JSON.parse(upstreamText);
119
+ if (!Array.isArray(listed.result?.tools))
120
+ throw new Error("Invalid upstream tool list");
121
+ listed.result.tools = listed.result.tools.filter((tool) => typeof tool?.name === "string" && authorization.tools.has(tool.name));
122
+ upstreamText = JSON.stringify(listed);
123
+ }
81
124
  // Redact tokens from upstream errors
82
125
  const redactedText = this.redactSecrets(upstreamText);
83
126
  // Forward response to client
@@ -117,6 +160,7 @@ export class McpRelay {
117
160
  });
118
161
  }
119
162
  async close() {
163
+ this.revokeAuthorization();
120
164
  return new Promise((resolve, reject) => {
121
165
  if (!this.server) {
122
166
  resolve();
@@ -130,9 +174,26 @@ export class McpRelay {
130
174
  });
131
175
  });
132
176
  }
177
+ allowedRequest(value, tools) {
178
+ if (!value || typeof value !== "object" || Array.isArray(value))
179
+ return false;
180
+ const rpc = value;
181
+ if (rpc.jsonrpc !== "2.0" || typeof rpc.method !== "string")
182
+ return false;
183
+ if (rpc.method === "notifications/initialized")
184
+ return rpc.id === undefined;
185
+ if (!(typeof rpc.id === "string" || typeof rpc.id === "number"))
186
+ return false;
187
+ if (["initialize", "ping", "tools/list"].includes(rpc.method))
188
+ return true;
189
+ if (rpc.method !== "tools/call" || !rpc.params || typeof rpc.params !== "object" || Array.isArray(rpc.params))
190
+ return false;
191
+ const name = rpc.params.name;
192
+ return typeof name === "string" && tools.has(name);
193
+ }
133
194
  redactSecrets(text) {
134
195
  let sanitized = text;
135
- for (const secret of this.opts.tokenProvider.secrets()) {
196
+ for (const secret of [...this.opts.tokenProvider.secrets(), this.authorization?.secret ?? ""].filter(Boolean)) {
136
197
  sanitized = sanitized.split(secret).join("[redacted]");
137
198
  }
138
199
  return sanitized;
package/dist/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, writeFileSync, chmodSync, unlinkSync, realpathSync } from "node:fs";
2
+ import { unlinkSync, realpathSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { loadConfig, redact, isAuthConfig, isExecutionConfig, isServiceConfig } from "./config.js";
@@ -16,6 +16,7 @@ import { OAuthClient } from "./auth/oauth-client.js";
16
16
  import { LoopbackLogin } from "./auth/loopback-login.js";
17
17
  import { OAuthTokenProvider, LoginRequiredError } from "./auth/token-provider.js";
18
18
  import { McpRelay } from "./auth/mcp-relay.js";
19
+ import { McpRelayConfig } from "./auth/mcp-relay-config.js";
19
20
  import { createBrowserOpener } from "./auth/browser-opener.js";
20
21
  import { FileLocalConfigStore } from "./registration/local-config.js";
21
22
  import { LaunchdService } from "./platform/macos/launchd.js";
@@ -51,26 +52,6 @@ function buildCheckoutPreflight() {
51
52
  },
52
53
  };
53
54
  }
54
- function buildMcpConfigWriter(stateRoot, relayUrl) {
55
- return (config) => {
56
- const dir = join(stateRoot, "mcp-config");
57
- mkdirSync(dir, { recursive: true, mode: 0o700 });
58
- const filePath = join(dir, `mcp-${process.pid}-${Date.now()}.json`);
59
- // Write token-free config pointing to relay; relay injects auth
60
- const mcpServers = config.tools.allow.length === 0
61
- ? {}
62
- : {
63
- ai_gateway: {
64
- type: "http",
65
- url: relayUrl,
66
- },
67
- };
68
- const contents = JSON.stringify({ mcpServers });
69
- writeFileSync(filePath, contents, { mode: 0o600 });
70
- chmodSync(filePath, 0o600);
71
- return filePath;
72
- };
73
- }
74
55
  async function buildOnceAdapter(config, env, tokenProvider, fetchFn) {
75
56
  const relay = new McpRelay({
76
57
  tokenProvider,
@@ -78,14 +59,7 @@ async function buildOnceAdapter(config, env, tokenProvider, fetchFn) {
78
59
  upstreamFetch: fetchFn,
79
60
  });
80
61
  const relayServer = await relay.start();
81
- let configPath = null;
82
- const configWriter = buildMcpConfigWriter(config.stateRoot, relayServer.url);
83
- const originalWriteConfig = configWriter;
84
- const wrappingConfigWriter = (cfg) => {
85
- const path = originalWriteConfig(cfg);
86
- configPath = path;
87
- return path;
88
- };
62
+ const configs = new McpRelayConfig(config.stateRoot, relay, relayServer.url);
89
63
  let adapter;
90
64
  try {
91
65
  adapter = new ClaudeCodeAdapter({
@@ -95,7 +69,7 @@ async function buildOnceAdapter(config, env, tokenProvider, fetchFn) {
95
69
  workItemLabel: config.workLoopId,
96
70
  env,
97
71
  registeredReadTools: config.registeredReadTools,
98
- writeMcpConfig: wrappingConfigWriter,
72
+ writeMcpConfig: (cfg) => configs.write(cfg),
99
73
  mcpServerName: "ai_gateway",
100
74
  binaryPath: config.claudeBinaryPath,
101
75
  hookExecutablePath: config.hookExecutablePath,
@@ -112,7 +86,12 @@ async function buildOnceAdapter(config, env, tokenProvider, fetchFn) {
112
86
  throw err;
113
87
  }
114
88
  // Cleanup function: always attempts both config unlink and relay close
115
- const cleanup = async () => cleanupOnceResources(configPath, () => relay.close());
89
+ const cleanup = async () => cleanupOnceResources(null, async () => { try {
90
+ configs.clear();
91
+ }
92
+ finally {
93
+ await relay.close();
94
+ } });
116
95
  return { adapter, cleanup };
117
96
  }
118
97
  async function handleLogin(config, stdout, stderr, fetchFn = fetch, opener) {
@@ -203,7 +182,7 @@ export async function main(argv, env, io = {}) {
203
182
  const authenticated = await runFile(binary, ["auth", "status"], { encoding: "utf8" }).then(() => true).catch(() => false);
204
183
  const registered = await gateway.registerLocalRunner({
205
184
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
206
- displayName: runnerDisplayName(), packageVersion: "0.1.3",
185
+ displayName: runnerDisplayName(), packageVersion: "0.1.5",
207
186
  capabilities: [{ adapter: "claude_code_cli", version: "1", capabilities: ["repository_read", "repository_edit", "shell_test", "git_diff"], authenticated }],
208
187
  });
209
188
  await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1 });
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { PROTOCOL_VERSION } from "../protocol.js";
2
+ import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
3
3
  function canonical(value) {
4
4
  if (Array.isArray(value))
5
5
  return value.map(canonical);
@@ -12,6 +12,17 @@ export function fingerprint(value) {
12
12
  .replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
13
13
  return createHash("sha256").update(json).digest("hex");
14
14
  }
15
+ export function contractFingerprint(payload) {
16
+ const unsigned = structuredClone(payload);
17
+ if (unsigned.provenance && typeof unsigned.provenance === "object") {
18
+ delete unsigned.provenance.compiled_at;
19
+ }
20
+ // Contract compiler uses PHP JSON's default escaped slashes. Job packages
21
+ // use JSON_UNESCAPED_SLASHES; the two hashing domains must stay distinct.
22
+ const json = JSON.stringify(canonical(unsigned)).replace(/\//g, "\\/")
23
+ .replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
24
+ return createHash("sha256").update(json).digest("hex");
25
+ }
15
26
  function text(value, label) {
16
27
  if (typeof value !== "string" || value.length === 0)
17
28
  throw new Error(`task package is missing ${label}`);
@@ -27,7 +38,7 @@ export class ExecutionCoordinator {
27
38
  const repository = pkg.repository;
28
39
  const adapterId = text(pkg.runtime_adapter, "runtime adapter");
29
40
  const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
30
- const contractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
41
+ const expectedContractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
31
42
  const workspace = await this.deps.workspaces.create({
32
43
  jobId: job.id,
33
44
  repositoryKey: text(repository?.key, "repository key"),
@@ -37,45 +48,129 @@ export class ExecutionCoordinator {
37
48
  const adapter = await this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id });
38
49
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "shadow", workspaceManifest: workspace.manifest });
39
50
  const gatewayShadow = await this.deps.gateway.shadow(job);
40
- if (gatewayShadow.contractFingerprint !== contractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
51
+ if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
41
52
  throw new Error("contract or task-package fingerprint mismatch before binding");
42
53
  }
43
54
  const binding = await this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path });
44
55
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest });
45
- await adapter.start({ bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId });
46
56
  let heartbeatFailure = null;
47
- const heartbeatTimer = this.deps.gateway.heartbeat ? setInterval(() => {
48
- void this.deps.gateway.heartbeat?.(binding.bindingId, job).catch(async (error) => {
57
+ let requestedTerminal;
58
+ let commandCursor = 0;
59
+ let controlsStopped = false;
60
+ let controlsInFlight;
61
+ const readControls = async () => {
62
+ if (!this.deps.gateway.readCommands || controlsStopped || requestedTerminal)
63
+ return;
64
+ if (controlsInFlight)
65
+ return controlsInFlight;
66
+ controlsInFlight = (async () => {
67
+ for (const command of await this.deps.gateway.readCommands(binding.bindingId, commandCursor)) {
68
+ if (controlsStopped)
69
+ return;
70
+ if (["cancel", "pause", "request_replan"].includes(command.command)) {
71
+ requestedTerminal = { eventType: command.command === "cancel" ? "cancelled" : "blocked", summary: `supervisor ${command.command} acknowledged` };
72
+ await adapter.cancel();
73
+ }
74
+ else {
75
+ await adapter.command(command);
76
+ }
77
+ if (!this.deps.gateway.acknowledgeCommand)
78
+ throw new Error("daemon command acknowledgement is unavailable");
79
+ await this.deps.gateway.acknowledgeCommand(binding.bindingId, command.sequence);
80
+ commandCursor = command.sequence;
81
+ if (requestedTerminal)
82
+ break;
83
+ }
84
+ })();
85
+ try {
86
+ await controlsInFlight;
87
+ }
88
+ finally {
89
+ controlsInFlight = undefined;
90
+ }
91
+ };
92
+ const heartbeatTimer = this.deps.gateway.heartbeat || this.deps.gateway.readCommands ? setInterval(() => {
93
+ void (async () => { await this.deps.gateway.heartbeat?.(binding.bindingId, job); await readControls(); })().catch(async (error) => {
49
94
  heartbeatFailure = error instanceof Error ? error : new Error(String(error));
50
95
  await adapter.cancel().catch(() => undefined);
51
96
  });
52
97
  }, 30_000) : undefined;
53
98
  heartbeatTimer?.unref();
54
- let terminal = { eventType: "completed", summary: "adapter completed" };
99
+ let terminal;
55
100
  let sequence = 0;
101
+ let checkpointSeen = false;
102
+ const append = async (event) => {
103
+ let payload = event.payload;
104
+ if (isTerminalEventType(event.eventType)) {
105
+ const evidence = this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha });
106
+ payload = { ...payload, diff_hash: evidence.diffHash, changed_file_manifest_hash: evidence.changedFileManifestHash };
107
+ }
108
+ const normalized = {
109
+ protocolVersion: PROTOCOL_VERSION, bindingId: binding.bindingId,
110
+ sequence: sequence + 1, eventType: event.eventType,
111
+ summary: event.summary.slice(0, MAX_EVENT_SUMMARY_CHARS),
112
+ ...(payload ? { payload } : {}),
113
+ idempotencyKey: `${binding.bindingId}:${sequence + 1}`, occurredAt: new Date().toISOString(),
114
+ };
115
+ validateEvent(normalized);
116
+ if (!this.deps.gateway.appendEvent)
117
+ throw new Error("daemon event reporting is unavailable");
118
+ await this.deps.gateway.appendEvent(binding.bindingId, normalized);
119
+ sequence = normalized.sequence;
120
+ if (event.eventType === "checkpoint_ready")
121
+ checkpointSeen = true;
122
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, eventCursor: sequence, ...(isTerminalEventType(event.eventType) ? { terminalEventType: event.eventType } : {}), workspaceManifest: workspace.manifest });
123
+ };
56
124
  try {
57
- if (adapter.events) {
125
+ await adapter.start({ bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId });
126
+ await readControls();
127
+ if (adapter.events && !requestedTerminal) {
58
128
  for await (const event of adapter.events()) {
59
129
  if (heartbeatFailure)
60
130
  throw heartbeatFailure;
61
- terminal = event;
62
- sequence += 1;
63
- await this.deps.gateway.appendEvent?.(binding.bindingId, {
64
- protocolVersion: PROTOCOL_VERSION,
65
- bindingId: binding.bindingId,
66
- sequence,
67
- eventType: event.eventType,
68
- summary: event.summary,
69
- ...(event.payload ? { payload: event.payload } : {}),
70
- idempotencyKey: `${binding.bindingId}:${sequence}`,
71
- occurredAt: new Date().toISOString(),
72
- });
73
- if (["completed", "failed", "cancelled", "blocked", "scope_conflict"].includes(event.eventType))
131
+ if (requestedTerminal)
132
+ break;
133
+ if (event.eventType === "completed" && !checkpointSeen) {
134
+ await append({ eventType: "checkpoint_ready", summary: "final checkpoint ready" });
135
+ }
136
+ if (isTerminalEventType(event.eventType)) {
137
+ if (controlsInFlight)
138
+ await controlsInFlight;
139
+ if (requestedTerminal)
140
+ break;
141
+ controlsStopped = true;
142
+ }
143
+ await append(event);
144
+ if (isTerminalEventType(event.eventType) || ["blocked", "scope_conflict"].includes(event.eventType)) {
145
+ controlsStopped = true;
146
+ terminal = event;
74
147
  break;
148
+ }
149
+ await readControls();
75
150
  }
76
151
  }
152
+ if (controlsInFlight)
153
+ await controlsInFlight;
154
+ if (requestedTerminal) {
155
+ terminal = requestedTerminal;
156
+ await append(terminal);
157
+ }
158
+ if (heartbeatFailure)
159
+ throw heartbeatFailure;
160
+ if (!terminal) {
161
+ terminal = { eventType: "failed", summary: "adapter ended without a terminal event" };
162
+ await append(terminal);
163
+ }
164
+ }
165
+ catch (error) {
166
+ await adapter.cancel().catch(() => undefined);
167
+ // Do not complete a job whose terminal evidence was not acknowledged.
168
+ // A safe bounded reason prevents raw adapter/transport errors leaking.
169
+ terminal = { eventType: "failed", summary: "daemon execution or event reporting failed" };
170
+ await append(terminal);
77
171
  }
78
172
  finally {
173
+ controlsStopped = true;
79
174
  if (heartbeatTimer)
80
175
  clearInterval(heartbeatTimer);
81
176
  }
@@ -3,7 +3,6 @@ import { promisify } from "node:util";
3
3
  import { hostname } from "node:os";
4
4
  import { createHash } from "node:crypto";
5
5
  import { join } from "node:path";
6
- import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
7
6
  import { McpGatewayClient } from "../gateway-client.js";
8
7
  import { AdapterRegistry } from "../adapters/registry.js";
9
8
  import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
@@ -15,11 +14,14 @@ import { ExecutionCoordinator } from "./execution-coordinator.js";
15
14
  import { JobPoller } from "./job-poller.js";
16
15
  import { ProcessLock } from "./process-lock.js";
17
16
  import { Daemon } from "./daemon.js";
17
+ import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
18
18
  import { McpRelay } from "../auth/mcp-relay.js";
19
+ import { McpRelayConfig } from "../auth/mcp-relay-config.js";
19
20
  const execFileAsync = promisify(execFile);
20
21
  export async function buildDaemon(input) {
21
22
  const relay = new McpRelay({ tokenProvider: input.tokenProvider, upstreamMcpUrl: input.gatewayMcpUrl, upstreamFetch: input.fetchFn });
22
23
  const relayServer = await relay.start();
24
+ const configs = new McpRelayConfig(input.stateRoot, relay, relayServer.url);
23
25
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
24
26
  const hookPath = new URL("../safety/hook-cli.js", import.meta.url).pathname;
25
27
  const plugin = new ClaudeCodePlugin({
@@ -38,14 +40,7 @@ export async function buildDaemon(input) {
38
40
  workItemLabel: jobId, env: input.env, registeredReadTools: [], hookExecutablePath: hookPath,
39
41
  binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
40
42
  mcpServerName: "ai_gateway",
41
- writeMcpConfig: (config) => {
42
- const root = join(input.stateRoot, "mcp-config");
43
- mkdirSync(root, { recursive: true, mode: 0o700 });
44
- const path = join(root, `daemon-${process.pid}-${Date.now()}.json`);
45
- writeFileSync(path, JSON.stringify({ mcpServers: config.tools.allow.length ? { ai_gateway: { type: "http", url: relayServer.url } } : {} }), { mode: 0o600 });
46
- chmodSync(path, 0o600);
47
- return path;
48
- },
43
+ writeMcpConfig: (config) => configs.write(config),
49
44
  }),
50
45
  });
51
46
  const registry = new AdapterRegistry([plugin]);
@@ -53,15 +48,17 @@ export async function buildDaemon(input) {
53
48
  const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
54
49
  const state = new FileDaemonStateStore(input.stateRoot);
55
50
  const coordinator = new ExecutionCoordinator({
51
+ terminalEvidenceCollector: buildTerminalEvidenceCollector(),
56
52
  gateway: {
57
- shadow: async (job) => ({
53
+ shadow: (job) => gateway.localJobShadow({ workLoopId: job.workLoopId,
58
54
  contractFingerprint: job.gatewayContractFingerprint ?? "",
59
- taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "",
60
- }),
55
+ taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }),
61
56
  createBinding: async (job) => gateway.createLocalJobBinding({
62
57
  jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
63
58
  launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
64
59
  }),
60
+ readCommands: (bindingId, cursor) => gateway.readCommands(bindingId, cursor),
61
+ acknowledgeCommand: (bindingId, sequence) => gateway.acknowledgeCommand(bindingId, sequence),
65
62
  appendEvent: (bindingId, event) => gateway.appendEvent(bindingId, event),
66
63
  heartbeat: async (bindingId, job) => {
67
64
  await Promise.all([gateway.heartbeat(bindingId), gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken })]);
@@ -75,7 +72,7 @@ export async function buildDaemon(input) {
75
72
  const poller = new JobPoller({
76
73
  heartbeat: () => gateway.heartbeatLocalRunner({
77
74
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
78
- displayName: runnerDisplayName(), packageVersion: "0.1.3",
75
+ displayName: runnerDisplayName(), packageVersion: "0.1.5",
79
76
  capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
80
77
  capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
81
78
  }).then(() => undefined),
@@ -92,7 +89,14 @@ export async function buildDaemon(input) {
92
89
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
93
90
  return { status: "claimed", job };
94
91
  },
95
- execute: (job) => coordinator.execute(job),
92
+ execute: async (job) => {
93
+ try {
94
+ await coordinator.execute(job);
95
+ }
96
+ finally {
97
+ configs.clear();
98
+ }
99
+ },
96
100
  delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
97
101
  });
98
102
  const daemon = new Daemon({
@@ -103,8 +107,8 @@ export async function buildDaemon(input) {
103
107
  try {
104
108
  const receipt = await gateway.readBindingEvents(previous.bindingId, previous.eventCursor ?? 0);
105
109
  const status = receipt.terminal
106
- ? receipt.terminalEventType === "completed" ? "completed"
107
- : receipt.terminalEventType === "cancelled" ? "cancelled" : "failed"
110
+ ? (receipt.terminalEventType ?? previous.terminalEventType) === "completed" ? "completed"
111
+ : (receipt.terminalEventType ?? previous.terminalEventType) === "cancelled" ? "cancelled" : "failed"
108
112
  : "failed";
109
113
  await gateway.completeLocalJob({
110
114
  jobId: previous.jobId, generation: previous.generation, leaseToken: previous.leaseToken, status,
@@ -119,6 +123,11 @@ export async function buildDaemon(input) {
119
123
  },
120
124
  poller,
121
125
  });
122
- return { daemon, close: () => relay.close() };
126
+ return { daemon, close: async () => { try {
127
+ configs.clear();
128
+ }
129
+ finally {
130
+ await relay.close();
131
+ } } };
123
132
  }
124
133
  export function runnerDisplayName() { return `${hostname()} local AI`; }
@@ -417,6 +417,23 @@ export class McpGatewayClient {
417
417
  async localRunnerStatus() {
418
418
  return this.callTool("local_agent_runner_status", { runner_id: this.runnerId });
419
419
  }
420
+ async localJobShadow(input) {
421
+ const shown = await this.callTool("work_loop_show", { work_loop_id: input.workLoopId });
422
+ if (shown.loop.environment !== "development")
423
+ throw new Error("local agent runtime is development-only");
424
+ const result = await this.callTool("work_execution_contract_show", {
425
+ contract_id: requiredString(shown.loop.work_execution_contract_id, "execution contract ID"),
426
+ });
427
+ if (result.contract.status !== "approved")
428
+ throw new Error("execution contract is not approved");
429
+ const payload = result.contract.contract_payload;
430
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
431
+ throw new Error("Gateway response is missing contract payload");
432
+ const storedFingerprint = requiredString(result.contract.content_fingerprint, "contract fingerprint");
433
+ if (storedFingerprint !== input.contractFingerprint)
434
+ throw new Error("claimed contract fingerprint differs from approved contract");
435
+ return { contractFingerprint: storedFingerprint, taskPackageFingerprint: input.taskPackageFingerprint, contractPayload: payload };
436
+ }
420
437
  async createLocalJobBinding(input) {
421
438
  const clientSessionReference = uuidv5(`local-agent-job:${input.jobId}:${input.generation}`, uuidv5.URL);
422
439
  const result = await this.callTool("local_agent_job_binding_create", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "test": "vitest run"
30
30
  },
31
31
  "dependencies": {
32
- "uuid": "11.1.0"
32
+ "uuid": "11.1.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "22.19.11",