mtech-ai-local-runner 0.1.111 → 0.1.112
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 +12 -0
- package/dist/src/adapters/claude-code/adapter.js +16 -7
- package/dist/src/adapters/claude-code/stream-parser.js +65 -4
- package/dist/src/adapters/runtime-failure.js +34 -0
- package/dist/src/daemon/execution-coordinator.js +7 -4
- package/dist/src/daemon/runtime.js +59 -10
- package/dist/src/safety/claude-review-provider.js +9 -1
- package/dist/src/safety/d4-execution-limits.js +23 -11
- package/dist/src/safety/d4-host-composition.js +100 -0
- package/dist/src/safety/d4-reviewed-hosts.js +2 -0
- package/dist/src/safety/d4-runtime-release.js +9 -0
- package/dist/src/verification/p1-execution-host.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 0.1.112: a failed Claude job says why it stopped
|
|
2
|
+
|
|
3
|
+
When a Claude job fails, the runner now tells the Gateway why, as one code: `auth` (the Claude account's sign-in was
|
|
4
|
+
refused or expired), `usage_limit`, `model_unavailable`, `rate_limited`, `provider_error`, `budget_exceeded`,
|
|
5
|
+
`cli_error` or `unknown` (Gateway Work Item 2ee28765). The Sentry "AI fix failed" card then says, for example, "Claude
|
|
6
|
+
stopped before the first answer: usage limit reached on the runner's Claude account." The runner reads only the Claude
|
|
7
|
+
CLI's error kind and HTTP status; it never sends the CLI's own text. If the card says `auth`, sign Claude in again on
|
|
8
|
+
this Mac (or renew the runner's token with `setup --claude-token`) and retry.
|
|
9
|
+
|
|
10
|
+
0.1.112 also carries the D4 work merged after 0.1.111 (PRs #943, #949, #957). Reviewed-host registration stays empty,
|
|
11
|
+
so D4 execution stays closed.
|
|
12
|
+
|
|
1
13
|
## 0.1.111: Work Rooms in the menu bar app
|
|
2
14
|
|
|
3
15
|
The menu bar window now has a **Rooms** tab (Gateway Work Item b35d65cb); the menu's **Show Rooms (n unread)…** opens
|
|
@@ -7,6 +7,7 @@ import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT, claudeCa
|
|
|
7
7
|
import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
|
|
8
8
|
import { isTerminalEventType } from "../../protocol.js";
|
|
9
9
|
import { parseClaudeLine } from "./stream-parser.js";
|
|
10
|
+
import { runtimeFailure } from "../runtime-failure.js";
|
|
10
11
|
import { p1ModelEvent } from '../../verification/p1-execution-host.js';
|
|
11
12
|
import { compilePrompt } from "./prompt-compiler.js";
|
|
12
13
|
import { CLAUDE_COMPLETION_SCHEMA } from "../completion-report.js";
|
|
@@ -180,7 +181,8 @@ export class ClaudeCodeAdapter {
|
|
|
180
181
|
|| this.options.securityPolicy?.pin.toolsSha256 !== profile.tools_sha256 || this.options.securityPolicy?.pin.requirementsSha256 !== profile.cli_security_fingerprint
|
|
181
182
|
|| this.options.securityPolicy?.pin.securityMode !== CLAUDE_RELEASE_MODE)
|
|
182
183
|
throw Error('D4_REVIEW_HOST_REQUIRED');
|
|
183
|
-
await host.assertCurrent(structuredClone(descriptor)
|
|
184
|
+
await host.assertCurrent(structuredClone(descriptor), { jobId: this.options.jobId ?? ctx.bindingId, bindingId: ctx.bindingId, generation: ctx.generation,
|
|
185
|
+
launchFingerprint: ctx.securityBinding?.launchFingerprint ?? '', contractFingerprint: ctx.securityBinding?.contractFingerprint ?? '', taskPackageFingerprint: ctx.securityBinding?.taskPackageFingerprint ?? '' });
|
|
184
186
|
};
|
|
185
187
|
if (this.d4Limits)
|
|
186
188
|
await assertD4Host();
|
|
@@ -193,7 +195,8 @@ export class ClaudeCodeAdapter {
|
|
|
193
195
|
if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
|
|
194
196
|
throw new Error("trusted cwd must live outside the assigned worktree");
|
|
195
197
|
const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
|
|
196
|
-
|
|
198
|
+
const recordOnly = !!this.d4Limits && !('max_total_tokens' in this.d4Limits);
|
|
199
|
+
if (!unbounded && !recordOnly && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
|
|
197
200
|
throw new Error("refusing to start: no positive remaining contract budget was supplied");
|
|
198
201
|
}
|
|
199
202
|
if (!this.options.worktreeRoot) {
|
|
@@ -216,9 +219,9 @@ export class ClaudeCodeAdapter {
|
|
|
216
219
|
if (this.cancelled)
|
|
217
220
|
throw new Error("adapter startup cancelled");
|
|
218
221
|
const mcpConfigPath = this.options.writeLocalMcpConfig(endpoint);
|
|
219
|
-
const maxBudgetUsd = unbounded
|
|
222
|
+
const maxBudgetUsd = unbounded || recordOnly
|
|
220
223
|
? null
|
|
221
|
-
: Math.min(this.options.maxBudgetUsd, this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : contractBudget(ctx.taskPackage));
|
|
224
|
+
: Math.min(this.options.maxBudgetUsd, this.d4Limits && 'max_estimated_cost_microusd' in this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : contractBudget(ctx.taskPackage));
|
|
222
225
|
const hookPolicy = buildHookPolicy({
|
|
223
226
|
taskPackage: ctx.taskPackage,
|
|
224
227
|
worktreeRoot: this.options.worktreeRoot,
|
|
@@ -309,6 +312,9 @@ export class ClaudeCodeAdapter {
|
|
|
309
312
|
exitInfo.signal = signal;
|
|
310
313
|
});
|
|
311
314
|
let sawTerminal = false;
|
|
315
|
+
// The CLI names an API error's kind on the assistant message before the result (stream-parser.ts); a later
|
|
316
|
+
// assistant message without one replaces it.
|
|
317
|
+
let assistantError;
|
|
312
318
|
try {
|
|
313
319
|
const lines = this.d4Guard ? this.d4Lines(handle, this.d4Guard) : handle.stdout;
|
|
314
320
|
for await (const line of lines) {
|
|
@@ -348,7 +354,9 @@ export class ClaudeCodeAdapter {
|
|
|
348
354
|
return;
|
|
349
355
|
}
|
|
350
356
|
}
|
|
351
|
-
|
|
357
|
+
if (message.type === "assistant")
|
|
358
|
+
assistantError = typeof message.error === "string" ? message.error : undefined;
|
|
359
|
+
let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1, assistantError });
|
|
352
360
|
if (!parsed)
|
|
353
361
|
continue;
|
|
354
362
|
if (parsed.eventType === "completed") {
|
|
@@ -357,7 +365,7 @@ export class ClaudeCodeAdapter {
|
|
|
357
365
|
if (this.candidateReview)
|
|
358
366
|
parsed = candidateReviewCompletion(parsed);
|
|
359
367
|
if (d4Usage)
|
|
360
|
-
parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: 'observed_stop_possible_overshoot' } } };
|
|
368
|
+
parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: this.d4Limits && 'max_total_tokens' in this.d4Limits ? 'observed_stop_possible_overshoot' : 'record_only' } } };
|
|
361
369
|
if (this.p1)
|
|
362
370
|
parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
|
|
363
371
|
}
|
|
@@ -370,7 +378,8 @@ export class ClaudeCodeAdapter {
|
|
|
370
378
|
}
|
|
371
379
|
}
|
|
372
380
|
if (!sawTerminal) {
|
|
373
|
-
yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})
|
|
381
|
+
yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})`,
|
|
382
|
+
payload: { runtime_failure: runtimeFailure("cli_error") } };
|
|
374
383
|
}
|
|
375
384
|
}
|
|
376
385
|
catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
2
|
import { MAX_EVENT_SUMMARY_CHARS } from "../../protocol.js";
|
|
3
3
|
import { p1ModelEvent } from '../../verification/p1-execution-host.js';
|
|
4
|
+
import { runtimeFailure } from "../runtime-failure.js";
|
|
4
5
|
const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
|
|
5
6
|
function bounded(summary) {
|
|
6
7
|
return summary.length > MAX_EVENT_SUMMARY_CHARS ? summary.slice(0, MAX_EVENT_SUMMARY_CHARS) : summary;
|
|
@@ -69,7 +70,66 @@ function parseSystemMessage(message) {
|
|
|
69
70
|
}
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
/*
|
|
74
|
+
* How Claude Code 2.1.308 (the listed release) says why a turn failed. An API error comes as a top-level assistant
|
|
75
|
+
* message with `error`, a coarse kind such as "authentication_failed", then a result with subtype "success",
|
|
76
|
+
* is_error true, `api_error_status` and, for some causes, `api_error`, a finer kind such as "usage_limit_reached".
|
|
77
|
+
* The CLI's own stops end with an error_* subtype. Only these identifiers and the status are read; the result text and
|
|
78
|
+
* `errors` hold provider wording and are never copied. Kinds this table does not name fall through to the status.
|
|
79
|
+
*/
|
|
80
|
+
const AUTH_KINDS = new Set(["authentication_failed", "oauth_org_not_allowed", "account_on_hold", "verification_required",
|
|
81
|
+
"cloud_credential_error"]);
|
|
82
|
+
const AUTH_API_ERRORS = new Set(["invalid_credential_header", "org_disabled_credential", "api_key_auth_disabled",
|
|
83
|
+
"provider_credentials", "gateway_signin_required", "gateway_session_expired"]);
|
|
84
|
+
const USAGE_API_ERRORS = new Set(["usage_limit_reached", "model_requires_usage_credits", "long_context_credits_required"]);
|
|
85
|
+
const KIND_CODES = new Map([["billing_error", "usage_limit"], ["rate_limit", "rate_limited"],
|
|
86
|
+
["model_not_found", "model_unavailable"], ["overloaded", "provider_error"], ["server_error", "provider_error"],
|
|
87
|
+
["max_output_tokens", "cli_error"]]);
|
|
88
|
+
const FAILURE_SUMMARIES = {
|
|
89
|
+
auth: "execution failed: Claude account sign-in refused",
|
|
90
|
+
usage_limit: "execution failed: Claude usage limit reached",
|
|
91
|
+
model_unavailable: "execution failed: model unavailable to the Claude account",
|
|
92
|
+
rate_limited: "execution failed: rate limited",
|
|
93
|
+
provider_error: "execution failed: provider API error",
|
|
94
|
+
budget_exceeded: "execution failed: budget exceeded",
|
|
95
|
+
cli_error: "execution failed: Claude CLI error",
|
|
96
|
+
unknown: "execution failed",
|
|
97
|
+
};
|
|
98
|
+
/** `assistantError` is the `error` of the last top-level assistant message before this result, if it had one. */
|
|
99
|
+
export function classifyClaudeFailure(message, assistantError) {
|
|
100
|
+
const subtype = String(message.subtype ?? "");
|
|
101
|
+
// The CLI ended the turn itself; no API error can be the cause.
|
|
102
|
+
if (subtype === "error_max_budget_usd" || subtype === "error_max_budget_exceeded")
|
|
103
|
+
return "budget_exceeded";
|
|
104
|
+
if (subtype === "error_max_turns")
|
|
105
|
+
return "cli_error";
|
|
106
|
+
const apiError = typeof message.api_error === "string" ? message.api_error : "";
|
|
107
|
+
if (AUTH_API_ERRORS.has(apiError))
|
|
108
|
+
return "auth";
|
|
109
|
+
if (USAGE_API_ERRORS.has(apiError))
|
|
110
|
+
return "usage_limit";
|
|
111
|
+
const kind = assistantError ?? "";
|
|
112
|
+
if (AUTH_KINDS.has(kind))
|
|
113
|
+
return "auth";
|
|
114
|
+
const mapped = KIND_CODES.get(kind);
|
|
115
|
+
if (mapped)
|
|
116
|
+
return mapped;
|
|
117
|
+
const status = message.api_error_status;
|
|
118
|
+
if (status === 401 || status === 403)
|
|
119
|
+
return "auth";
|
|
120
|
+
if (status === 402)
|
|
121
|
+
return "usage_limit";
|
|
122
|
+
if (status === 404)
|
|
123
|
+
return "model_unavailable";
|
|
124
|
+
if (status === 429)
|
|
125
|
+
return "rate_limited";
|
|
126
|
+
if (typeof status === "number" && Number.isInteger(status) && status >= 500 && status <= 599)
|
|
127
|
+
return "provider_error";
|
|
128
|
+
if (kind === "invalid_request" || subtype === "error_during_execution" || message.terminal_reason === "turn_setup_failed")
|
|
129
|
+
return "cli_error";
|
|
130
|
+
return "unknown";
|
|
131
|
+
}
|
|
132
|
+
function parseResultMessage(message, assistantError) {
|
|
73
133
|
const isError = message.is_error === true;
|
|
74
134
|
const costUsd = typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined;
|
|
75
135
|
if (!isError) {
|
|
@@ -90,8 +150,9 @@ function parseResultMessage(message) {
|
|
|
90
150
|
return { eventType: "failed", summary: "execution failed: structured output missing",
|
|
91
151
|
payload: { ...(costUsd !== undefined ? { cost_usd: costUsd } : {}), completion_rejection: { version: "completion-rejection-v1", code } } };
|
|
92
152
|
}
|
|
93
|
-
const
|
|
94
|
-
return { eventType: "failed", summary
|
|
153
|
+
const code = classifyClaudeFailure(message, assistantError);
|
|
154
|
+
return { eventType: "failed", summary: FAILURE_SUMMARIES[code],
|
|
155
|
+
payload: { ...(costUsd !== undefined ? { cost_usd: costUsd } : {}), runtime_failure: runtimeFailure(code, message.api_error_status) } };
|
|
95
156
|
}
|
|
96
157
|
export function parseClaudeLine(line, worktreeRoot, options = {}) {
|
|
97
158
|
let message;
|
|
@@ -110,7 +171,7 @@ export function parseClaudeLine(line, worktreeRoot, options = {}) {
|
|
|
110
171
|
case "user":
|
|
111
172
|
return parseUserMessage(message.message ?? {});
|
|
112
173
|
case "result":
|
|
113
|
-
return parseResultMessage(message);
|
|
174
|
+
return parseResultMessage(message, options.assistantError);
|
|
114
175
|
default:
|
|
115
176
|
return null;
|
|
116
177
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a provider CLI stopped, as one bounded code (no provider text). The Claude parser sets it on a failed terminal
|
|
3
|
+
* event; the daemon copies it into the job completion so the Gateway can tell an account problem from a code problem.
|
|
4
|
+
*
|
|
5
|
+
* - auth: the CLI's account was signed out, expired or refused.
|
|
6
|
+
* - usage_limit: the account's usage limit or credit ran out.
|
|
7
|
+
* - model_unavailable: the account cannot use the requested model.
|
|
8
|
+
* - rate_limited: the provider throttled the account for a short time.
|
|
9
|
+
* - provider_error: the provider's API failed or was overloaded.
|
|
10
|
+
* - budget_exceeded: the job's own cost budget ran out.
|
|
11
|
+
* - cli_error: the CLI stopped on its own error, not the account's or the provider's.
|
|
12
|
+
* - unknown: an error the CLI reported without a recognisable kind.
|
|
13
|
+
*/
|
|
14
|
+
export const RUNTIME_FAILURE_CODES = ["auth", "usage_limit", "model_unavailable", "rate_limited", "provider_error",
|
|
15
|
+
"budget_exceeded", "cli_error", "unknown"];
|
|
16
|
+
export function runtimeFailure(code, httpStatus) {
|
|
17
|
+
return { version: "runtime-failure-v1", code, ...(isHttpErrorStatus(httpStatus) ? { http_status: httpStatus } : {}) };
|
|
18
|
+
}
|
|
19
|
+
/** The exact v1 shape or undefined; copies nothing else. */
|
|
20
|
+
export function runtimeFailureDiagnostic(value) {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
return undefined;
|
|
23
|
+
const d = value;
|
|
24
|
+
if (d.version !== "runtime-failure-v1" || !RUNTIME_FAILURE_CODES.includes(d.code))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (Object.keys(d).some(key => !["version", "code", "http_status"].includes(key)))
|
|
27
|
+
return undefined;
|
|
28
|
+
if (d.http_status !== undefined && !isHttpErrorStatus(d.http_status))
|
|
29
|
+
return undefined;
|
|
30
|
+
return runtimeFailure(d.code, d.http_status);
|
|
31
|
+
}
|
|
32
|
+
function isHttpErrorStatus(value) {
|
|
33
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599;
|
|
34
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt } from '../verification/continuation-runner-contract.js';
|
|
2
2
|
import { completionRejectionDiagnostic } from '../adapters/completion-report.js';
|
|
3
|
+
import { runtimeFailureDiagnostic } from '../adapters/runtime-failure.js';
|
|
3
4
|
import { assertCandidateReviewPackages, candidateReviewReport } from '../safety/candidate-review-report.js';
|
|
4
|
-
import {
|
|
5
|
+
import { isD4ExecutionSchema } from '../safety/d4-execution-limits.js';
|
|
5
6
|
import { publicationError } from '../publication/failure.js';
|
|
6
7
|
import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
|
|
7
8
|
import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
|
|
@@ -179,7 +180,7 @@ export class ExecutionCoordinator {
|
|
|
179
180
|
assertBoundExecutionAttempt(job, binding.taskPackage, binding.iterationId);
|
|
180
181
|
if (executionAttempt && binding.bindingReplayed !== false)
|
|
181
182
|
throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
|
|
182
|
-
if (pkg.candidate_review_execution?.schema_version
|
|
183
|
+
if (isD4ExecutionSchema(pkg.candidate_review_execution?.schema_version)
|
|
183
184
|
&& (job.generation !== 1 || binding.bindingReplayed !== false))
|
|
184
185
|
throw Error('D4_BINDING_RECONCILIATION_REQUIRED');
|
|
185
186
|
binding = immutable(structuredClone(binding));
|
|
@@ -449,7 +450,7 @@ export class ExecutionCoordinator {
|
|
|
449
450
|
if (isP1 && !hostProof)
|
|
450
451
|
event = p1ModelEvent(event);
|
|
451
452
|
const previousStage = executionStage;
|
|
452
|
-
const d4StopAck = job.taskPackage.candidate_review_execution?.schema_version
|
|
453
|
+
const d4StopAck = isD4ExecutionSchema(job.taskPackage.candidate_review_execution?.schema_version)
|
|
453
454
|
&& ['failed', 'cancelled', 'blocked'].includes(event.eventType);
|
|
454
455
|
let payload = event.payload;
|
|
455
456
|
if (isTerminalEventType(event.eventType) && !d4StopAck) {
|
|
@@ -833,6 +834,8 @@ export class ExecutionCoordinator {
|
|
|
833
834
|
const reportDiagnostic = diagnostic && (status === "blocked"
|
|
834
835
|
? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
|
|
835
836
|
const rejectedCompletion = completionRejectionDiagnostic(terminal.payload?.completion_rejection);
|
|
837
|
+
// Why the provider CLI stopped, as a bounded code; only on a failed job.
|
|
838
|
+
const runtimeFailure = status === 'failed' ? runtimeFailureDiagnostic(terminal.payload?.runtime_failure) : undefined;
|
|
836
839
|
const rejectedCounts = reportDiagnostic?.rejection_counts;
|
|
837
840
|
if (rejectedCompletion || rejectedCounts) {
|
|
838
841
|
try {
|
|
@@ -842,7 +845,7 @@ export class ExecutionCoordinator {
|
|
|
842
845
|
}
|
|
843
846
|
const securityReceipt = adapter.securityReceipt?.();
|
|
844
847
|
const reads = safeLocalReadObservations(adapter.localReadObservations?.());
|
|
845
|
-
const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reason ? { reason } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(rejectedCompletion ? { completion_rejection: rejectedCompletion } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
|
|
848
|
+
const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reason ? { reason } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(rejectedCompletion ? { completion_rejection: rejectedCompletion } : {}), ...(runtimeFailure ? { runtime_failure: runtimeFailure } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
|
|
846
849
|
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
|
|
847
850
|
phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
|
|
848
851
|
completionRequest: structuredClone(completionRequest) });
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { composeReviewedD4Host } from '../safety/d4-host-composition.js';
|
|
2
|
+
import { protocolHash } from '../publication/candidate.js';
|
|
1
3
|
import { createClaudeProductionHost } from '../safety/claude-production-host.js';
|
|
2
4
|
import { adapterStatus } from '../adapters/status.js';
|
|
3
5
|
import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
|
|
@@ -15,7 +17,7 @@ import { CodexPlugin } from "../adapters/codex/plugin.js";
|
|
|
15
17
|
import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
|
|
16
18
|
import { AdapterRegistry } from "../adapters/registry.js";
|
|
17
19
|
import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
|
|
18
|
-
import { D4_OBSERVED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
|
|
20
|
+
import { D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
|
|
19
21
|
import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
|
|
20
22
|
import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from "../safety/claude-runner-token.js";
|
|
21
23
|
import { WorkspaceManager } from "../workspace/workspace-manager.js";
|
|
@@ -76,6 +78,27 @@ export async function buildDaemon(input) {
|
|
|
76
78
|
pendingCodexClaim.job.lease_expires_at = expiresAt;
|
|
77
79
|
};
|
|
78
80
|
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
81
|
+
// Only this daemon's currently executing claim may revalidate a D4 pre-spawn request.
|
|
82
|
+
let activeD4Job, activeD4Binding;
|
|
83
|
+
let d4Host = input.d4ExecutionHost;
|
|
84
|
+
if (!d4Host) {
|
|
85
|
+
const composition = await composeReviewedD4Host({ binaryIdentity: () => claude.binaryIdentity(), revalidateJob: async (descriptor, context) => {
|
|
86
|
+
const job = activeD4Job;
|
|
87
|
+
if (!job || context.jobId !== job.id || context.bindingId !== activeD4Binding || context.generation !== 1 || job.generation !== 1
|
|
88
|
+
|| context.launchFingerprint !== job.launchFingerprint || context.contractFingerprint !== job.gatewayContractFingerprint
|
|
89
|
+
|| context.taskPackageFingerprint !== job.gatewayTaskPackageFingerprint
|
|
90
|
+
|| protocolHash(job.taskPackage) !== job.gatewayTaskPackageFingerprint
|
|
91
|
+
|| protocolHash(job.taskPackage.candidate_review_execution) !== protocolHash(descriptor))
|
|
92
|
+
throw Error('D4_JOB_CONTEXT_REJECTED');
|
|
93
|
+
const { job: current } = await gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken });
|
|
94
|
+
if (activeD4Job !== job || activeD4Binding !== context.bindingId || current.id !== job.id || current.generation !== 1
|
|
95
|
+
|| !['claimed', 'shadowing', 'running'].includes(current.status) || !Number.isSafeInteger(current.lease_expires_at)
|
|
96
|
+
|| current.lease_expires_at <= Math.floor(Date.now() / 1000))
|
|
97
|
+
throw Error('D4_JOB_CONTEXT_REJECTED');
|
|
98
|
+
} });
|
|
99
|
+
d4Host = composition.host;
|
|
100
|
+
process.stderr.write(`${JSON.stringify({ event: 'd4_host', status: composition.status })}\n`);
|
|
101
|
+
}
|
|
79
102
|
// The menu bar app (Work Item b9218937): counts and clickable notices; osascript stays the fallback.
|
|
80
103
|
let roomActivityEnabled = false;
|
|
81
104
|
const menubar = buildMenubar({ stateRoot: input.stateRoot, home: input.env.HOME ?? "", gatewayMcpUrl: input.gatewayMcpUrl,
|
|
@@ -124,7 +147,7 @@ export async function buildDaemon(input) {
|
|
|
124
147
|
securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
|
|
125
148
|
pullRequestEvidence: (args, signal) => gateway.readPullRequestEvidence(args, signal),
|
|
126
149
|
readCandidateReviewContext: loopId => gateway.readCandidateReviewContext(loopId),
|
|
127
|
-
d4ExecutionHost:
|
|
150
|
+
d4ExecutionHost: d4Host,
|
|
128
151
|
}),
|
|
129
152
|
});
|
|
130
153
|
const codex = new CodexPlugin({
|
|
@@ -156,10 +179,15 @@ export async function buildDaemon(input) {
|
|
|
156
179
|
shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
|
|
157
180
|
contractFingerprint: job.gatewayContractFingerprint ?? "",
|
|
158
181
|
taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
|
|
159
|
-
createBinding: async (job, signal) =>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
createBinding: async (job, signal) => {
|
|
183
|
+
const binding = await gateway.createLocalJobBinding({
|
|
184
|
+
jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
|
|
185
|
+
launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
|
|
186
|
+
}, signal);
|
|
187
|
+
if (activeD4Job?.id === job.id)
|
|
188
|
+
activeD4Binding = binding.bindingId;
|
|
189
|
+
return binding;
|
|
190
|
+
},
|
|
163
191
|
publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
|
|
164
192
|
syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
|
|
165
193
|
confirmServerPublicationCompletion: (bindingId, cursor, job, receipt) => gateway.confirmServerPublicationCompletion(bindingId, cursor, job, receipt),
|
|
@@ -221,7 +249,22 @@ export async function buildDaemon(input) {
|
|
|
221
249
|
return { status: "claimed", job };
|
|
222
250
|
},
|
|
223
251
|
execute: async (job) => {
|
|
224
|
-
|
|
252
|
+
const claimed = job;
|
|
253
|
+
if (claimed.taskPackage.candidate_review_execution) {
|
|
254
|
+
if (activeD4Job)
|
|
255
|
+
throw Error('D4_JOB_CONTEXT_REJECTED');
|
|
256
|
+
activeD4Job = structuredClone(claimed);
|
|
257
|
+
activeD4Binding = undefined;
|
|
258
|
+
try {
|
|
259
|
+
await coordinator.execute(claimed);
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
activeD4Job = undefined;
|
|
263
|
+
activeD4Binding = undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else
|
|
267
|
+
await coordinator.execute(claimed);
|
|
225
268
|
},
|
|
226
269
|
requestSync: reason => { transport?.requestSync(reason); },
|
|
227
270
|
// Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
|
|
@@ -298,10 +341,16 @@ export async function buildDaemon(input) {
|
|
|
298
341
|
}
|
|
299
342
|
const askInbox = await asks.advertisement();
|
|
300
343
|
const guidance = await guide.advertisement();
|
|
344
|
+
let d4Ready;
|
|
345
|
+
try {
|
|
346
|
+
if (d4Host && (!d4Host.ready || await d4Host.ready()))
|
|
347
|
+
d4Ready = d4Host;
|
|
348
|
+
}
|
|
349
|
+
catch { /* D4 readiness never disables other workflows. */ }
|
|
301
350
|
return {
|
|
302
351
|
deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
|
|
303
352
|
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
304
|
-
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])],
|
|
353
|
+
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], d4Ready), poller.accepting),
|
|
305
354
|
mode: "full",
|
|
306
355
|
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
307
356
|
...(guidance ? { guidance } : {}),
|
|
@@ -471,7 +520,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
|
|
|
471
520
|
return probes.map(probe => {
|
|
472
521
|
const status = adapterStatus(probe);
|
|
473
522
|
const authenticated = status.authenticated;
|
|
474
|
-
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1' && value !== D4_OBSERVED_EXECUTION_CAPABILITY);
|
|
523
|
+
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1' && value !== D4_OBSERVED_EXECUTION_CAPABILITY && value !== D4_RECORDED_EXECUTION_CAPABILITY);
|
|
475
524
|
if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
|
|
476
525
|
capabilities.push('local_fix_publication_v1');
|
|
477
526
|
if (modes.includes('gateway_ci_v1'))
|
|
@@ -484,7 +533,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
|
|
|
484
533
|
capabilities.push('pull_request_evidence_v1');
|
|
485
534
|
const d4 = authenticated && probe.id === 'claude_code_cli' && d4Host;
|
|
486
535
|
if (d4)
|
|
487
|
-
capabilities.push(D4_OBSERVED_EXECUTION_CAPABILITY);
|
|
536
|
+
capabilities.push(...(d4.capabilities ?? [D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY]));
|
|
488
537
|
return { adapter: probe.id, version: probe.version, ...status, capabilities, ...(d4 ? { runner_release_sha256: d4.runnerReleaseSha256, host_policy_sha256: d4.hostPolicySha256 } : {}) };
|
|
489
538
|
});
|
|
490
539
|
}
|
|
@@ -23,6 +23,14 @@ function textOf(content) {
|
|
|
23
23
|
const parts = content.filter((block) => record(block) && block.type === 'text' && typeof block.text === 'string').map(block => block.text);
|
|
24
24
|
return parts.length ? parts.join('') : null;
|
|
25
25
|
}
|
|
26
|
+
/** Tool evidence accepts text only. Never discard an unrecognized block and salvage a passing JSON object. */
|
|
27
|
+
function toolResultText(content) {
|
|
28
|
+
if (typeof content === 'string')
|
|
29
|
+
return content;
|
|
30
|
+
if (!Array.isArray(content) || content.length === 0 || !content.every((block) => record(block) && Object.keys(block).length === 2 && block.type === 'text' && typeof block.text === 'string'))
|
|
31
|
+
return 'CLAUDE_REVIEW_TOOL_RESULT_CONTENT_REJECTED';
|
|
32
|
+
return content.map(block => block.text).join('');
|
|
33
|
+
}
|
|
26
34
|
const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
27
35
|
/** The user's own text. The real CLI prepends context in system-reminder tags, in its own block or the same one. */
|
|
28
36
|
function userText(content) {
|
|
@@ -131,7 +139,7 @@ export async function startClaudeReviewProvider(script) {
|
|
|
131
139
|
continue;
|
|
132
140
|
toolResultIds.push(block.tool_use_id);
|
|
133
141
|
if (!toolResults.has(block.tool_use_id))
|
|
134
|
-
toolResults.set(block.tool_use_id, { isError: block.is_error === true, text:
|
|
142
|
+
toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: toolResultText(block.content) });
|
|
135
143
|
}
|
|
136
144
|
const firstUser = messages.find(message => message.role === 'user'), { text: firstUserText, reminders } = firstUser ? userText(firstUser.content) : { text: null, reminders: 0 };
|
|
137
145
|
// The scripted turn: the launched model, the exact review prompt as the first user text and an offered tool list.
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { protocolHash } from '../publication/candidate.js';
|
|
2
2
|
import { D4_REVIEW_INVENTORY } from './local-tool-policy.js';
|
|
3
3
|
const LIMIT_KEYS = ['max_candidates', 'max_process_starts', 'max_runtime_seconds', 'automatic_retries', 'max_total_tokens', 'max_estimated_cost_microusd'];
|
|
4
|
+
export function parseD4RecordLimits(value) {
|
|
5
|
+
const names = LIMIT_KEYS.slice(0, 4);
|
|
6
|
+
if (!object(value) || !keys(value, names) || !names.every(k => integer(value[k]))
|
|
7
|
+
|| value.max_candidates !== 1 || value.max_process_starts !== 1 || value.automatic_retries !== 0
|
|
8
|
+
|| !integer(value.max_runtime_seconds, 1) || value.max_runtime_seconds > 900)
|
|
9
|
+
return reject('D4_LIMIT_PROFILE_REJECTED');
|
|
10
|
+
return Object.freeze({ ...value });
|
|
11
|
+
}
|
|
4
12
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
5
13
|
const keys = (v, expected) => Object.keys(v).length === expected.length && expected.every(k => Object.hasOwn(v, k));
|
|
6
14
|
const integer = (v, min = 0) => typeof v === 'number' && Number.isSafeInteger(v) && v >= min;
|
|
@@ -26,9 +34,12 @@ const d4 = (p) => ['candidate_review', 'candidate_review_record', 'candidate_rev
|
|
|
26
34
|
export const D4_OBSERVED_EXECUTION_SCHEMA = 'd4-review-execution-v2';
|
|
27
35
|
export const D4_OBSERVED_EXECUTION_STAGE = 'cli_observed_usage_v1';
|
|
28
36
|
export const D4_OBSERVED_EXECUTION_CAPABILITY = 'd4_review_observed_usage_v1';
|
|
37
|
+
export const D4_RECORDED_EXECUTION_SCHEMA = 'd4-review-execution-v3';
|
|
38
|
+
export const D4_RECORDED_EXECUTION_CAPABILITY = 'd4_review_recorded_usage_v1';
|
|
39
|
+
export const isD4ExecutionSchema = (s) => s === D4_OBSERVED_EXECUTION_SCHEMA || s === D4_RECORDED_EXECUTION_SCHEMA;
|
|
29
40
|
const PROFILE_KEYS = ['schema_version', 'decision_revision_id', 'runtime_adapter', 'provider', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint',
|
|
30
41
|
'runner_release_sha256', 'host_policy_sha256', 'review_status', 'limits', 'usage_enforcement'];
|
|
31
|
-
/**
|
|
42
|
+
/** Immutable v2 observed-stop and v3 recording-only packages; neither grants launch authority. */
|
|
32
43
|
export function assertD4PackageLimitSupport(bound, approved, resume) {
|
|
33
44
|
if (![bound, approved].some(p => object(p) && d4(p)))
|
|
34
45
|
return;
|
|
@@ -43,19 +54,20 @@ export function assertD4PackageLimitSupport(bound, approved, resume) {
|
|
|
43
54
|
return reject('D4_LIMIT_PROFILE_REQUIRED');
|
|
44
55
|
if (protocolHash(a) !== protocolHash(b))
|
|
45
56
|
return reject('D4_LIMIT_PROFILE_CHANGED');
|
|
46
|
-
if (a.schema_version
|
|
57
|
+
if (!isD4ExecutionSchema(a.schema_version))
|
|
47
58
|
return assertCurrentD4LimitSupport();
|
|
59
|
+
const recordOnly = a.schema_version === D4_RECORDED_EXECUTION_SCHEMA;
|
|
48
60
|
const p = a.profile, u = p.usage_enforcement;
|
|
49
|
-
if (!keys(a, ['schema_version', 'stage', 'profile', 'profile_fingerprint', 'registration_fingerprint']) || a.stage !== D4_OBSERVED_EXECUTION_STAGE
|
|
50
|
-
|| !keys(p, PROFILE_KEYS) || p.schema_version !== 'd4-review-execution-profile-v2' || p.review_status !== 'reviewed'
|
|
61
|
+
if (!keys(a, ['schema_version', 'stage', 'profile', 'profile_fingerprint', 'registration_fingerprint']) || a.stage !== (recordOnly ? 'cli_recorded_usage_v1' : D4_OBSERVED_EXECUTION_STAGE)
|
|
62
|
+
|| !keys(p, PROFILE_KEYS) || p.schema_version !== (recordOnly ? 'd4-review-execution-profile-v3' : 'd4-review-execution-profile-v2') || p.review_status !== 'reviewed'
|
|
51
63
|
|| p.runtime_adapter !== 'claude_code_cli' || p.provider !== 'anthropic' || p.model_id !== 'claude-sonnet-5-5' || p.tool_inventory !== D4_REVIEW_INVENTORY
|
|
52
64
|
|| typeof p.decision_revision_id !== 'string' || !(/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/).test(p.decision_revision_id)
|
|
53
65
|
|| !['tools_sha256', 'cli_security_fingerprint', 'runner_release_sha256', 'host_policy_sha256'].every(k => typeof p[k] === 'string' && /^[a-f0-9]{64}$/.test(p[k]))
|
|
54
66
|
|| typeof a.registration_fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(a.registration_fingerprint)
|
|
55
67
|
|| a.profile_fingerprint !== protocolHash(p) || !object(u) || !keys(u, ['tokens', 'estimated_cost', 'hard_stop'])
|
|
56
|
-
|| u.tokens !== 'observed_stop' || u.estimated_cost !== 'observed_stop' || u.hard_stop !== false)
|
|
68
|
+
|| u.tokens !== (recordOnly ? 'record_only' : 'observed_stop') || u.estimated_cost !== (recordOnly ? 'record_only' : 'observed_stop') || u.hard_stop !== false)
|
|
57
69
|
return reject('D4_LIMIT_PROFILE_REJECTED');
|
|
58
|
-
return parseD4Limits(p.limits);
|
|
70
|
+
return recordOnly ? parseD4RecordLimits(p.limits) : parseD4Limits(p.limits);
|
|
59
71
|
}
|
|
60
72
|
/** Conservative diagnostic interpretation only. All token partitions and the CLI's estimated cost must be explicit.
|
|
61
73
|
* This does not establish that a CLI result is cumulative, timely, complete or an invoice/weekly-limit measurement. */
|
|
@@ -106,8 +118,8 @@ export class D4CliUsageTracker {
|
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
const STOP_TIMEOUT_MS = 6000;
|
|
109
|
-
/** Local owned-process
|
|
110
|
-
*
|
|
121
|
+
/** Local owned-process watchdog. It grants no admission; the adapter also requires a trusted reviewed host.
|
|
122
|
+
* V2 stops on observed thresholds (possible overshoot); V3 records usage without numeric stopping.
|
|
111
123
|
* In-memory start accounting is not a durable restart fence; existing Job/lease owners must provide that later. */
|
|
112
124
|
export class D4ObservedLimitWatchdog {
|
|
113
125
|
host;
|
|
@@ -124,7 +136,7 @@ export class D4ObservedLimitWatchdog {
|
|
|
124
136
|
signalStopped;
|
|
125
137
|
constructor(limits, host) {
|
|
126
138
|
this.host = host;
|
|
127
|
-
this.limits = parseD4Limits(limits);
|
|
139
|
+
this.limits = object(limits) && !Object.hasOwn(limits, 'max_total_tokens') ? parseD4RecordLimits(limits) : parseD4Limits(limits);
|
|
128
140
|
this.stoppedSignal = new Promise(resolve => { this.signalStopped = resolve; });
|
|
129
141
|
}
|
|
130
142
|
now() { const n = (this.host.now ?? (() => performance.now()))(); if (!Number.isFinite(n) || n < this.lastNow)
|
|
@@ -167,9 +179,9 @@ export class D4ObservedLimitWatchdog {
|
|
|
167
179
|
return this.stop('D4_USAGE_UNAVAILABLE');
|
|
168
180
|
}
|
|
169
181
|
this.last = { ...s };
|
|
170
|
-
if (s.total_tokens >= this.limits.max_total_tokens)
|
|
182
|
+
if ('max_total_tokens' in this.limits && s.total_tokens >= this.limits.max_total_tokens)
|
|
171
183
|
return this.stop('D4_OBSERVED_TOKEN_LIMIT');
|
|
172
|
-
if (s.estimated_cost_microusd >= this.limits.max_estimated_cost_microusd)
|
|
184
|
+
if ('max_estimated_cost_microusd' in this.limits && s.estimated_cost_microusd >= this.limits.max_estimated_cost_microusd)
|
|
173
185
|
return this.stop('D4_OBSERVED_COST_LIMIT');
|
|
174
186
|
}
|
|
175
187
|
cancel() { return this.stop('D4_CANCELLED'); }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { D4_REVIEW_REGISTRY_FILE, d4RuntimeReleaseFingerprint } from './d4-runtime-release.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { realpath } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { protocolHash } from '../publication/candidate.js';
|
|
6
|
+
import { measureLocalFixRuntime } from '../publication/runtime-release.js';
|
|
7
|
+
import { measureCommandFile } from './registered-command-integrity.js';
|
|
8
|
+
import { assertCurrentClaudeBinary, claudeBinaryLaunchPath } from './claude-binary-identity.js';
|
|
9
|
+
import { localToolInventory } from './local-tool-server.js';
|
|
10
|
+
import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
|
|
11
|
+
import { assertD4PackageLimitSupport, D4_RECORDED_EXECUTION_SCHEMA, D4_RECORDED_EXECUTION_CAPABILITY } from './d4-execution-limits.js';
|
|
12
|
+
import { REVIEWED_D4_HOST_REGISTRATIONS } from './d4-reviewed-hosts.js';
|
|
13
|
+
const denied = () => Error('D4_HOST_INPUT_REJECTED');
|
|
14
|
+
const hex = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
15
|
+
const uuid = (v) => typeof v === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v);
|
|
16
|
+
const exact = (v, names) => Object.keys(v).sort().join() === names.sort().join();
|
|
17
|
+
/** Reuses the full packaged runtime/dependency measurement. A matching digest is not a review:
|
|
18
|
+
* only the compiled reviewed registration supplies authority, and Gateway still owns execution grants. */
|
|
19
|
+
export async function composeReviewedD4Host(input) {
|
|
20
|
+
const registrations = input.registrations ?? REVIEWED_D4_HOST_REGISTRATIONS;
|
|
21
|
+
if (registrations.length === 0)
|
|
22
|
+
return { status: 'absent' };
|
|
23
|
+
if (registrations.length !== 1)
|
|
24
|
+
return { status: 'mismatch' };
|
|
25
|
+
try {
|
|
26
|
+
const r = structuredClone(registrations[0]), p = r.policy;
|
|
27
|
+
if (!exact(r, ['schema_version', 'review_status', 'review_receipt_id', 'runner_release_sha256', 'host_policy_sha256', 'policy'])
|
|
28
|
+
|| r.schema_version !== 'd4-trusted-host-registration-v1' || r.review_status !== 'reviewed' || !uuid(r.review_receipt_id)
|
|
29
|
+
|| !hex(r.runner_release_sha256) || !hex(r.host_policy_sha256) || !p || typeof p !== 'object'
|
|
30
|
+
|| !exact(p, ['schema_version', 'decision_revision_id', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint', 'claude_binary_sha256', 'claude_launch_config_sha256', 'node', 'usage_policy', 'max_candidates', 'max_process_starts', 'max_runtime_seconds', 'automatic_retries', 'automatic_resume'])
|
|
31
|
+
|| p.schema_version !== 'd4-trusted-readonly-policy-v1' || !uuid(p.decision_revision_id) || p.model_id !== 'claude-sonnet-5-5'
|
|
32
|
+
|| p.tool_inventory !== 'local-tools-d4-review-v1' || p.tools_sha256 !== protocolHash({ tools: localToolInventory('local-tools-d4-review-v1') })
|
|
33
|
+
|| !hex(p.cli_security_fingerprint) || !hex(p.claude_binary_sha256) || p.claude_launch_config_sha256 !== protocolHash(CLAUDE_LAUNCH_CONFIG)
|
|
34
|
+
|| !p.node || !exact(p.node, ['executable', 'sha256']) || !path.isAbsolute(p.node.executable) || !hex(p.node.sha256)
|
|
35
|
+
|| p.usage_policy !== 'record_only' || p.max_candidates !== 1 || p.max_process_starts !== 1 || p.max_runtime_seconds !== 900 || p.automatic_retries !== 0 || p.automatic_resume !== false
|
|
36
|
+
|| r.host_policy_sha256 !== protocolHash(p))
|
|
37
|
+
throw denied();
|
|
38
|
+
const root = await realpath(input.packageRoot ?? fileURLToPath(new URL('../../../', import.meta.url)));
|
|
39
|
+
if (input.packageRoot !== undefined && root !== input.packageRoot)
|
|
40
|
+
throw denied();
|
|
41
|
+
const registrySha256 = await measureCommandFile(path.join(root, D4_REVIEW_REGISTRY_FILE));
|
|
42
|
+
const runtimeCurrent = async () => {
|
|
43
|
+
if (await realpath(process.execPath) !== p.node.executable || await measureCommandFile(p.node.executable) !== p.node.sha256)
|
|
44
|
+
throw denied();
|
|
45
|
+
const { files } = await measureLocalFixRuntime(root);
|
|
46
|
+
if (files[D4_REVIEW_REGISTRY_FILE] !== registrySha256 || d4RuntimeReleaseFingerprint(files) !== r.runner_release_sha256)
|
|
47
|
+
throw denied();
|
|
48
|
+
};
|
|
49
|
+
await runtimeCurrent();
|
|
50
|
+
const localCurrent = async () => {
|
|
51
|
+
await runtimeCurrent();
|
|
52
|
+
const identity = await input.binaryIdentity();
|
|
53
|
+
if (identity.sha256 !== p.claude_binary_sha256)
|
|
54
|
+
throw denied();
|
|
55
|
+
await assertCurrentClaudeBinary(identity, claudeBinaryLaunchPath(identity), p.claude_binary_sha256);
|
|
56
|
+
};
|
|
57
|
+
const descriptorCurrent = (d) => {
|
|
58
|
+
if (d.schema_version !== D4_RECORDED_EXECUTION_SCHEMA)
|
|
59
|
+
throw denied();
|
|
60
|
+
assertD4PackageLimitSupport({ candidate_review_execution: d }, { candidate_review_execution: d });
|
|
61
|
+
const profile = d.profile;
|
|
62
|
+
for (const key of ['decision_revision_id', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint'])
|
|
63
|
+
if (profile[key] !== p[key])
|
|
64
|
+
throw denied();
|
|
65
|
+
if (profile.runner_release_sha256 !== r.runner_release_sha256 || profile.host_policy_sha256 !== r.host_policy_sha256)
|
|
66
|
+
throw denied();
|
|
67
|
+
};
|
|
68
|
+
const host = Object.freeze({ capabilities: Object.freeze([D4_RECORDED_EXECUTION_CAPABILITY]), runnerReleaseSha256: r.runner_release_sha256, hostPolicySha256: r.host_policy_sha256,
|
|
69
|
+
ready: async () => { try {
|
|
70
|
+
await localCurrent();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
} },
|
|
76
|
+
assertCurrent: async (d, job) => {
|
|
77
|
+
try {
|
|
78
|
+
const descriptor = structuredClone(d), context = structuredClone(job);
|
|
79
|
+
if (!context || !context.jobId || !context.bindingId || context.generation !== 1
|
|
80
|
+
|| !hex(context.launchFingerprint) || !hex(context.contractFingerprint) || !hex(context.taskPackageFingerprint))
|
|
81
|
+
throw denied();
|
|
82
|
+
descriptorCurrent(descriptor);
|
|
83
|
+
await localCurrent();
|
|
84
|
+
await input.revalidateJob(descriptor, context);
|
|
85
|
+
// Recheck original inputs after the asynchronous authority lookup, before returning to spawn.
|
|
86
|
+
if (protocolHash(d) !== protocolHash(descriptor) || protocolHash(job) !== protocolHash(context))
|
|
87
|
+
throw denied();
|
|
88
|
+
descriptorCurrent(descriptor);
|
|
89
|
+
await localCurrent();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw denied();
|
|
93
|
+
}
|
|
94
|
+
} });
|
|
95
|
+
return { status: 'composed', host };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { status: 'mismatch' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { protocolHash } from '../publication/candidate.js';
|
|
2
|
+
/** The compiled owner-reviewed registration is the trust anchor, not runtime payload.
|
|
3
|
+
* Including its embedded expected digest in that digest would require a fixed point.
|
|
4
|
+
* The composer separately pins these exact file bytes for every current-host check. */
|
|
5
|
+
export const D4_REVIEW_REGISTRY_FILE = 'dist/src/safety/d4-reviewed-hosts.js';
|
|
6
|
+
export function d4RuntimeReleaseFingerprint(files) {
|
|
7
|
+
const runtime = Object.fromEntries(Object.entries(files).filter(([file]) => file !== D4_REVIEW_REGISTRY_FILE));
|
|
8
|
+
return protocolHash({ role: 'd4_review_host', files: runtime });
|
|
9
|
+
}
|
|
@@ -3,6 +3,7 @@ import { RunnerObservedVerifier, assertGatewayVerificationScope } from './runner
|
|
|
3
3
|
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
4
4
|
import { protocolHash } from '../publication/candidate.js';
|
|
5
5
|
import { resolveRunnerVerification, assertVerificationHostVersion, REVIEWED_POLICY, REVIEWED_LOOP_TYPE } from './reviewed-runner-contract.js';
|
|
6
|
+
import { runtimeFailureDiagnostic } from '../adapters/runtime-failure.js';
|
|
6
7
|
export class RunnerObservedExecutionHost {
|
|
7
8
|
privateRoot;
|
|
8
9
|
supportedVerificationSchemas;
|
|
@@ -101,6 +102,9 @@ export function p1ModelEvent(event) {
|
|
|
101
102
|
}
|
|
102
103
|
if (Array.isArray(event.payload?.memory_usage_items))
|
|
103
104
|
payload.memory_usage_items = event.payload.memory_usage_items;
|
|
105
|
+
const failure = event.eventType === 'failed' ? runtimeFailureDiagnostic(event.payload?.runtime_failure) : undefined;
|
|
106
|
+
if (failure)
|
|
107
|
+
payload.runtime_failure = failure;
|
|
104
108
|
// Only parsers can attach these provider observations; never completion text.
|
|
105
109
|
const usage = event.payload?.usage;
|
|
106
110
|
if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
|