mtech-ai-local-runner 0.1.7 → 0.1.9
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 +77 -2
- package/dist/src/adapters/claude-code/adapter.js +157 -98
- package/dist/src/adapters/claude-code/completion-report.js +2 -64
- package/dist/src/adapters/claude-code/prompt-compiler.js +1 -60
- package/dist/src/adapters/claude-code/runtime-options.js +72 -0
- package/dist/src/adapters/codex/adapter.js +170 -0
- package/dist/src/adapters/codex/app-server-events.js +84 -0
- package/dist/src/adapters/codex/compatibility.js +45 -0
- package/dist/src/adapters/codex/config-builder.js +40 -0
- package/dist/src/adapters/codex/host-admission.js +3 -0
- package/dist/src/adapters/codex/inspection-rpc.js +48 -0
- package/dist/src/adapters/codex/job-rpc.js +189 -0
- package/dist/src/adapters/codex/native-policy.js +112 -0
- package/dist/src/adapters/codex/native-proof.js +14 -0
- package/dist/src/adapters/codex/native-session.js +375 -0
- package/dist/src/adapters/codex/plugin.js +26 -0
- package/dist/src/adapters/codex/pre-model-gate.js +112 -0
- package/dist/src/adapters/codex/prompt-compiler.js +13 -0
- package/dist/src/adapters/codex/runtime-options.js +163 -0
- package/dist/src/adapters/codex/scratch.js +28 -0
- package/dist/src/adapters/codex/stdio-rpc.js +113 -0
- package/dist/src/adapters/codex/stream-parser.js +85 -0
- package/dist/src/adapters/codex/supported-admission.js +97 -0
- package/dist/src/adapters/codex/tool-dispatcher.js +20 -0
- package/dist/src/adapters/completion-report.js +64 -0
- package/dist/src/adapters/discovery.js +76 -0
- package/dist/src/adapters/prompt-compiler.js +60 -0
- package/dist/src/adapters/registry.js +10 -5
- package/dist/src/cli.js +50 -58
- package/dist/src/commands/doctor.js +7 -1
- package/dist/src/commands/setup.js +3 -0
- package/dist/src/daemon/execution-coordinator.js +91 -19
- package/dist/src/daemon/job-poller.js +10 -1
- package/dist/src/daemon/runtime.js +55 -41
- package/dist/src/gateway-client.js +19 -3
- package/dist/src/process/node-process-group-host.js +74 -0
- package/dist/src/safety/cli-host-admission.js +74 -0
- package/dist/src/safety/cli-security-modes.js +4 -0
- package/dist/src/safety/codex-production-host.js +267 -0
- package/dist/src/safety/codex-supported-releases.js +1 -0
- package/dist/src/safety/hook-cli.js +21 -7
- package/dist/src/safety/hook-policy-builder.js +4 -11
- package/dist/src/safety/job-security-policy.js +62 -0
- package/dist/src/safety/job-security-receipt.js +20 -0
- package/dist/src/safety/job-security-session.js +133 -0
- package/dist/src/safety/job-tool-dispatcher.js +52 -0
- package/dist/src/safety/job-tool-policy.js +61 -0
- package/dist/src/safety/job-tool-session.js +82 -0
- package/dist/src/safety/local-tool-policy.js +101 -0
- package/dist/src/safety/local-tool-server.js +174 -0
- package/dist/src/safety/local-tool-worker.js +57 -0
- package/dist/src/safety/pre-tool-use-hook.js +3 -39
- package/dist/src/safety/registered-command-integrity.js +52 -0
- package/dist/src/safety/security-requirements.js +38 -0
- package/dist/src/safety/subprocess-sandbox.js +62 -0
- package/package.json +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { COMPLETION_REPORT_NOTICE } from "./completion-report.js";
|
|
2
|
+
const STOP_BEFORE_PUBLICATION_RULE = "Stop before publication. Never run git commit, git push, create a pull request, merge, " +
|
|
3
|
+
"deploy, access production, send an external message, or write data outside this worktree.";
|
|
4
|
+
const EVENT_SCHEMA_NOTICE = "Report progress only through the normalized event lifecycle (started, progress, file_changed, question, " +
|
|
5
|
+
"test_started, test_result, budget_warning, checkpoint_ready, blocked, completed, failed). " +
|
|
6
|
+
"Checkpoint your work with checkpoint_ready before stopping whenever it is safe to do so.";
|
|
7
|
+
function requiredArray(value, label) {
|
|
8
|
+
if (!Array.isArray(value)) {
|
|
9
|
+
throw new Error(`task package is missing ${label}`);
|
|
10
|
+
}
|
|
11
|
+
return value.map((item) => String(item));
|
|
12
|
+
}
|
|
13
|
+
function record(value) {
|
|
14
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
15
|
+
? value
|
|
16
|
+
: undefined;
|
|
17
|
+
}
|
|
18
|
+
export function compilePrompt(taskPackage, options) {
|
|
19
|
+
const contract = record(taskPackage.contract);
|
|
20
|
+
if (!contract) {
|
|
21
|
+
throw new Error("task package is missing a contract");
|
|
22
|
+
}
|
|
23
|
+
const agentExecution = record(taskPackage.agent_execution);
|
|
24
|
+
if (!agentExecution) {
|
|
25
|
+
throw new Error("task package is missing the implementation brief (agent_execution section)");
|
|
26
|
+
}
|
|
27
|
+
const brief = record(agentExecution.implementation_brief);
|
|
28
|
+
if (!brief) {
|
|
29
|
+
throw new Error("task package is missing the implementation brief");
|
|
30
|
+
}
|
|
31
|
+
const unresolvedQuestions = requiredArray(brief.unresolved_questions ?? [], "unresolved_questions");
|
|
32
|
+
if (unresolvedQuestions.length > 0) {
|
|
33
|
+
throw new Error(`task package has unresolved questions that must be resolved before dispatch: ${unresolvedQuestions.join("; ")}`);
|
|
34
|
+
}
|
|
35
|
+
const contractObjective = record(contract.objective);
|
|
36
|
+
const objective = String(brief.objective ?? contractObjective?.summary ?? "");
|
|
37
|
+
if (!objective) {
|
|
38
|
+
throw new Error("task package is missing an objective");
|
|
39
|
+
}
|
|
40
|
+
const acceptanceCriteria = requiredArray(brief.acceptance_criteria ?? contractObjective?.acceptance_criteria ?? [], "acceptance_criteria");
|
|
41
|
+
const prohibitedActions = requiredArray(agentExecution.prohibited_actions ?? [], "prohibited_actions");
|
|
42
|
+
const requiredChanges = requiredArray(brief.required_changes ?? [], "required_changes");
|
|
43
|
+
const invariants = requiredArray(brief.invariants ?? [], "invariants");
|
|
44
|
+
const nonGoals = requiredArray(brief.non_goals ?? [], "non_goals");
|
|
45
|
+
const requiredTests = requiredArray(brief.required_tests ?? [], "required_tests");
|
|
46
|
+
const sections = [
|
|
47
|
+
`# Bounded implementation brief: ${options.workItemLabel}`,
|
|
48
|
+
`## Objective\n${objective}`,
|
|
49
|
+
`## Required changes\n${requiredChanges.map((item) => `- ${item}`).join("\n")}`,
|
|
50
|
+
`## Invariants\n${invariants.map((item) => `- ${item}`).join("\n")}`,
|
|
51
|
+
`## Non-goals\n${nonGoals.map((item) => `- ${item}`).join("\n")}`,
|
|
52
|
+
`## Acceptance criteria\n${acceptanceCriteria.map((item) => `- ${item}`).join("\n")}`,
|
|
53
|
+
`## Required tests\n${requiredTests.map((item) => `- ${item}`).join("\n")}`,
|
|
54
|
+
`## Prohibited actions\n${prohibitedActions.map((item) => `- ${item}`).join("\n")}`,
|
|
55
|
+
`## Stop-before-publication rule\n${STOP_BEFORE_PUBLICATION_RULE}`,
|
|
56
|
+
`## Event and checkpoint obligations\n${EVENT_SCHEMA_NOTICE}`,
|
|
57
|
+
`## Final completion report\n${COMPLETION_REPORT_NOTICE}`,
|
|
58
|
+
];
|
|
59
|
+
return sections.join("\n\n");
|
|
60
|
+
}
|
|
@@ -9,11 +9,16 @@ export class AdapterRegistry {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
async probeAll() {
|
|
12
|
-
return Promise.all([...this.plugins.values()].map(async (plugin) =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
return Promise.all([...this.plugins.values()].map(async (plugin) => {
|
|
13
|
+
let probe;
|
|
14
|
+
try {
|
|
15
|
+
probe = await plugin.probe();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
probe = { available: false, authenticated: false, capabilities: [], reason: "Adapter discovery failed; check the local executable and login" };
|
|
19
|
+
}
|
|
20
|
+
return { id: plugin.id, version: plugin.version, ...probe };
|
|
21
|
+
}));
|
|
17
22
|
}
|
|
18
23
|
async select(id, version, input) {
|
|
19
24
|
const plugin = this.plugins.get(id);
|
package/dist/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { loadConfig, redact, isAuthConfig, isExecutionConfig, isServiceConfig }
|
|
|
6
6
|
import { McpGatewayClient } from "./gateway-client.js";
|
|
7
7
|
import { Runner } from "./runner.js";
|
|
8
8
|
import { FileRunnerStateStore } from "./state-store.js";
|
|
9
|
-
import {
|
|
9
|
+
import { createClaudeCodeAdapterOptions } from "./adapters/claude-code/runtime-options.js";
|
|
10
10
|
import { ClaudeCodeAdapter } from "./adapters/claude-code/adapter.js";
|
|
11
11
|
import { verifyCheckout } from "./safety/checkout-guard.js";
|
|
12
12
|
import { buildNodeCheckoutInspectorDeps, realNodeCheckoutInspectorDeps } from "./safety/node-checkout-inspector.js";
|
|
@@ -15,15 +15,12 @@ import { FileOAuthCredentialStore } from "./auth/credential-store.js";
|
|
|
15
15
|
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
|
-
import { McpRelay } from "./auth/mcp-relay.js";
|
|
19
|
-
import { McpRelayConfig } from "./auth/mcp-relay-config.js";
|
|
20
18
|
import { createBrowserOpener } from "./auth/browser-opener.js";
|
|
21
19
|
import { FileLocalConfigStore } from "./registration/local-config.js";
|
|
22
20
|
import { LaunchdService } from "./platform/macos/launchd.js";
|
|
23
21
|
import { buildDaemon, runnerDisplayName } from "./daemon/runtime.js";
|
|
24
22
|
import { createHash } from "node:crypto";
|
|
25
|
-
import {
|
|
26
|
-
import { promisify } from "node:util";
|
|
23
|
+
import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
|
|
27
24
|
import { mkdir, rm } from "node:fs/promises";
|
|
28
25
|
export async function cleanupOnceResources(configPath, closeRelay, unlinkFile = unlinkSync) {
|
|
29
26
|
const errors = [];
|
|
@@ -53,45 +50,17 @@ function buildCheckoutPreflight() {
|
|
|
53
50
|
};
|
|
54
51
|
}
|
|
55
52
|
async function buildOnceAdapter(config, env, tokenProvider, fetchFn) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
const options = createClaudeCodeAdapterOptions({
|
|
54
|
+
worktreePath: config.checkoutPath, jobId: config.workLoopId, stateRoot: config.stateRoot, env,
|
|
55
|
+
binaryPath: config.claudeBinaryPath, hookExecutablePath: config.hookExecutablePath,
|
|
56
|
+
maxBudgetUsd: Number.MAX_SAFE_INTEGER, workItemLabel: config.workLoopId,
|
|
60
57
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
worktreeRoot: config.checkoutPath,
|
|
68
|
-
maxBudgetUsd: Number.MAX_SAFE_INTEGER,
|
|
69
|
-
workItemLabel: config.workLoopId,
|
|
70
|
-
env,
|
|
71
|
-
registeredReadTools: config.registeredReadTools,
|
|
72
|
-
writeMcpConfig: (cfg) => configs.write(cfg),
|
|
73
|
-
mcpServerName: "ai_gateway",
|
|
74
|
-
binaryPath: config.claudeBinaryPath,
|
|
75
|
-
hookExecutablePath: config.hookExecutablePath,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
// Close relay if adapter construction fails
|
|
80
|
-
try {
|
|
81
|
-
await relay.close();
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// Suppress relay close error; propagate adapter error
|
|
85
|
-
}
|
|
86
|
-
throw err;
|
|
87
|
-
}
|
|
88
|
-
// Cleanup function: always attempts both config unlink and relay close
|
|
89
|
-
const cleanup = async () => cleanupOnceResources(null, async () => { try {
|
|
90
|
-
configs.clear();
|
|
91
|
-
}
|
|
92
|
-
finally {
|
|
93
|
-
await relay.close();
|
|
94
|
-
} });
|
|
58
|
+
// Do not create a Gateway binding through the legacy once path while the
|
|
59
|
+
// shared native release evidence is absent. Injected test adapters are separate.
|
|
60
|
+
if (!options.securityPolicy)
|
|
61
|
+
throw new Error('CLI_SECURITY_ADMISSION_UNAVAILABLE: verified CLI isolation is required');
|
|
62
|
+
const adapter = new ClaudeCodeAdapter(options);
|
|
63
|
+
const cleanup = async () => adapter.cancel();
|
|
95
64
|
return { adapter, cleanup };
|
|
96
65
|
}
|
|
97
66
|
async function handleLogin(config, stdout, stderr, fetchFn = fetch, opener) {
|
|
@@ -168,6 +137,11 @@ export async function main(argv, env, io = {}) {
|
|
|
168
137
|
if (config.mode === "setup") {
|
|
169
138
|
if (process.platform !== "darwin")
|
|
170
139
|
throw new Error("Local runner daemon setup requires macOS");
|
|
140
|
+
const detected = await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env));
|
|
141
|
+
if (!hasUsableAdapter(detected)) {
|
|
142
|
+
stdout(JSON.stringify({ ok: false, configured: false, adapters: adapterDiagnosticsWithRemedies(detected), action: "Resolve adapter diagnostics, then rerun setup." }));
|
|
143
|
+
throw new Error("No usable authenticated runtime adapter is available");
|
|
144
|
+
}
|
|
171
145
|
const loginCode = await handleLogin(config, stdout, stderr, fetchFn, io.browserOpener);
|
|
172
146
|
if (loginCode !== 0)
|
|
173
147
|
return loginCode;
|
|
@@ -176,21 +150,29 @@ export async function main(argv, env, io = {}) {
|
|
|
176
150
|
const oauthClient = new OAuthClient({ resource: config.gatewayMcpUrl, fetchFn });
|
|
177
151
|
const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: config.gatewayMcpUrl, clock: () => Date.now() });
|
|
178
152
|
const gateway = new McpGatewayClient({ mcpUrl: config.gatewayMcpUrl, tokenProvider, runnerId: "setup", fetchFn });
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
153
|
+
const adapters = await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env));
|
|
154
|
+
if (!hasUsableAdapter(adapters)) {
|
|
155
|
+
stdout(JSON.stringify({ ok: false, configured: false, adapters: adapterDiagnosticsWithRemedies(adapters), action: "Resolve adapter diagnostics, then rerun setup." }));
|
|
156
|
+
throw new Error("No usable authenticated runtime adapter is available. Run doctor for adapter diagnostics.");
|
|
157
|
+
}
|
|
183
158
|
const registered = await gateway.registerLocalRunner({
|
|
184
159
|
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
|
|
185
|
-
displayName: runnerDisplayName(), packageVersion: "0.1.
|
|
186
|
-
capabilities:
|
|
160
|
+
displayName: runnerDisplayName(), packageVersion: "0.1.9",
|
|
161
|
+
capabilities: adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
|
|
187
162
|
});
|
|
188
163
|
await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1 });
|
|
189
164
|
await launchd.install(process.argv[1], config.stateRoot);
|
|
190
|
-
stdout(JSON.stringify({ configured: true, runnerId: registered.runner.id,
|
|
191
|
-
return
|
|
165
|
+
stdout(JSON.stringify({ configured: true, runnerId: registered.runner.id, adapters }));
|
|
166
|
+
return 0;
|
|
192
167
|
}
|
|
168
|
+
const doctorAdapters = config.mode === "doctor" ? adapterDiagnosticsWithRemedies(await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env))) : undefined;
|
|
193
169
|
const local = await localStore.load();
|
|
170
|
+
if (!local && config.mode === "doctor") {
|
|
171
|
+
stdout(JSON.stringify({ ok: false, configured: false, adapters: doctorAdapters,
|
|
172
|
+
checks: { oauth: false, gateway: false, runner_registration: false, workspace: false, launchd: false },
|
|
173
|
+
action: "Resolve adapter diagnostics, then run setup to register this runner." }));
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
194
176
|
if (!local)
|
|
195
177
|
throw new Error("Local runner is not configured. Run npx mtech-ai-local-runner setup.");
|
|
196
178
|
if (config.mode === "start") {
|
|
@@ -214,13 +196,23 @@ export async function main(argv, env, io = {}) {
|
|
|
214
196
|
const oauthClient = new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn });
|
|
215
197
|
const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: local.gatewayMcpUrl, tokenOverride: env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, clock: () => Date.now() });
|
|
216
198
|
if (config.mode === "doctor") {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
199
|
+
const adapters = doctorAdapters;
|
|
200
|
+
const localChecks = Promise.allSettled([
|
|
201
|
+
launchd.status(),
|
|
202
|
+
mkdir(local.workspaceRoot, { recursive: true, mode: 0o700 }),
|
|
203
|
+
]);
|
|
204
|
+
// Refresh rotating credentials once before the Gateway client asks for a token.
|
|
205
|
+
const [oauth] = await Promise.allSettled([tokenProvider.getToken()]);
|
|
206
|
+
const [gateway] = oauth.status === "fulfilled"
|
|
207
|
+
? await Promise.allSettled([new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn }).localRunnerStatus()])
|
|
208
|
+
: [{ status: "rejected", reason: "Authentication is unavailable" }];
|
|
209
|
+
const [service, workspace] = await localChecks;
|
|
210
|
+
const checks = [oauth, gateway, service, workspace];
|
|
211
|
+
const loaded = service.status === "fulfilled" && service.value.loaded;
|
|
212
|
+
const ok = checks.every(check => check.status === "fulfilled") && loaded && hasUsableAdapter(adapters);
|
|
213
|
+
stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded },
|
|
214
|
+
...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
|
|
215
|
+
return ok ? 0 : 1;
|
|
224
216
|
}
|
|
225
217
|
const runtime = await buildDaemon({ gatewayMcpUrl: local.gatewayMcpUrl, runnerId: local.runnerId, workspaceRoot: local.workspaceRoot, stateRoot: config.stateRoot, tokenProvider, fetchFn, env });
|
|
226
218
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasUsableAdapter } from "../adapters/discovery.js";
|
|
1
2
|
export class Doctor {
|
|
2
3
|
deps;
|
|
3
4
|
constructor(deps) {
|
|
@@ -24,6 +25,11 @@ export class Doctor {
|
|
|
24
25
|
checks.push({ key, ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
const adapters = await this.deps.adapterDiagnostics?.();
|
|
29
|
+
if (adapters) {
|
|
30
|
+
const check = checks.find(check => check.key === "adapters");
|
|
31
|
+
check.ok = hasUsableAdapter(adapters);
|
|
32
|
+
}
|
|
33
|
+
return { ok: checks.every((check) => check.ok), checks, ...(adapters ? { adapters } : {}) };
|
|
28
34
|
}
|
|
29
35
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasUsableAdapter } from "../adapters/discovery.js";
|
|
1
2
|
export class SetupCommand {
|
|
2
3
|
deps;
|
|
3
4
|
constructor(deps) {
|
|
@@ -8,6 +9,8 @@ export class SetupCommand {
|
|
|
8
9
|
throw new Error("Local runner daemon setup requires macOS");
|
|
9
10
|
await this.deps.login();
|
|
10
11
|
const adapters = await this.deps.probeAdapters();
|
|
12
|
+
if (!hasUsableAdapter(adapters))
|
|
13
|
+
throw new Error("No usable authenticated runtime adapter is available");
|
|
11
14
|
const registration = await this.deps.register({ workspaceRoot: input.workspaceRoot, adapters });
|
|
12
15
|
await this.deps.installService();
|
|
13
16
|
const doctor = await this.deps.doctor();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { requireSecurityRequirements } from '../safety/security-requirements.js';
|
|
2
|
+
import { GatewayToolError } from "../gateway-client.js";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
|
|
3
5
|
function canonical(value) {
|
|
@@ -28,30 +30,97 @@ function text(value, label) {
|
|
|
28
30
|
throw new Error(`task package is missing ${label}`);
|
|
29
31
|
return value;
|
|
30
32
|
}
|
|
33
|
+
function immutable(value) {
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
for (const child of Object.values(value))
|
|
36
|
+
immutable(child);
|
|
37
|
+
Object.freeze(value);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
class PreparationDeadlineError extends Error {
|
|
42
|
+
}
|
|
31
43
|
export class ExecutionCoordinator {
|
|
32
44
|
deps;
|
|
33
45
|
constructor(deps) {
|
|
34
46
|
this.deps = deps;
|
|
35
47
|
}
|
|
36
48
|
async execute(job) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
job = { ...job, taskPackage: immutable(structuredClone(job.taskPackage)) };
|
|
50
|
+
let stage = "package";
|
|
51
|
+
let workspace;
|
|
52
|
+
let adapter;
|
|
53
|
+
let binding;
|
|
54
|
+
const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
|
|
55
|
+
const step = async (operation) => {
|
|
56
|
+
const remaining = deadline - Date.now();
|
|
57
|
+
if (remaining <= 0)
|
|
58
|
+
throw new PreparationDeadlineError();
|
|
59
|
+
let timer;
|
|
60
|
+
try {
|
|
61
|
+
return await Promise.race([operation(), new Promise((_, reject) => {
|
|
62
|
+
timer = setTimeout(() => reject(new PreparationDeadlineError()), remaining);
|
|
63
|
+
})]);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
const pkg = job.taskPackage;
|
|
72
|
+
const repository = pkg.repository;
|
|
73
|
+
const adapterId = text(pkg.runtime_adapter, "runtime adapter");
|
|
74
|
+
const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
|
|
75
|
+
const expectedContractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
|
|
76
|
+
requireSecurityRequirements(pkg.security_requirements, { adapterId, adapterVersion });
|
|
77
|
+
if (!/^[a-f0-9]{64}$/.test(job.launchFingerprint ?? '') || job.gatewayContractFingerprint !== expectedContractFingerprint
|
|
78
|
+
|| job.gatewayTaskPackageFingerprint !== fingerprint(pkg))
|
|
79
|
+
throw new Error("Claimed launch fingerprints differ");
|
|
80
|
+
stage = "workspace";
|
|
81
|
+
workspace = await step(() => this.deps.workspaces.create({
|
|
82
|
+
jobId: job.id,
|
|
83
|
+
repositoryKey: text(repository?.key, "repository key"),
|
|
84
|
+
providerUrl: text(repository?.provider_url, "repository provider URL"),
|
|
85
|
+
baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
|
|
86
|
+
}));
|
|
87
|
+
stage = "adapter";
|
|
88
|
+
adapter = await step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }));
|
|
89
|
+
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "shadow", workspaceManifest: workspace.manifest });
|
|
90
|
+
stage = "shadow";
|
|
91
|
+
const gatewayShadow = await step(() => this.deps.gateway.shadow(job));
|
|
92
|
+
stage = "fingerprint";
|
|
93
|
+
if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
|
|
94
|
+
throw new Error("contract or task-package fingerprint mismatch before binding");
|
|
95
|
+
}
|
|
96
|
+
stage = "binding";
|
|
97
|
+
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "binding" });
|
|
98
|
+
binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const diagnostic = { stage, code: "PREPARATION_FAILED" };
|
|
102
|
+
if (error instanceof PreparationDeadlineError)
|
|
103
|
+
diagnostic.code = "PREPARATION_DEADLINE_EXCEEDED";
|
|
104
|
+
if (error instanceof GatewayToolError) {
|
|
105
|
+
diagnostic.tool = error.toolName;
|
|
106
|
+
diagnostic.code = error.code;
|
|
107
|
+
}
|
|
108
|
+
this.deps.reportPreparationFailure?.(diagnostic);
|
|
109
|
+
if (stage === "binding" && !(error instanceof GatewayToolError && error.code === "GATEWAY_TOOL_REJECTED")) {
|
|
110
|
+
// The server may have committed the binding. Never fabricate a blocked result
|
|
111
|
+
// or repeat the mutation before the deterministic binding reference is reconciled.
|
|
112
|
+
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
|
|
113
|
+
phase: "binding_uncertain", preparationFailure: diagnostic });
|
|
114
|
+
throw new Error("Binding outcome requires reconciliation");
|
|
115
|
+
}
|
|
116
|
+
// Retain this exact safe terminal payload until Gateway acknowledges it.
|
|
117
|
+
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
|
|
118
|
+
phase: "preparation_failed", preparationFailure: diagnostic });
|
|
119
|
+
await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
|
|
120
|
+
status: "blocked", result: { preparation_failure: { ...diagnostic } } });
|
|
121
|
+
await this.deps.state.clear();
|
|
122
|
+
return;
|
|
53
123
|
}
|
|
54
|
-
const binding = await this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path });
|
|
55
124
|
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest });
|
|
56
125
|
let heartbeatFailure = null;
|
|
57
126
|
let requestedTerminal;
|
|
@@ -122,7 +191,10 @@ export class ExecutionCoordinator {
|
|
|
122
191
|
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
192
|
};
|
|
124
193
|
try {
|
|
125
|
-
|
|
194
|
+
if (!job.taskPackage.resolved_model_id
|
|
195
|
+
|| binding.resolvedModelId !== job.taskPackage.resolved_model_id)
|
|
196
|
+
throw new Error("Binding model differs from approved launch");
|
|
197
|
+
await adapter.start({ securityBinding: { launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint }, generation: job.generation, approvedTaskPackage: job.taskPackage, bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId });
|
|
126
198
|
await readControls();
|
|
127
199
|
if (adapter.events && !requestedTerminal) {
|
|
128
200
|
for await (const event of adapter.events()) {
|
|
@@ -175,7 +247,7 @@ export class ExecutionCoordinator {
|
|
|
175
247
|
clearInterval(heartbeatTimer);
|
|
176
248
|
}
|
|
177
249
|
const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
|
|
178
|
-
await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary } });
|
|
250
|
+
await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(adapter.securityReceipt?.() ? { security_receipt: adapter.securityReceipt() } : {}) } });
|
|
179
251
|
await this.deps.state.clear();
|
|
180
252
|
}
|
|
181
253
|
}
|
|
@@ -9,14 +9,23 @@ export class JobPoller {
|
|
|
9
9
|
async tick() {
|
|
10
10
|
if (this.stopped)
|
|
11
11
|
return;
|
|
12
|
+
let stage = "heartbeat";
|
|
12
13
|
try {
|
|
13
14
|
await this.deps.heartbeat?.();
|
|
15
|
+
stage = "wait";
|
|
14
16
|
const claim = await this.deps.wait();
|
|
15
|
-
|
|
17
|
+
stage = "execute";
|
|
16
18
|
if (claim.status === "claimed" && claim.job)
|
|
17
19
|
await this.deps.execute(claim.job);
|
|
20
|
+
this.failures = 0;
|
|
18
21
|
}
|
|
19
22
|
catch {
|
|
23
|
+
this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
|
|
24
|
+
// Unknown execution/terminal outcomes must reconcile before any new claim.
|
|
25
|
+
if (stage === "execute") {
|
|
26
|
+
this.stop();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
20
29
|
this.failures += 1;
|
|
21
30
|
const base = Math.min(30_000, 1000 * (2 ** Math.min(5, this.failures - 1)));
|
|
22
31
|
const jitter = Math.floor(base * 0.2 * (this.deps.random ?? Math.random)());
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from "node:util";
|
|
1
|
+
import { createCodexProductionHost } from '../safety/codex-production-host.js';
|
|
3
2
|
import { hostname } from "node:os";
|
|
4
3
|
import { createHash } from "node:crypto";
|
|
5
4
|
import { join } from "node:path";
|
|
6
5
|
import { McpGatewayClient } from "../gateway-client.js";
|
|
6
|
+
import { discoverAdapters } from "../adapters/discovery.js";
|
|
7
|
+
import { CodexPlugin } from "../adapters/codex/plugin.js";
|
|
8
|
+
import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
|
|
7
9
|
import { AdapterRegistry } from "../adapters/registry.js";
|
|
8
10
|
import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
|
|
9
|
-
import {
|
|
11
|
+
import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
|
|
10
12
|
import { WorkspaceManager } from "../workspace/workspace-manager.js";
|
|
11
13
|
import { GitClient } from "../workspace/git-client.js";
|
|
12
14
|
import { FileDaemonStateStore } from "../state-store.js";
|
|
@@ -15,39 +17,31 @@ import { JobPoller } from "./job-poller.js";
|
|
|
15
17
|
import { ProcessLock } from "./process-lock.js";
|
|
16
18
|
import { Daemon } from "./daemon.js";
|
|
17
19
|
import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
|
|
18
|
-
import { McpRelay } from "../auth/mcp-relay.js";
|
|
19
|
-
import { McpRelayConfig } from "../auth/mcp-relay-config.js";
|
|
20
|
-
const execFileAsync = promisify(execFile);
|
|
21
20
|
export async function buildDaemon(input) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
21
|
+
if (input.codexSupportedProbe && !input.codexSecurityHost)
|
|
22
|
+
throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
|
|
23
|
+
const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexProductionHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
|
|
24
|
+
let pendingCodexClaim;
|
|
25
25
|
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
26
26
|
const hookPath = new URL("../safety/hook-cli.js", import.meta.url).pathname;
|
|
27
27
|
const plugin = new ClaudeCodePlugin({
|
|
28
|
-
probe: async () =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return { available: true, authenticated: auth, executableVersion: version.stdout.trim() };
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
return { available: false, authenticated: false, reason: error instanceof Error ? error.message : String(error) };
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
options: ({ worktreePath, jobId }) => ({
|
|
39
|
-
processHost: new NodeProcessHost(), worktreeRoot: worktreePath, maxBudgetUsd: Number.MAX_SAFE_INTEGER,
|
|
40
|
-
workItemLabel: jobId, env: input.env, registeredReadTools: [], hookExecutablePath: hookPath,
|
|
28
|
+
probe: async () => (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
|
|
29
|
+
options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
|
|
30
|
+
worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
|
|
31
|
+
maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hookPath,
|
|
41
32
|
binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
|
|
42
|
-
mcpServerName: "ai_gateway",
|
|
43
|
-
writeMcpConfig: (config) => configs.write(config),
|
|
44
33
|
}),
|
|
45
34
|
});
|
|
46
|
-
const
|
|
35
|
+
const codex = new CodexPlugin({
|
|
36
|
+
probe: async () => production ? production.probe() : input.codexSupportedProbe ? input.codexSupportedProbe() : (await discoverAdapters(input.env, undefined, ['codex_cli']))[0],
|
|
37
|
+
options: ({ worktreePath, jobId }) => buildCodexJobOptions({ worktreePath, jobId, stateRoot: input.stateRoot, env: input.env, ...(production ? { productionHostFactory: () => production.job(jobId, worktreePath) } : { securityHost: input.codexSecurityHost }) }),
|
|
38
|
+
});
|
|
39
|
+
const registry = new AdapterRegistry([plugin, codex]);
|
|
47
40
|
const probes = await registry.probeAll();
|
|
48
41
|
const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
|
|
49
42
|
const state = new FileDaemonStateStore(input.stateRoot);
|
|
50
43
|
const coordinator = new ExecutionCoordinator({
|
|
44
|
+
reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
|
|
51
45
|
terminalEvidenceCollector: buildTerminalEvidenceCollector(),
|
|
52
46
|
gateway: {
|
|
53
47
|
shadow: (job) => gateway.localJobShadow({ workLoopId: job.workLoopId,
|
|
@@ -63,16 +57,24 @@ export async function buildDaemon(input) {
|
|
|
63
57
|
heartbeat: async (bindingId, job) => {
|
|
64
58
|
await Promise.all([gateway.heartbeat(bindingId), gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken })]);
|
|
65
59
|
},
|
|
66
|
-
complete: (result) => gateway.completeLocalJob(result),
|
|
60
|
+
complete: async (result) => { await gateway.completeLocalJob(result); production?.releaseJob(result.jobId); pendingCodexClaim = undefined; },
|
|
67
61
|
},
|
|
68
|
-
registry,
|
|
62
|
+
registry: { select: async (id, version, options) => {
|
|
63
|
+
if (production && id === 'codex_cli') {
|
|
64
|
+
if (!pendingCodexClaim || pendingCodexClaim.job?.id !== options.jobId)
|
|
65
|
+
throw new Error('CODEX_TRUSTED_CLAIM_REQUIRED');
|
|
66
|
+
production.registerClaim(pendingCodexClaim);
|
|
67
|
+
}
|
|
68
|
+
return registry.select(id, version, options);
|
|
69
|
+
} },
|
|
69
70
|
workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
|
|
70
71
|
state,
|
|
71
72
|
});
|
|
72
73
|
const poller = new JobPoller({
|
|
74
|
+
reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
|
|
73
75
|
heartbeat: () => gateway.heartbeatLocalRunner({
|
|
74
76
|
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
75
|
-
displayName: runnerDisplayName(), packageVersion: "0.1.
|
|
77
|
+
displayName: runnerDisplayName(), packageVersion: "0.1.9",
|
|
76
78
|
capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
|
|
77
79
|
capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
|
|
78
80
|
}).then(() => undefined),
|
|
@@ -80,22 +82,19 @@ export async function buildDaemon(input) {
|
|
|
80
82
|
const claim = await gateway.waitForLocalJob(versions, 25);
|
|
81
83
|
if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
|
|
82
84
|
return { status: "no_job" };
|
|
85
|
+
if (production && claim.task_package.runtime_adapter === 'codex_cli')
|
|
86
|
+
pendingCodexClaim = claim;
|
|
83
87
|
const job = {
|
|
84
88
|
id: claim.job.id, generation: claim.job.generation, leaseToken: claim.lease_token, launchGrant: claim.launch_grant,
|
|
85
89
|
workLoopId: claim.job.work_loop_id, taskPackage: claim.task_package,
|
|
86
|
-
gatewayContractFingerprint: claim.contract_fingerprint,
|
|
90
|
+
launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
|
|
87
91
|
gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
|
|
88
92
|
};
|
|
89
93
|
await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
|
|
90
94
|
return { status: "claimed", job };
|
|
91
95
|
},
|
|
92
96
|
execute: async (job) => {
|
|
93
|
-
|
|
94
|
-
await coordinator.execute(job);
|
|
95
|
-
}
|
|
96
|
-
finally {
|
|
97
|
-
configs.clear();
|
|
98
|
-
}
|
|
97
|
+
await coordinator.execute(job);
|
|
99
98
|
},
|
|
100
99
|
delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
101
100
|
});
|
|
@@ -103,6 +102,10 @@ export async function buildDaemon(input) {
|
|
|
103
102
|
lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
|
|
104
103
|
reconcile: async (stored) => {
|
|
105
104
|
const previous = stored;
|
|
105
|
+
if (await reconcilePreparationState(previous, {
|
|
106
|
+
complete: (result) => gateway.completeLocalJob(result), clear: () => state.clear(),
|
|
107
|
+
}))
|
|
108
|
+
return;
|
|
106
109
|
if (previous.bindingId && previous.jobId && previous.generation && previous.leaseToken) {
|
|
107
110
|
try {
|
|
108
111
|
const receipt = await gateway.readBindingEvents(previous.bindingId, previous.eventCursor ?? 0);
|
|
@@ -123,11 +126,22 @@ export async function buildDaemon(input) {
|
|
|
123
126
|
},
|
|
124
127
|
poller,
|
|
125
128
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
await relay.close();
|
|
131
|
-
} } };
|
|
129
|
+
// Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
|
|
130
|
+
return { daemon, close: async () => { poller.stop(); production?.close(); } };
|
|
132
131
|
}
|
|
133
132
|
export function runnerDisplayName() { return `${hostname()} local AI`; }
|
|
133
|
+
/** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
|
|
134
|
+
export async function reconcilePreparationState(previous, deps) {
|
|
135
|
+
if (previous.phase === "binding" || previous.phase === "binding_uncertain") {
|
|
136
|
+
throw new Error("Binding outcome requires reconciliation before runner polling");
|
|
137
|
+
}
|
|
138
|
+
if (!previous.preparationFailure)
|
|
139
|
+
return false;
|
|
140
|
+
if (!previous.jobId || !previous.generation || !previous.leaseToken) {
|
|
141
|
+
throw new Error("Preparation receipt is missing its job fence");
|
|
142
|
+
}
|
|
143
|
+
await deps.complete({ jobId: previous.jobId, generation: previous.generation,
|
|
144
|
+
leaseToken: previous.leaseToken, status: "blocked", result: { preparation_failure: previous.preparationFailure } });
|
|
145
|
+
await deps.clear();
|
|
146
|
+
return true;
|
|
147
|
+
}
|