mtech-ai-local-runner 0.1.4 → 0.1.6
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 +17 -2
- package/dist/src/adapters/claude-code/adapter.js +19 -1
- package/dist/src/adapters/claude-code/completion-report.js +52 -0
- package/dist/src/adapters/claude-code/prompt-compiler.js +2 -0
- package/dist/src/auth/mcp-relay-config.js +47 -0
- package/dist/src/auth/mcp-relay.js +67 -6
- package/dist/src/cli.js +11 -32
- package/dist/src/daemon/runtime.js +18 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,6 +10,21 @@ The package stores safe runner configuration locally and keeps OAuth refresh cre
|
|
|
10
10
|
|
|
11
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
12
|
|
|
13
|
+
## 0.1.6
|
|
14
|
+
|
|
15
|
+
- Require a final JSON report with actual findings, nonempty evidence and no pending work before reporting completion. Incomplete or missing reports fail without a successful investigation artifact.
|
|
16
|
+
- Explicitly restrict available built-in tools and ignore child messages as executor completion evidence.
|
|
17
|
+
- Preserve employee-local duration/count policy and Gateway model selection. Structured assertions remain client-reported and require separate semantic review.
|
|
18
|
+
|
|
19
|
+
## 0.1.5
|
|
20
|
+
|
|
21
|
+
- Authenticate local MCP relay callers with a random per-job bearer capability.
|
|
22
|
+
- Forward only the job-selected Gateway tools; filter tool discovery and reject other MCP methods, batches, browser origins and foreign Host headers.
|
|
23
|
+
- Revoke capabilities and remove private MCP configs when jobs settle or the runtime closes normally.
|
|
24
|
+
- Update uuid to patched version 11.1.1.
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
13
28
|
## 0.1.4
|
|
14
29
|
|
|
15
30
|
- Independently verify the approved contract payload and task-package fingerprints before creating a runtime binding.
|
|
@@ -20,6 +35,6 @@ Public npm installation requires no npm account or publishing OTP. Setup require
|
|
|
20
35
|
Existing employees can upgrade their installed daemon with:
|
|
21
36
|
|
|
22
37
|
```bash
|
|
23
|
-
npx mtech-ai-local-runner@0.1.
|
|
24
|
-
npx mtech-ai-local-runner@0.1.
|
|
38
|
+
npx mtech-ai-local-runner@0.1.6 setup
|
|
39
|
+
npx mtech-ai-local-runner@0.1.6 doctor
|
|
25
40
|
```
|
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { isTerminalEventType } from "../../protocol.js";
|
|
4
4
|
import { parseClaudeLine } from "./stream-parser.js";
|
|
5
5
|
import { compilePrompt } from "./prompt-compiler.js";
|
|
6
|
+
import { completionEvent } from "./completion-report.js";
|
|
6
7
|
import { buildHookPolicy, encodeHookPolicy } from "../../safety/hook-policy-builder.js";
|
|
7
8
|
import { HOOK_POLICY_ENV_VAR } from "../../safety/hook-cli.js";
|
|
8
9
|
const ALLOWED_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "NODE_ENV"];
|
|
@@ -108,6 +109,7 @@ export class ClaudeCodeAdapter {
|
|
|
108
109
|
"--settings", JSON.stringify(settings),
|
|
109
110
|
"--setting-sources", "user",
|
|
110
111
|
"--disable-slash-commands",
|
|
112
|
+
"--tools", builtinToolSurface.join(","),
|
|
111
113
|
"--allowedTools", toolSurface.join(","),
|
|
112
114
|
];
|
|
113
115
|
if (ctx.resumeSessionId) {
|
|
@@ -136,9 +138,25 @@ export class ClaudeCodeAdapter {
|
|
|
136
138
|
});
|
|
137
139
|
let sawTerminal = false;
|
|
138
140
|
for await (const line of handle.stdout) {
|
|
139
|
-
|
|
141
|
+
let message;
|
|
142
|
+
try {
|
|
143
|
+
const value = JSON.parse(line);
|
|
144
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
145
|
+
continue;
|
|
146
|
+
message = value;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// A nested agent message is not evidence that the executor has finished.
|
|
152
|
+
if (message.parent_tool_use_id != null)
|
|
153
|
+
continue;
|
|
154
|
+
let parsed = parseClaudeLine(line, this.options.worktreeRoot);
|
|
140
155
|
if (!parsed)
|
|
141
156
|
continue;
|
|
157
|
+
if (parsed.eventType === "completed") {
|
|
158
|
+
parsed = completionEvent(message.result, parsed.payload?.cost_usd);
|
|
159
|
+
}
|
|
142
160
|
if (isTerminalEventType(parsed.eventType))
|
|
143
161
|
sawTerminal = true;
|
|
144
162
|
yield parsed;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { MAX_EVENT_SUMMARY_CHARS } from "../../protocol.js";
|
|
2
|
+
const MAX_EVIDENCE_ITEMS = 20;
|
|
3
|
+
const MAX_EVIDENCE_CHARS = 500;
|
|
4
|
+
const MAX_REPORT_ENVELOPE_CHARS = 16_000;
|
|
5
|
+
export const COMPLETION_REPORT_NOTICE = "Do the assigned work yourself using the available tools; do not delegate or start background work. " +
|
|
6
|
+
"Do not report completed while waiting for results, tests, or another agent. " +
|
|
7
|
+
"Your final response must be one JSON object, without Markdown fences: " +
|
|
8
|
+
'{"status":"completed","report":"actual findings and results","evidence":["file path and observed finding, or command and actual result"],"pending_work":[]}. ' +
|
|
9
|
+
`The report must be nonempty and at most ${MAX_EVENT_SUMMARY_CHARS} characters. ` +
|
|
10
|
+
`Include 1-${MAX_EVIDENCE_ITEMS} nonempty evidence strings, each at most ${MAX_EVIDENCE_CHARS} characters. ` +
|
|
11
|
+
"Address the assigned objective and each applicable acceptance criterion with observed evidence, not a plan or promise. " +
|
|
12
|
+
"Only attest to executor work you observed; Gateway records transport, completion, and review receipts separately. " +
|
|
13
|
+
'If the task is unfinished, use "status":"blocked" and list the remaining work in "pending_work". ' +
|
|
14
|
+
"An incomplete or missing report is a failed execution, never a successful investigation artifact.";
|
|
15
|
+
function boundedText(value, max) {
|
|
16
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max;
|
|
17
|
+
}
|
|
18
|
+
/** Validate the full final result before truncation; never publish malformed/interim output. */
|
|
19
|
+
export function completionEvent(result, costUsd) {
|
|
20
|
+
const usage = typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0
|
|
21
|
+
? { cost_usd: costUsd }
|
|
22
|
+
: {};
|
|
23
|
+
const failed = {
|
|
24
|
+
eventType: "failed",
|
|
25
|
+
summary: "completion report is missing, incomplete or invalid",
|
|
26
|
+
payload: usage,
|
|
27
|
+
};
|
|
28
|
+
if (typeof result !== "string" || result.length > MAX_REPORT_ENVELOPE_CHARS)
|
|
29
|
+
return failed;
|
|
30
|
+
let report;
|
|
31
|
+
try {
|
|
32
|
+
report = JSON.parse(result);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return failed;
|
|
36
|
+
}
|
|
37
|
+
if (!report || typeof report !== "object" || Array.isArray(report))
|
|
38
|
+
return failed;
|
|
39
|
+
const value = report;
|
|
40
|
+
if (Object.keys(value).some((key) => !["status", "report", "evidence", "pending_work"].includes(key))
|
|
41
|
+
|| value.status !== "completed"
|
|
42
|
+
|| !boundedText(value.report, MAX_EVENT_SUMMARY_CHARS)
|
|
43
|
+
|| !Array.isArray(value.pending_work) || value.pending_work.length !== 0
|
|
44
|
+
|| !Array.isArray(value.evidence) || value.evidence.length < 1 || value.evidence.length > MAX_EVIDENCE_ITEMS
|
|
45
|
+
|| !value.evidence.every((item) => boundedText(item, MAX_EVIDENCE_CHARS)))
|
|
46
|
+
return failed;
|
|
47
|
+
return {
|
|
48
|
+
eventType: "completed",
|
|
49
|
+
summary: value.report.trim(),
|
|
50
|
+
payload: { ...usage, investigation_summary: value.report.trim(), completion_evidence: value.evidence },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMPLETION_REPORT_NOTICE } from "./completion-report.js";
|
|
1
2
|
const STOP_BEFORE_PUBLICATION_RULE = "Stop before publication. Never run git commit, git push, create a pull request, merge, " +
|
|
2
3
|
"deploy, access production, send an external message, or write data outside this worktree.";
|
|
3
4
|
const EVENT_SCHEMA_NOTICE = "Report progress only through the normalized event lifecycle (started, progress, file_changed, question, " +
|
|
@@ -53,6 +54,7 @@ export function compilePrompt(taskPackage, options) {
|
|
|
53
54
|
`## Prohibited actions\n${prohibitedActions.map((item) => `- ${item}`).join("\n")}`,
|
|
54
55
|
`## Stop-before-publication rule\n${STOP_BEFORE_PUBLICATION_RULE}`,
|
|
55
56
|
`## Event and checkpoint obligations\n${EVENT_SCHEMA_NOTICE}`,
|
|
57
|
+
`## Final completion report\n${COMPLETION_REPORT_NOTICE}`,
|
|
56
58
|
];
|
|
57
59
|
return sections.join("\n\n");
|
|
58
60
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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.
|
|
185
|
+
displayName: runnerDisplayName(), packageVersion: "0.1.6",
|
|
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 });
|
|
@@ -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";
|
|
@@ -17,10 +16,12 @@ import { ProcessLock } from "./process-lock.js";
|
|
|
17
16
|
import { Daemon } from "./daemon.js";
|
|
18
17
|
import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
|
|
19
18
|
import { McpRelay } from "../auth/mcp-relay.js";
|
|
19
|
+
import { McpRelayConfig } from "../auth/mcp-relay-config.js";
|
|
20
20
|
const execFileAsync = promisify(execFile);
|
|
21
21
|
export async function buildDaemon(input) {
|
|
22
22
|
const relay = new McpRelay({ tokenProvider: input.tokenProvider, upstreamMcpUrl: input.gatewayMcpUrl, upstreamFetch: input.fetchFn });
|
|
23
23
|
const relayServer = await relay.start();
|
|
24
|
+
const configs = new McpRelayConfig(input.stateRoot, relay, relayServer.url);
|
|
24
25
|
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
25
26
|
const hookPath = new URL("../safety/hook-cli.js", import.meta.url).pathname;
|
|
26
27
|
const plugin = new ClaudeCodePlugin({
|
|
@@ -39,14 +40,7 @@ export async function buildDaemon(input) {
|
|
|
39
40
|
workItemLabel: jobId, env: input.env, registeredReadTools: [], hookExecutablePath: hookPath,
|
|
40
41
|
binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
|
|
41
42
|
mcpServerName: "ai_gateway",
|
|
42
|
-
writeMcpConfig: (config) =>
|
|
43
|
-
const root = join(input.stateRoot, "mcp-config");
|
|
44
|
-
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
45
|
-
const path = join(root, `daemon-${process.pid}-${Date.now()}.json`);
|
|
46
|
-
writeFileSync(path, JSON.stringify({ mcpServers: config.tools.allow.length ? { ai_gateway: { type: "http", url: relayServer.url } } : {} }), { mode: 0o600 });
|
|
47
|
-
chmodSync(path, 0o600);
|
|
48
|
-
return path;
|
|
49
|
-
},
|
|
43
|
+
writeMcpConfig: (config) => configs.write(config),
|
|
50
44
|
}),
|
|
51
45
|
});
|
|
52
46
|
const registry = new AdapterRegistry([plugin]);
|
|
@@ -78,7 +72,7 @@ export async function buildDaemon(input) {
|
|
|
78
72
|
const poller = new JobPoller({
|
|
79
73
|
heartbeat: () => gateway.heartbeatLocalRunner({
|
|
80
74
|
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
81
|
-
displayName: runnerDisplayName(), packageVersion: "0.1.
|
|
75
|
+
displayName: runnerDisplayName(), packageVersion: "0.1.6",
|
|
82
76
|
capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
|
|
83
77
|
capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
|
|
84
78
|
}).then(() => undefined),
|
|
@@ -95,7 +89,14 @@ export async function buildDaemon(input) {
|
|
|
95
89
|
await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
|
|
96
90
|
return { status: "claimed", job };
|
|
97
91
|
},
|
|
98
|
-
execute: (job) =>
|
|
92
|
+
execute: async (job) => {
|
|
93
|
+
try {
|
|
94
|
+
await coordinator.execute(job);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
configs.clear();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
99
100
|
delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
100
101
|
});
|
|
101
102
|
const daemon = new Daemon({
|
|
@@ -122,6 +123,11 @@ export async function buildDaemon(input) {
|
|
|
122
123
|
},
|
|
123
124
|
poller,
|
|
124
125
|
});
|
|
125
|
-
return { daemon, close: () =>
|
|
126
|
+
return { daemon, close: async () => { try {
|
|
127
|
+
configs.clear();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await relay.close();
|
|
131
|
+
} } };
|
|
126
132
|
}
|
|
127
133
|
export function runnerDisplayName() { return `${hostname()} local AI`; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtech-ai-local-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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.
|
|
32
|
+
"uuid": "11.1.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "22.19.11",
|