mtech-ai-local-runner 0.0.0-stage → 0.1.0
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/LICENSE +3 -0
- package/README.md +8 -2
- package/dist/src/adapters/claude-code/adapter.js +215 -0
- package/dist/src/adapters/claude-code/prompt-compiler.js +58 -0
- package/dist/src/adapters/claude-code/stream-parser.js +128 -0
- package/dist/src/adapters/registry.js +34 -0
- package/dist/src/auth/browser-opener.js +36 -0
- package/dist/src/auth/credential-store.js +132 -0
- package/dist/src/auth/loopback-login.js +145 -0
- package/dist/src/auth/mcp-relay.js +140 -0
- package/dist/src/auth/oauth-client.js +325 -0
- package/dist/src/auth/token-provider.js +106 -0
- package/dist/src/cli.js +388 -0
- package/dist/src/commands/doctor.js +29 -0
- package/dist/src/commands/service.js +10 -0
- package/dist/src/commands/setup.js +18 -0
- package/dist/src/config.js +182 -0
- package/dist/src/daemon/daemon.js +18 -0
- package/dist/src/daemon/execution-coordinator.js +86 -0
- package/dist/src/daemon/job-poller.js +29 -0
- package/dist/src/daemon/process-lock.js +26 -0
- package/dist/src/daemon/runtime.js +117 -0
- package/dist/src/gateway-client.js +462 -0
- package/dist/src/platform/macos/keychain-store.js +46 -0
- package/dist/src/platform/macos/launchd.js +61 -0
- package/dist/src/process/node-process-host.js +30 -0
- package/dist/src/protocol.js +75 -0
- package/dist/src/registration/local-config.js +65 -0
- package/dist/src/registration/runner-registration.js +26 -0
- package/dist/src/runner.js +237 -0
- package/dist/src/safety/checkout-guard.js +70 -0
- package/dist/src/safety/command-policy.js +124 -0
- package/dist/src/safety/environment-guard.js +59 -0
- package/dist/src/safety/hook-cli.js +94 -0
- package/dist/src/safety/hook-policy-builder.js +37 -0
- package/dist/src/safety/node-checkout-inspector.js +74 -0
- package/dist/src/safety/pre-tool-use-hook.js +71 -0
- package/dist/src/safety/secret-redactor.js +64 -0
- package/dist/src/safety/terminal-evidence.js +49 -0
- package/dist/src/state-store.js +96 -0
- package/dist/src/workspace/git-client.js +14 -0
- package/dist/src/workspace/retention-manager.js +17 -0
- package/dist/src/workspace/workspace-manager.js +82 -0
- package/package.json +30 -4
package/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MTEC AI Local Runner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install and configure the employee-bound macOS daemon:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx mtech-ai-local-runner setup
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The package stores safe runner configuration locally and keeps OAuth refresh credentials in macOS Keychain when available.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { isTerminalEventType } from "../../protocol.js";
|
|
4
|
+
import { parseClaudeLine } from "./stream-parser.js";
|
|
5
|
+
import { compilePrompt } from "./prompt-compiler.js";
|
|
6
|
+
import { buildHookPolicy, encodeHookPolicy } from "../../safety/hook-policy-builder.js";
|
|
7
|
+
import { HOOK_POLICY_ENV_VAR } from "../../safety/hook-cli.js";
|
|
8
|
+
const ALLOWED_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "NODE_ENV"];
|
|
9
|
+
const ALLOWED_SUPERVISOR_COMMANDS = new Set(["continue", "answer_question", "request_revision", "request_checkpoint", "submit_for_review"]);
|
|
10
|
+
const DEFAULT_BUILTIN_TOOL_SURFACE = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "Read", "Glob", "Grep"];
|
|
11
|
+
function defaultPathExists(p) {
|
|
12
|
+
return existsSync(p);
|
|
13
|
+
}
|
|
14
|
+
function allowlistedEnv(env) {
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const key of ALLOWED_ENV_KEYS) {
|
|
17
|
+
const value = env[key];
|
|
18
|
+
if (value !== undefined)
|
|
19
|
+
result[key] = value;
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function contractBudget(taskPackage) {
|
|
24
|
+
const pkg = taskPackage;
|
|
25
|
+
const execution = pkg.agent_execution;
|
|
26
|
+
const limits = execution?.limits;
|
|
27
|
+
const value = Number(limits?.max_estimated_cost_usd);
|
|
28
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
29
|
+
throw new Error("refusing to start: task package has no positive contract budget");
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function isUnboundedLocalExecution(taskPackage) {
|
|
34
|
+
const pkg = taskPackage;
|
|
35
|
+
const execution = pkg.execution;
|
|
36
|
+
return execution?.mode === "employee_ai_work_session"
|
|
37
|
+
&& execution?.runtime_adapter === "claude_code_cli"
|
|
38
|
+
&& execution?.limits_mode === "unbounded";
|
|
39
|
+
}
|
|
40
|
+
function contractReadTools(taskPackage, registeredReadTools) {
|
|
41
|
+
const pkg = taskPackage;
|
|
42
|
+
const contract = pkg.contract;
|
|
43
|
+
const policy = contract?.policy;
|
|
44
|
+
const allowed = new Set(Array.isArray(policy?.allowed_gateway_tools)
|
|
45
|
+
? policy.allowed_gateway_tools.filter((tool) => typeof tool === "string")
|
|
46
|
+
: []);
|
|
47
|
+
return [...new Set(registeredReadTools.filter((tool) => allowed.has(tool)))];
|
|
48
|
+
}
|
|
49
|
+
export class ClaudeCodeAdapter {
|
|
50
|
+
options;
|
|
51
|
+
handle = null;
|
|
52
|
+
constructor(options) {
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
async start(ctx) {
|
|
56
|
+
const resolvedModelId = ctx.resolvedModelId;
|
|
57
|
+
if (!resolvedModelId) {
|
|
58
|
+
throw new Error("refusing to start: no Gateway-resolved model ID was supplied");
|
|
59
|
+
}
|
|
60
|
+
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
61
|
+
if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
62
|
+
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
63
|
+
}
|
|
64
|
+
if (!this.options.worktreeRoot) {
|
|
65
|
+
throw new Error("refusing to start: no assigned worktree was supplied");
|
|
66
|
+
}
|
|
67
|
+
if (!ctx.sessionId) {
|
|
68
|
+
throw new Error("refusing to start: no runner-generated session ID was supplied");
|
|
69
|
+
}
|
|
70
|
+
const hookPath = this.requireTrustedHookPath();
|
|
71
|
+
const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
|
|
72
|
+
const allowedReadTools = contractReadTools(ctx.taskPackage, this.options.registeredReadTools);
|
|
73
|
+
const mcpConfigPath = this.options.writeMcpConfig({ tools: { allow: allowedReadTools } });
|
|
74
|
+
const maxBudgetUsd = unbounded
|
|
75
|
+
? null
|
|
76
|
+
: Math.min(this.options.maxBudgetUsd, contractBudget(ctx.taskPackage));
|
|
77
|
+
const hookPolicy = buildHookPolicy({
|
|
78
|
+
taskPackage: ctx.taskPackage,
|
|
79
|
+
worktreeRoot: this.options.worktreeRoot,
|
|
80
|
+
registeredReadTools: this.options.registeredReadTools,
|
|
81
|
+
generatedPaths: [mcpConfigPath],
|
|
82
|
+
});
|
|
83
|
+
const settings = {
|
|
84
|
+
hooks: {
|
|
85
|
+
PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: hookPath }] }],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const builtinToolSurface = this.options.builtinToolSurface ?? DEFAULT_BUILTIN_TOOL_SURFACE;
|
|
89
|
+
const mcpServerName = this.options.mcpServerName ?? "ai_gateway";
|
|
90
|
+
const toolSurface = [
|
|
91
|
+
...builtinToolSurface,
|
|
92
|
+
...allowedReadTools.map((tool) => `mcp__${mcpServerName}__${tool}`),
|
|
93
|
+
];
|
|
94
|
+
const args = [
|
|
95
|
+
"--print",
|
|
96
|
+
"--input-format", "stream-json",
|
|
97
|
+
"--output-format", "stream-json",
|
|
98
|
+
"--verbose",
|
|
99
|
+
"--include-partial-messages",
|
|
100
|
+
"--no-chrome",
|
|
101
|
+
"--permission-mode", "bypassPermissions",
|
|
102
|
+
"--dangerously-skip-permissions",
|
|
103
|
+
"--model", resolvedModelId,
|
|
104
|
+
...(maxBudgetUsd === null ? [] : ["--max-budget-usd", String(maxBudgetUsd)]),
|
|
105
|
+
"--name", this.options.workItemLabel.trim().slice(0, 80) || `work-item-${ctx.bindingId.slice(0, 8)}`,
|
|
106
|
+
"--strict-mcp-config",
|
|
107
|
+
"--mcp-config", mcpConfigPath,
|
|
108
|
+
"--settings", JSON.stringify(settings),
|
|
109
|
+
"--setting-sources", "user",
|
|
110
|
+
"--disable-slash-commands",
|
|
111
|
+
"--allowedTools", toolSurface.join(","),
|
|
112
|
+
];
|
|
113
|
+
if (ctx.resumeSessionId) {
|
|
114
|
+
args.push("--resume", ctx.resumeSessionId);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
args.push("--session-id", ctx.sessionId);
|
|
118
|
+
}
|
|
119
|
+
const env = {
|
|
120
|
+
...allowlistedEnv(this.options.env),
|
|
121
|
+
[HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy),
|
|
122
|
+
};
|
|
123
|
+
const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
|
|
124
|
+
cwd: this.options.worktreeRoot,
|
|
125
|
+
env,
|
|
126
|
+
});
|
|
127
|
+
this.handle = handle;
|
|
128
|
+
handle.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } })}\n`);
|
|
129
|
+
}
|
|
130
|
+
async *events() {
|
|
131
|
+
const handle = this.requireHandle();
|
|
132
|
+
const exitInfo = { code: null, signal: null };
|
|
133
|
+
handle.once("exit", (code, signal) => {
|
|
134
|
+
exitInfo.code = code;
|
|
135
|
+
exitInfo.signal = signal;
|
|
136
|
+
});
|
|
137
|
+
let sawTerminal = false;
|
|
138
|
+
for await (const line of handle.stdout) {
|
|
139
|
+
const parsed = parseClaudeLine(line, this.options.worktreeRoot);
|
|
140
|
+
if (!parsed)
|
|
141
|
+
continue;
|
|
142
|
+
if (isTerminalEventType(parsed.eventType))
|
|
143
|
+
sawTerminal = true;
|
|
144
|
+
yield parsed;
|
|
145
|
+
if (sawTerminal) {
|
|
146
|
+
handle.stdin.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!sawTerminal) {
|
|
151
|
+
yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async command(command) {
|
|
155
|
+
if (!ALLOWED_SUPERVISOR_COMMANDS.has(command.command)) {
|
|
156
|
+
throw new Error(`unsupported supervisor command: ${command.command} is not allowed for this adapter`);
|
|
157
|
+
}
|
|
158
|
+
const handle = this.requireHandle();
|
|
159
|
+
const payload = { type: "supervisor_command", command: command.command };
|
|
160
|
+
if (command.command === "answer_question" && typeof command.arguments?.answer === "string") {
|
|
161
|
+
payload.answer = command.arguments.answer;
|
|
162
|
+
}
|
|
163
|
+
if (command.command === "request_revision" && Array.isArray(command.arguments?.acceptance_criterion_ids)) {
|
|
164
|
+
payload.acceptance_criterion_ids = command.arguments?.acceptance_criterion_ids;
|
|
165
|
+
}
|
|
166
|
+
handle.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: JSON.stringify(payload) }] } })}\n`);
|
|
167
|
+
}
|
|
168
|
+
async cancel() {
|
|
169
|
+
const handle = this.requireHandle();
|
|
170
|
+
handle.stdin.end();
|
|
171
|
+
handle.kill("SIGTERM");
|
|
172
|
+
}
|
|
173
|
+
requireTrustedHookPath() {
|
|
174
|
+
const hookPath = this.options.hookExecutablePath;
|
|
175
|
+
if (!hookPath || !path.isAbsolute(hookPath)) {
|
|
176
|
+
throw new Error("refusing to start: PreToolUse hook executable path must be an absolute path");
|
|
177
|
+
}
|
|
178
|
+
if (!/^[A-Za-z0-9._/:\\-]+$/.test(hookPath)) {
|
|
179
|
+
throw new Error("refusing to start: PreToolUse hook must use a safe executable path without shell metacharacters");
|
|
180
|
+
}
|
|
181
|
+
const worktreeRoot = path.resolve(this.options.worktreeRoot);
|
|
182
|
+
const rel = path.relative(worktreeRoot, path.resolve(hookPath));
|
|
183
|
+
const isInsideWorktree = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
184
|
+
if (isInsideWorktree) {
|
|
185
|
+
throw new Error("refusing to start: PreToolUse hook executable must live outside the assigned worktree");
|
|
186
|
+
}
|
|
187
|
+
const exists = this.options.hookExecutableExists ?? defaultPathExists;
|
|
188
|
+
if (!exists(hookPath)) {
|
|
189
|
+
throw new Error("refusing to start: PreToolUse hook executable was not found at the trusted path");
|
|
190
|
+
}
|
|
191
|
+
return hookPath;
|
|
192
|
+
}
|
|
193
|
+
requireHandle() {
|
|
194
|
+
if (!this.handle) {
|
|
195
|
+
throw new Error("adapter has not been started");
|
|
196
|
+
}
|
|
197
|
+
return this.handle;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export class ClaudeCodePlugin {
|
|
201
|
+
pluginOptions;
|
|
202
|
+
id = "claude_code_cli";
|
|
203
|
+
version = "1";
|
|
204
|
+
capabilities = ["repository_read", "repository_edit", "shell_test", "git_diff"];
|
|
205
|
+
constructor(pluginOptions) {
|
|
206
|
+
this.pluginOptions = pluginOptions;
|
|
207
|
+
}
|
|
208
|
+
async probe() {
|
|
209
|
+
const result = await this.pluginOptions.probe();
|
|
210
|
+
return { ...result, capabilities: result.capabilities ?? this.capabilities };
|
|
211
|
+
}
|
|
212
|
+
async create(input) {
|
|
213
|
+
return new ClaudeCodeAdapter(this.pluginOptions.options(input));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const STOP_BEFORE_PUBLICATION_RULE = "Stop before publication. Never run git commit, git push, create a pull request, merge, " +
|
|
2
|
+
"deploy, access production, send an external message, or write data outside this worktree.";
|
|
3
|
+
const EVENT_SCHEMA_NOTICE = "Report progress only through the normalized event lifecycle (started, progress, file_changed, question, " +
|
|
4
|
+
"test_started, test_result, budget_warning, checkpoint_ready, blocked, completed, failed). " +
|
|
5
|
+
"Checkpoint your work with checkpoint_ready before stopping whenever it is safe to do so.";
|
|
6
|
+
function requiredArray(value, label) {
|
|
7
|
+
if (!Array.isArray(value)) {
|
|
8
|
+
throw new Error(`task package is missing ${label}`);
|
|
9
|
+
}
|
|
10
|
+
return value.map((item) => String(item));
|
|
11
|
+
}
|
|
12
|
+
function record(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
14
|
+
? value
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
17
|
+
export function compilePrompt(taskPackage, options) {
|
|
18
|
+
const contract = record(taskPackage.contract);
|
|
19
|
+
if (!contract) {
|
|
20
|
+
throw new Error("task package is missing a contract");
|
|
21
|
+
}
|
|
22
|
+
const agentExecution = record(taskPackage.agent_execution);
|
|
23
|
+
if (!agentExecution) {
|
|
24
|
+
throw new Error("task package is missing the implementation brief (agent_execution section)");
|
|
25
|
+
}
|
|
26
|
+
const brief = record(agentExecution.implementation_brief);
|
|
27
|
+
if (!brief) {
|
|
28
|
+
throw new Error("task package is missing the implementation brief");
|
|
29
|
+
}
|
|
30
|
+
const unresolvedQuestions = requiredArray(brief.unresolved_questions ?? [], "unresolved_questions");
|
|
31
|
+
if (unresolvedQuestions.length > 0) {
|
|
32
|
+
throw new Error(`task package has unresolved questions that must be resolved before dispatch: ${unresolvedQuestions.join("; ")}`);
|
|
33
|
+
}
|
|
34
|
+
const contractObjective = record(contract.objective);
|
|
35
|
+
const objective = String(brief.objective ?? contractObjective?.summary ?? "");
|
|
36
|
+
if (!objective) {
|
|
37
|
+
throw new Error("task package is missing an objective");
|
|
38
|
+
}
|
|
39
|
+
const acceptanceCriteria = requiredArray(brief.acceptance_criteria ?? contractObjective?.acceptance_criteria ?? [], "acceptance_criteria");
|
|
40
|
+
const prohibitedActions = requiredArray(agentExecution.prohibited_actions ?? [], "prohibited_actions");
|
|
41
|
+
const requiredChanges = requiredArray(brief.required_changes ?? [], "required_changes");
|
|
42
|
+
const invariants = requiredArray(brief.invariants ?? [], "invariants");
|
|
43
|
+
const nonGoals = requiredArray(brief.non_goals ?? [], "non_goals");
|
|
44
|
+
const requiredTests = requiredArray(brief.required_tests ?? [], "required_tests");
|
|
45
|
+
const sections = [
|
|
46
|
+
`# Bounded implementation brief: ${options.workItemLabel}`,
|
|
47
|
+
`## Objective\n${objective}`,
|
|
48
|
+
`## Required changes\n${requiredChanges.map((item) => `- ${item}`).join("\n")}`,
|
|
49
|
+
`## Invariants\n${invariants.map((item) => `- ${item}`).join("\n")}`,
|
|
50
|
+
`## Non-goals\n${nonGoals.map((item) => `- ${item}`).join("\n")}`,
|
|
51
|
+
`## Acceptance criteria\n${acceptanceCriteria.map((item) => `- ${item}`).join("\n")}`,
|
|
52
|
+
`## Required tests\n${requiredTests.map((item) => `- ${item}`).join("\n")}`,
|
|
53
|
+
`## Prohibited actions\n${prohibitedActions.map((item) => `- ${item}`).join("\n")}`,
|
|
54
|
+
`## Stop-before-publication rule\n${STOP_BEFORE_PUBLICATION_RULE}`,
|
|
55
|
+
`## Event and checkpoint obligations\n${EVENT_SCHEMA_NOTICE}`,
|
|
56
|
+
];
|
|
57
|
+
return sections.join("\n\n");
|
|
58
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import { MAX_EVENT_SUMMARY_CHARS } from "../../protocol.js";
|
|
3
|
+
const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
|
|
4
|
+
function bounded(summary) {
|
|
5
|
+
return summary.length > MAX_EVENT_SUMMARY_CHARS ? summary.slice(0, MAX_EVENT_SUMMARY_CHARS) : summary;
|
|
6
|
+
}
|
|
7
|
+
function relativePath(absolutePath, worktreeRoot) {
|
|
8
|
+
if (!absolutePath || !isAbsolute(absolutePath))
|
|
9
|
+
return null;
|
|
10
|
+
const candidate = relative(resolve(worktreeRoot), resolve(absolutePath));
|
|
11
|
+
return candidate && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
|
|
12
|
+
&& !isAbsolute(candidate)
|
|
13
|
+
? candidate
|
|
14
|
+
: null;
|
|
15
|
+
}
|
|
16
|
+
function parseToolResultPassed(text) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(text);
|
|
19
|
+
return typeof parsed.passed === "boolean" ? parsed.passed : undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function parseAssistantMessage(message, worktreeRoot) {
|
|
26
|
+
const content = message.content ?? [];
|
|
27
|
+
for (const block of content) {
|
|
28
|
+
if (block.type !== "tool_use")
|
|
29
|
+
continue;
|
|
30
|
+
const name = String(block.name ?? "");
|
|
31
|
+
const input = block.input ?? {};
|
|
32
|
+
if (EDIT_TOOL_NAMES.has(name)) {
|
|
33
|
+
const filePath = String(input.file_path ?? "");
|
|
34
|
+
const relative = relativePath(filePath, worktreeRoot);
|
|
35
|
+
if (relative === null) {
|
|
36
|
+
return { eventType: "scope_conflict", summary: "edit targeted a path outside the assigned worktree" };
|
|
37
|
+
}
|
|
38
|
+
return { eventType: "file_changed", summary: bounded(`edited ${relative}`), payload: { file: relative } };
|
|
39
|
+
}
|
|
40
|
+
if (name === "Bash") {
|
|
41
|
+
return { eventType: "test_started", summary: "verification command started" };
|
|
42
|
+
}
|
|
43
|
+
if (name === "AskUserQuestion") {
|
|
44
|
+
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
45
|
+
const question = String(input.question ?? questions[0]?.question ?? "").trim() || "executor requested clarification";
|
|
46
|
+
return { eventType: "question", summary: bounded(question), payload: { question: bounded(question) } };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function parseUserMessage(message) {
|
|
52
|
+
const content = message.content ?? [];
|
|
53
|
+
for (const block of content) {
|
|
54
|
+
if (block.type !== "tool_result")
|
|
55
|
+
continue;
|
|
56
|
+
const isError = block.is_error === true;
|
|
57
|
+
const content = block.content;
|
|
58
|
+
const text = Array.isArray(content)
|
|
59
|
+
? content.find((item) => typeof item === "object" && item !== null && item.type === "text")
|
|
60
|
+
: undefined;
|
|
61
|
+
const raw = typeof content === "string" ? content : text && typeof text.text === "string" ? text.text : "";
|
|
62
|
+
const passed = parseToolResultPassed(raw);
|
|
63
|
+
const resolvedPassed = passed ?? !isError;
|
|
64
|
+
return {
|
|
65
|
+
eventType: "test_result",
|
|
66
|
+
summary: resolvedPassed ? "verification passed" : "verification failed",
|
|
67
|
+
payload: { status: resolvedPassed ? "passed" : "failed" },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function parseSystemMessage(message) {
|
|
73
|
+
const subtype = String(message.subtype ?? "");
|
|
74
|
+
if (subtype === "init") {
|
|
75
|
+
return { eventType: "started", summary: "session initialized" };
|
|
76
|
+
}
|
|
77
|
+
if (subtype === "budget_warning") {
|
|
78
|
+
const remaining = typeof message.remaining_usd === "number" ? message.remaining_usd : undefined;
|
|
79
|
+
return {
|
|
80
|
+
eventType: "budget_warning",
|
|
81
|
+
summary: remaining !== undefined ? `budget warning: $${remaining.toFixed(2)} remaining` : "budget warning",
|
|
82
|
+
payload: remaining !== undefined ? { remaining_usd: remaining } : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (subtype === "checkpoint") {
|
|
86
|
+
return { eventType: "checkpoint_ready", summary: "checkpoint ready" };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function parseResultMessage(message) {
|
|
91
|
+
const isError = message.is_error === true;
|
|
92
|
+
const costUsd = typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined;
|
|
93
|
+
if (!isError) {
|
|
94
|
+
const result = typeof message.result === "string" ? bounded(message.result.trim()) : "";
|
|
95
|
+
return {
|
|
96
|
+
eventType: "completed",
|
|
97
|
+
summary: result || "execution completed",
|
|
98
|
+
payload: {
|
|
99
|
+
...(costUsd !== undefined ? { cost_usd: costUsd } : {}),
|
|
100
|
+
...(result ? { investigation_summary: result } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const subtype = String(message.subtype ?? "");
|
|
105
|
+
const summary = subtype === "error_max_budget_exceeded" ? "execution failed: budget exceeded" : "execution failed";
|
|
106
|
+
return { eventType: "failed", summary, payload: costUsd !== undefined ? { cost_usd: costUsd } : undefined };
|
|
107
|
+
}
|
|
108
|
+
export function parseClaudeLine(line, worktreeRoot) {
|
|
109
|
+
let message;
|
|
110
|
+
try {
|
|
111
|
+
message = JSON.parse(line);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
switch (message.type) {
|
|
117
|
+
case "system":
|
|
118
|
+
return parseSystemMessage(message);
|
|
119
|
+
case "assistant":
|
|
120
|
+
return parseAssistantMessage(message.message ?? {}, worktreeRoot);
|
|
121
|
+
case "user":
|
|
122
|
+
return parseUserMessage(message.message ?? {});
|
|
123
|
+
case "result":
|
|
124
|
+
return parseResultMessage(message);
|
|
125
|
+
default:
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class AdapterRegistry {
|
|
2
|
+
plugins;
|
|
3
|
+
constructor(plugins) {
|
|
4
|
+
this.plugins = new Map();
|
|
5
|
+
for (const plugin of plugins) {
|
|
6
|
+
if (this.plugins.has(plugin.id))
|
|
7
|
+
throw new Error(`Duplicate runtime adapter ${plugin.id}`);
|
|
8
|
+
this.plugins.set(plugin.id, plugin);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async probeAll() {
|
|
12
|
+
return Promise.all([...this.plugins.values()].map(async (plugin) => ({
|
|
13
|
+
id: plugin.id,
|
|
14
|
+
version: plugin.version,
|
|
15
|
+
...await plugin.probe(),
|
|
16
|
+
})));
|
|
17
|
+
}
|
|
18
|
+
async select(id, version, input) {
|
|
19
|
+
const plugin = this.plugins.get(id);
|
|
20
|
+
if (!plugin)
|
|
21
|
+
throw new Error(`Unknown runtime adapter ${id}`);
|
|
22
|
+
if (plugin.version !== version)
|
|
23
|
+
throw new Error(`Runtime adapter ${id} version mismatch`);
|
|
24
|
+
const probe = await plugin.probe();
|
|
25
|
+
if (!probe.available)
|
|
26
|
+
throw new Error(`Runtime adapter ${id} is unavailable${probe.reason ? `: ${probe.reason}` : ""}`);
|
|
27
|
+
if (!probe.authenticated)
|
|
28
|
+
throw new Error(`Runtime adapter ${id} authentication is unavailable`);
|
|
29
|
+
const missing = plugin.capabilities.filter((capability) => !probe.capabilities.includes(capability));
|
|
30
|
+
if (missing.length > 0)
|
|
31
|
+
throw new Error(`Runtime adapter ${id} probe omitted capabilities: ${missing.join(", ")}`);
|
|
32
|
+
return plugin.create(input);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export async function createBrowserOpener() {
|
|
3
|
+
return async (url) => {
|
|
4
|
+
let command;
|
|
5
|
+
let args;
|
|
6
|
+
if (process.platform === "darwin") {
|
|
7
|
+
command = "open";
|
|
8
|
+
args = [url];
|
|
9
|
+
}
|
|
10
|
+
else if (process.platform === "linux") {
|
|
11
|
+
command = "xdg-open";
|
|
12
|
+
args = [url];
|
|
13
|
+
}
|
|
14
|
+
else if (process.platform === "win32") {
|
|
15
|
+
command = "rundll32";
|
|
16
|
+
args = ["url.dll,FileProtocolHandler", url];
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
20
|
+
}
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const proc = spawn(command, args, {
|
|
23
|
+
stdio: "ignore",
|
|
24
|
+
detached: true,
|
|
25
|
+
});
|
|
26
|
+
proc.on("error", (err) => {
|
|
27
|
+
reject(err);
|
|
28
|
+
});
|
|
29
|
+
// Give the process a moment to start
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
proc.unref();
|
|
32
|
+
resolve();
|
|
33
|
+
}, 100);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { writeFile, readFile, chmod, rm, mkdir } from "node:fs/promises";
|
|
2
|
+
import { statSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
export class FileOAuthCredentialStore {
|
|
6
|
+
stateRoot;
|
|
7
|
+
filePath;
|
|
8
|
+
constructor(stateRoot) {
|
|
9
|
+
this.stateRoot = stateRoot;
|
|
10
|
+
this.filePath = join(stateRoot, "auth.json");
|
|
11
|
+
}
|
|
12
|
+
async load(resource) {
|
|
13
|
+
if (!existsSync(this.filePath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
// Check permissions before reading
|
|
18
|
+
const stat = statSync(this.filePath);
|
|
19
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
20
|
+
// File has group or other access
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const content = await readFile(this.filePath, "utf-8");
|
|
24
|
+
const store = JSON.parse(content);
|
|
25
|
+
// Backward compatibility: if it's a single credential with no __version__
|
|
26
|
+
if (store.schemaVersion === 1 && store.resource && typeof store.resource === "string" && store.resource === resource) {
|
|
27
|
+
const credential = store;
|
|
28
|
+
if (this.isValidCredential(credential)) {
|
|
29
|
+
return credential;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Multi-resource store format
|
|
33
|
+
if (store.__version__ === "multi" && typeof store.credentials === "object" && store.credentials !== null) {
|
|
34
|
+
const creds = store.credentials;
|
|
35
|
+
const cred = creds[resource];
|
|
36
|
+
if (cred && this.isValidCredential(cred)) {
|
|
37
|
+
return cred;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async save(credential) {
|
|
47
|
+
// Ensure parent directory exists with correct permissions
|
|
48
|
+
await mkdir(this.stateRoot, { mode: 0o700, recursive: true });
|
|
49
|
+
// Validate credential before writing
|
|
50
|
+
if (!this.isValidCredential(credential)) {
|
|
51
|
+
throw new Error("Invalid credential structure");
|
|
52
|
+
}
|
|
53
|
+
// Read existing store
|
|
54
|
+
let store = { __version__: "multi", credentials: {} };
|
|
55
|
+
if (existsSync(this.filePath)) {
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(this.filePath);
|
|
58
|
+
if ((stat.mode & 0o077) === 0) {
|
|
59
|
+
const content = await readFile(this.filePath, "utf-8");
|
|
60
|
+
const existing = JSON.parse(content);
|
|
61
|
+
if (existing.__version__ === "multi" && typeof existing.credentials === "object") {
|
|
62
|
+
store = existing;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Ignore read errors; use fresh store
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Update the credential for this resource
|
|
71
|
+
if (!store.credentials || typeof store.credentials !== "object") {
|
|
72
|
+
store.credentials = {};
|
|
73
|
+
}
|
|
74
|
+
store.credentials[credential.resource] = credential;
|
|
75
|
+
const data = JSON.stringify(store);
|
|
76
|
+
// Write to temporary file with restricted permissions
|
|
77
|
+
const tmpFileName = `auth-${randomBytes(8).toString("hex")}.tmp`;
|
|
78
|
+
const tmpPath = join(this.stateRoot, tmpFileName);
|
|
79
|
+
try {
|
|
80
|
+
// Write with mode 0600 from the start
|
|
81
|
+
await writeFile(tmpPath, data, { mode: 0o600, flag: "wx" });
|
|
82
|
+
// Sync to disk
|
|
83
|
+
const fd = await import("node:fs/promises").then(m => m.open(tmpPath, "r"));
|
|
84
|
+
await fd.sync();
|
|
85
|
+
await fd.close();
|
|
86
|
+
// Atomic rename to final location
|
|
87
|
+
await import("node:fs/promises").then(m => m.rename(tmpPath, this.filePath));
|
|
88
|
+
// Ensure final file has correct mode
|
|
89
|
+
await chmod(this.filePath, 0o600);
|
|
90
|
+
// Ensure parent has correct mode
|
|
91
|
+
await chmod(this.stateRoot, 0o700);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// Clean up temp file if it exists
|
|
95
|
+
try {
|
|
96
|
+
await rm(tmpPath, { force: true });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Ignore cleanup errors
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async remove() {
|
|
105
|
+
try {
|
|
106
|
+
await rm(this.filePath, { force: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Idempotent: ignore errors
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
isValidCredential(obj) {
|
|
113
|
+
if (!obj || typeof obj !== "object")
|
|
114
|
+
return false;
|
|
115
|
+
const cred = obj;
|
|
116
|
+
return (cred.schemaVersion === 1
|
|
117
|
+
&& typeof cred.resource === "string"
|
|
118
|
+
&& cred.resource.length > 0
|
|
119
|
+
&& typeof cred.issuer === "string"
|
|
120
|
+
&& cred.issuer.length > 0
|
|
121
|
+
&& typeof cred.clientId === "string"
|
|
122
|
+
&& cred.clientId.length > 0
|
|
123
|
+
&& Array.isArray(cred.scope)
|
|
124
|
+
&& cred.scope.length > 0
|
|
125
|
+
&& cred.scope.every((s) => typeof s === "string")
|
|
126
|
+
&& typeof cred.accessToken === "string"
|
|
127
|
+
&& cred.accessToken.length > 0
|
|
128
|
+
&& typeof cred.accessTokenExpiresAt === "string"
|
|
129
|
+
&& cred.accessTokenExpiresAt.length > 0
|
|
130
|
+
&& (cred.refreshToken === undefined || typeof cred.refreshToken === "string"));
|
|
131
|
+
}
|
|
132
|
+
}
|