mtech-ai-local-runner 0.1.70 → 0.1.71
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 +4 -2
- package/dist/src/adapters/discovery.js +9 -2
- package/dist/src/asks/inbox.js +12 -6
- package/dist/src/asks/notifier.js +23 -7
- package/dist/src/daemon/execution-coordinator.js +5 -1
- package/dist/src/gateway-client.js +9 -2
- package/dist/src/safety/job-tool-policy.js +13 -3
- package/dist/src/verification/p1-execution-host.js +16 -7
- package/dist/src/verification/reviewed-runner-contract.js +77 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -187,8 +187,10 @@ seconds and logs only an error code, and the ask waits for the next session star
|
|
|
187
187
|
carries validated ids only; the AI reads the ask itself with the Gateway show tools, which mark its text as untrusted.
|
|
188
188
|
|
|
189
189
|
An ask or answer the runner puts into a session on its own, by a wake or at a turn end, also gets one macOS
|
|
190
|
-
notification naming that app ("It is in your Claude session
|
|
191
|
-
|
|
190
|
+
notification naming that app and session ("It is in your Claude session in api-cloud-service-ai-gateway (last typed
|
|
191
|
+
17:39)."), since the person may be looking elsewhere (Work Item 643dc395). Hooks carry no session title, so the
|
|
192
|
+
session is named by the last segment of its cwd and the local time of the last prompt a person typed there; an answer
|
|
193
|
+
held in its asking session is named the same way. No prompt text is shown. It is sent within two minutes of the show and never for a delivery that already had a notification; one
|
|
192
194
|
shown because the person typed gets none. The Gateway's `shown_via` stays `rewake` or `stop`.
|
|
193
195
|
|
|
194
196
|
While the Gateway's sync says `ask_alert: true` for the person (their actor id is in
|
|
@@ -55,8 +55,15 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
|
|
|
55
55
|
}
|
|
56
56
|
}));
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether setup can make this Mac a full runner. Codex needs its probe to admit it. Claude needs only a verified login:
|
|
60
|
+
* the host probe reports Claude unsupported on every Mac, and the daemon runs Claude only from its own verified copy of
|
|
61
|
+
* the reviewed release the Gateway lists, so the probe must not decide it. Without this a Mac with only Claude always
|
|
62
|
+
* became a messenger (Work Item 1c8026f1).
|
|
63
|
+
*/
|
|
58
64
|
export function hasUsableAdapter(adapters) {
|
|
59
|
-
return adapters.some(adapter => adapter.
|
|
65
|
+
return adapters.some(adapter => adapter.authenticated
|
|
66
|
+
&& (adapter.available !== false || adapter.id === 'claude_code_cli' && adapter.loginStatus === 'authenticated'));
|
|
60
67
|
}
|
|
61
68
|
/** Stable local remedies, never a CLI stderr or account-output echo. */
|
|
62
69
|
export function adapterDiagnosticsWithRemedies(adapters) {
|
|
@@ -75,7 +82,7 @@ export function adapterDiagnosticsWithRemedies(adapters) {
|
|
|
75
82
|
else if (adapter.loginStatus === 'unknown')
|
|
76
83
|
action = 'Claude login status is unknown; inspect the local status diagnostic before changing credentials. Execution remains blocked.';
|
|
77
84
|
else if (!adapter.available && adapter.executableVersion && adapter.authenticated)
|
|
78
|
-
action = 'Claude
|
|
85
|
+
action = 'Claude is logged in. The runner runs it only from its own verified copy of the reviewed release the AI Gateway lists; that security check happens in the running service, not here.';
|
|
79
86
|
else if (!adapter.available)
|
|
80
87
|
action = 'Install Claude Code CLI and sign in, then rerun doctor; security admission is also required.';
|
|
81
88
|
else if (!adapter.authenticated)
|
package/dist/src/asks/inbox.js
CHANGED
|
@@ -3,6 +3,7 @@ import { addDeliveries, claimOpen, claimShownNotifications, closeDeliveries, con
|
|
|
3
3
|
import { droppableDeliveryId, parseAskDelivery } from "./types.js";
|
|
4
4
|
import { renderOutput } from "./templates.js";
|
|
5
5
|
import { PRESENCE_RECHECK_MS } from "./presence.js";
|
|
6
|
+
import { sessionPlace } from "./notifier.js";
|
|
6
7
|
/** The most reports one flush sends, so a misbehaving Gateway can never keep it busy. */
|
|
7
8
|
const MAX_REPORTS_PER_FLUSH = 200;
|
|
8
9
|
/** How many ended delivery ids are remembered, so one offered again is never stored or shown again. */
|
|
@@ -71,6 +72,11 @@ export class AskInbox {
|
|
|
71
72
|
watchers: this.watchers.size,
|
|
72
73
|
};
|
|
73
74
|
}
|
|
75
|
+
/** Which session a notification points at: its folder and last typed time (Work Item 643dc395). */
|
|
76
|
+
placeOf(key) {
|
|
77
|
+
const session = key === null ? undefined : this.state.sessions[key];
|
|
78
|
+
return session === undefined ? undefined : sessionPlace(session.cwd, session.lastPromptAt);
|
|
79
|
+
}
|
|
74
80
|
/** The app of the session with the most recent typed prompt, for an alert's Open button (Work Item 643dc395). */
|
|
75
81
|
preferredApp() {
|
|
76
82
|
return preferredApp(this.state);
|
|
@@ -294,7 +300,7 @@ export class AskInbox {
|
|
|
294
300
|
confirmWake(this.state, key, output, now);
|
|
295
301
|
}
|
|
296
302
|
// What a wake or a turn end just showed gets its one notification: the person may not be looking at that session.
|
|
297
|
-
const shown = claimShownNotifications(this.state, now);
|
|
303
|
+
const shown = claimShownNotifications(this.state, now).map(claim => ({ ...claim, place: this.placeOf(this.state.items[claim.deliveryId]?.sessionKey ?? null) }));
|
|
298
304
|
// Claimed before anything runs, so a delivery is never opened or notified twice.
|
|
299
305
|
const opening = new Set(plan.open.map(item => item.deliveryId));
|
|
300
306
|
claimOpen(this.state, [...opening], now);
|
|
@@ -303,7 +309,7 @@ export class AskInbox {
|
|
|
303
309
|
const notify = plan.notify.map(item => item.deliveryId);
|
|
304
310
|
const held = plan.held.flatMap(item => {
|
|
305
311
|
const session = item.sessionKey === null ? undefined : this.state.sessions[item.sessionKey];
|
|
306
|
-
return session === undefined ? [] : [{ deliveryId: item.deliveryId, app: session.app }];
|
|
312
|
+
return session === undefined ? [] : [{ deliveryId: item.deliveryId, app: session.app, place: this.placeOf(item.sessionKey) }];
|
|
307
313
|
});
|
|
308
314
|
return {
|
|
309
315
|
notify: notify.filter(id => !opening.has(id)),
|
|
@@ -318,10 +324,10 @@ export class AskInbox {
|
|
|
318
324
|
await this.persist();
|
|
319
325
|
if (work.notify.length > 0)
|
|
320
326
|
this.startNotifying(work.notify);
|
|
321
|
-
for (const { deliveryId, app } of work.held)
|
|
322
|
-
this.startNotifying([deliveryId], { heldIn: app });
|
|
323
|
-
for (const { deliveryId, app } of work.shown)
|
|
324
|
-
this.startNotifying([deliveryId], { shownIn: app });
|
|
327
|
+
for (const { deliveryId, app, place } of work.held)
|
|
328
|
+
this.startNotifying([deliveryId], { heldIn: app, ...(place === undefined ? {} : { place }) });
|
|
329
|
+
for (const { deliveryId, app, place } of work.shown)
|
|
330
|
+
this.startNotifying([deliveryId], { shownIn: app, ...(place === undefined ? {} : { place }) });
|
|
325
331
|
if (work.open !== null)
|
|
326
332
|
this.startOpening(work.open);
|
|
327
333
|
try {
|
|
@@ -27,10 +27,23 @@ const OPENED_TEXT = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
/** An answer held in its live asking session (Work Decision 8562d191): no session is opened, the person goes back to it. */
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
30
|
+
function heldText(app, place) {
|
|
31
|
+
const where = place === undefined ? "" : `${place.folder === null ? "" : `, in ${place.folder}`}${typedText(place)}`;
|
|
32
|
+
return `AI Gateway: your ask has an answer. It is waiting in the ${APP_NAME[app]} session that asked${where}. Type there to see it.`;
|
|
33
|
+
}
|
|
34
|
+
const MAX_FOLDER_CHARS = 48;
|
|
35
|
+
/** The place of a session from its cwd and lastPromptAt; undefined when neither is known. */
|
|
36
|
+
export function sessionPlace(cwd, lastPromptAt, timeZone) {
|
|
37
|
+
const segment = cwd.split("/").filter(part => part !== "").at(-1) ?? "";
|
|
38
|
+
const clean = segment.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
39
|
+
const folder = clean === "" ? null : clean.length > MAX_FOLDER_CHARS ? `${clean.slice(0, MAX_FOLDER_CHARS - 1)}…` : clean;
|
|
40
|
+
const lastTyped = lastPromptAt === null ? null
|
|
41
|
+
: new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, ...(timeZone === undefined ? {} : { timeZone }) }).format(new Date(lastPromptAt));
|
|
42
|
+
return folder === null && lastTyped === null ? undefined : { folder, lastTyped };
|
|
43
|
+
}
|
|
44
|
+
function typedText(place) {
|
|
45
|
+
return place.lastTyped !== null && /^\d{2}:\d{2}$/.test(place.lastTyped) ? ` (last typed ${place.lastTyped})` : "";
|
|
46
|
+
}
|
|
34
47
|
export const OSASCRIPT = "/usr/bin/osascript";
|
|
35
48
|
const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title \"AI Gateway\"", "end run"];
|
|
36
49
|
/**
|
|
@@ -41,12 +54,15 @@ const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title
|
|
|
41
54
|
export const NOTIFY_TIMEOUT_MS = 3_000;
|
|
42
55
|
export function notificationText(item, options = {}) {
|
|
43
56
|
if (options.heldIn !== undefined)
|
|
44
|
-
return
|
|
57
|
+
return heldText(options.heldIn, options.place);
|
|
45
58
|
if (options.openedApp !== undefined)
|
|
46
59
|
return OPENED_TEXT[item.direction][options.openedApp];
|
|
47
60
|
const lead = (item.direction === "ask" ? ASK_LEAD : ANSWER_LEAD)[item.subjectType];
|
|
48
|
-
if (options.shownIn !== undefined)
|
|
49
|
-
|
|
61
|
+
if (options.shownIn !== undefined) {
|
|
62
|
+
const place = options.place;
|
|
63
|
+
const where = place === undefined ? "" : `${place.folder === null ? "" : ` in ${place.folder}`}${typedText(place)}`;
|
|
64
|
+
return `${lead} It is in your ${APP_NAME[options.shownIn]} session${where}.`;
|
|
65
|
+
}
|
|
50
66
|
return `${lead} ${item.direction === "ask" ? "Open Claude or ChatGPT to see it." : "Open Claude or ChatGPT to continue."}`;
|
|
51
67
|
}
|
|
52
68
|
/** The arguments osascript gets: the fixed script, then the text as its only argument. */
|
|
@@ -2,6 +2,7 @@ import { completionRejectionDiagnostic } from '../adapters/completion-report.js'
|
|
|
2
2
|
import { publicationError } from '../publication/failure.js';
|
|
3
3
|
import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
|
|
4
4
|
import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
|
|
5
|
+
import { assertVerificationHostVersion } from '../verification/reviewed-runner-contract.js';
|
|
5
6
|
import { executionError, executionFailure, jobHeartbeatError } from '../safety/execution-failure.js';
|
|
6
7
|
import { isUnsupportedSupervisorCommand } from '../safety/unsupported-supervisor-command.js';
|
|
7
8
|
import { requireSecurityRequirements } from '../safety/security-requirements.js';
|
|
@@ -75,7 +76,9 @@ export class ExecutionCoordinator {
|
|
|
75
76
|
let localFixPublication;
|
|
76
77
|
const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
|
|
77
78
|
const isP1 = hasP1(job.taskPackage);
|
|
78
|
-
const
|
|
79
|
+
const envelope = job.taskPackage.verification;
|
|
80
|
+
const runnerBinding = envelope?.schema_version === 'local-runner-verification-v2' ? envelope.runner_binding : envelope;
|
|
81
|
+
const isP2 = isP1 && runnerBinding?.gateway_verification !== undefined;
|
|
79
82
|
let p1Result;
|
|
80
83
|
let p2Handoff;
|
|
81
84
|
const p1ReceiptIds = [];
|
|
@@ -92,6 +95,7 @@ export class ExecutionCoordinator {
|
|
|
92
95
|
assertP1Bindings(pkg);
|
|
93
96
|
if (!this.deps.p1Host)
|
|
94
97
|
throw Error('P1_HOST_REQUIRED');
|
|
98
|
+
assertVerificationHostVersion(pkg.verification, this.deps.p1Host.supportedVerificationSchemas);
|
|
95
99
|
this.deps.p1Host.assertEligible(job);
|
|
96
100
|
}
|
|
97
101
|
if (isP2 && !this.deps.gateway.stageGatewayVerification)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { publicationError } from './publication/failure.js';
|
|
2
2
|
import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
|
|
3
|
+
import { hasP1 } from './verification/p1-execution-host.js';
|
|
4
|
+
import { resolveRunnerVerification, REVIEWED_POLICY, REVIEWED_LOOP_TYPE } from './verification/reviewed-runner-contract.js';
|
|
3
5
|
import { parseClaudeSyncRelease } from "./safety/claude-release.js";
|
|
4
6
|
import { createHash } from "node:crypto";
|
|
5
7
|
import { v5 as uuidv5 } from "uuid";
|
|
@@ -88,7 +90,12 @@ export function shadowTaskPackage(contract) {
|
|
|
88
90
|
const payload = contract.contract_payload;
|
|
89
91
|
const execution = nested(payload, "agent_execution");
|
|
90
92
|
const brief = nested(execution, "implementation_brief");
|
|
91
|
-
const verification =
|
|
93
|
+
const verification = hasP1(payload) ? payload.verification : undefined;
|
|
94
|
+
if (hasP1(payload)) {
|
|
95
|
+
const resolved = resolveRunnerVerification(verification);
|
|
96
|
+
if (resolved.version === 2 && (nested(payload, 'policy.policy_key') !== REVIEWED_POLICY || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
|
|
97
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
98
|
+
}
|
|
92
99
|
const repositories = (payload.repositories ?? []).map((repository) => ({
|
|
93
100
|
repository_key: requiredString(repository.repository_key, "repository key"),
|
|
94
101
|
access: repository.access === "write" ? "write" : "read",
|
|
@@ -414,7 +421,7 @@ export class McpGatewayClient {
|
|
|
414
421
|
const fingerprint = requiredString(nested(context.bundle, "contract.content_fingerprint"), "contract fingerprint");
|
|
415
422
|
const result = await this.callTool("agent_runtime_event_append", {
|
|
416
423
|
binding_id: bindingId,
|
|
417
|
-
...(nested(context.bundle, 'contract.verification.mode')
|
|
424
|
+
...(['runner_observed_v1', 'runner_observed_reviewed_v1'].includes(String(nested(context.bundle, 'contract.verification.mode'))) || ['runner_observed_v1', 'runner_observed_reviewed_v1'].includes(String(nested(context.bundle, 'verification.mode'))) ? { job_fence: context.jobFence } : {}),
|
|
418
425
|
...(isTerminalEventType(event.eventType) ? { lease_token: context.leaseToken } : {}),
|
|
419
426
|
event: {
|
|
420
427
|
protocol_version: event.protocolVersion,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { protocolHash } from '../publication/candidate.js';
|
|
3
|
+
import { resolveRunnerVerification, REVIEWED_VERIFICATION_SCHEMA } from '../verification/reviewed-runner-contract.js';
|
|
4
|
+
import { assertP1Bindings } from '../verification/p1-execution-host.js';
|
|
3
5
|
function object(value) {
|
|
4
6
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
5
7
|
throw new Error('Invalid Job policy');
|
|
@@ -48,15 +50,23 @@ export function jobToolPolicyOptions(worktreeRoot, pkg, approved, registry, runt
|
|
|
48
50
|
}
|
|
49
51
|
if (allowedPaths.length === 0)
|
|
50
52
|
throw new Error('Job approved source scope has no intersection');
|
|
51
|
-
const
|
|
53
|
+
const frozenVerification = object(object(pkg.contract).verification);
|
|
54
|
+
const reviewed = frozenVerification.schema_version === REVIEWED_VERIFICATION_SCHEMA;
|
|
55
|
+
if (reviewed && approved.verification === undefined)
|
|
56
|
+
throw new Error('Reviewed Job verification approval is missing');
|
|
57
|
+
const commandBinding = reviewed ? resolveRunnerVerification(frozenVerification).runnerBinding : frozenVerification;
|
|
58
|
+
const commands = strings(reviewed ? (commandBinding.required_commands ?? []) : commandBinding.required_commands);
|
|
52
59
|
const approvedCommands = strings(approved.required_commands);
|
|
53
60
|
if (commands.length !== approvedCommands.length || JSON.stringify([...commands].sort()) !== JSON.stringify([...approvedCommands].sort()))
|
|
54
61
|
throw new Error('Job required commands differ from approval');
|
|
55
62
|
if (approved.verification !== undefined) {
|
|
56
|
-
const
|
|
63
|
+
const envelope = object(approved.verification);
|
|
64
|
+
if (reviewed || envelope.schema_version === REVIEWED_VERIFICATION_SCHEMA)
|
|
65
|
+
assertP1Bindings(approved, pkg, true);
|
|
66
|
+
const verification = envelope.schema_version === REVIEWED_VERIFICATION_SCHEMA ? resolveRunnerVerification(envelope).runnerBinding : envelope;
|
|
57
67
|
if (runtime.runtimeAdapter !== 'codex_cli' || runtime.runtimeAdapterVersion !== '1' || approved.runtime_transport !== 'official_exec_v1' || verification.runtime_adapter !== runtime.runtimeAdapter || verification.runtime_transport !== approved.runtime_transport || verification.policy_key !== 'local-runner-verified-v1' || protocolHash(repo.blocked_paths) !== protocolHash(source.blocked_paths))
|
|
58
68
|
throw new Error('P1 Job runtime or scope differs from approval');
|
|
59
|
-
if (approved.local_fix_scope !== undefined || verification.mode !== 'runner_observed_v1' || verification.schema_version !== 'local-runner-verification-v1' || pkg.verification !== undefined && protocolHash(
|
|
69
|
+
if (approved.local_fix_scope !== undefined || verification.mode !== 'runner_observed_v1' || verification.schema_version !== 'local-runner-verification-v1' || pkg.verification !== undefined && protocolHash(envelope) !== protocolHash(pkg.verification) || protocolHash(envelope) !== protocolHash(frozenVerification) || protocolHash(repo.allowed_paths) !== protocolHash(approved.allowed_paths) || protocolHash(verification.allowed_paths) !== protocolHash(approved.allowed_paths))
|
|
60
70
|
throw new Error('P1 Job verification differs from approval');
|
|
61
71
|
return { worktreeRoot, access: repo.access === 'write' && source.access === 'write' ? 'write' : 'read', allowedPaths: strings(approved.allowed_paths), blockedPaths: strings(repo.blocked_paths), requiredCommands: [] };
|
|
62
72
|
}
|
|
@@ -1,38 +1,47 @@
|
|
|
1
1
|
import { RunnerObservedVerifier, assertGatewayVerificationScope } from './runner-observed-verifier.js';
|
|
2
2
|
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
3
3
|
import { protocolHash } from '../publication/candidate.js';
|
|
4
|
+
import { resolveRunnerVerification, assertVerificationHostVersion, REVIEWED_POLICY, REVIEWED_LOOP_TYPE } from './reviewed-runner-contract.js';
|
|
4
5
|
export class RunnerObservedExecutionHost {
|
|
5
6
|
privateRoot;
|
|
7
|
+
supportedVerificationSchemas;
|
|
6
8
|
registration;
|
|
7
9
|
constructor(privateRoot, registration) {
|
|
8
10
|
this.privateRoot = privateRoot;
|
|
9
11
|
this.registration = structuredClone(registration);
|
|
12
|
+
this.supportedVerificationSchemas = Object.freeze([...(registration.supportedVerificationSchemas ?? ['local-runner-verification-v1'])]);
|
|
10
13
|
}
|
|
11
14
|
options(input) {
|
|
12
15
|
const p = input.job.taskPackage, r = p.repository;
|
|
13
|
-
return { root: input.checkoutPath, privateRoot: this.privateRoot, registration: this.registration, verification: p.verification,
|
|
16
|
+
return { root: input.checkoutPath, privateRoot: this.privateRoot, registration: this.registration, verification: resolveRunnerVerification(p.verification).runnerBinding,
|
|
14
17
|
fence: { binding_id: input.bindingId, job_id: input.job.id, generation: input.job.generation, contract_fingerprint: p.contract_fingerprint, base_commit_sha: r.base_commit_sha, working_branch: r.working_branch },
|
|
15
18
|
allowedPaths: p.allowed_paths, blockedPaths: r.blocked_paths, candidateLimits: p.candidate_limits,
|
|
16
19
|
current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal, exportCandidate: input.exportCandidate };
|
|
17
20
|
}
|
|
18
21
|
assertEligible(job) {
|
|
22
|
+
assertVerificationHostVersion(job.taskPackage.verification, this.supportedVerificationSchemas);
|
|
19
23
|
const r = this.registration;
|
|
20
24
|
if (r.manifest.schema !== 'local-project-verification-v1' || !r.review.receiptId?.trim() || !r.review.reviewer?.trim() || r.review.manifestFingerprint !== protocolHash(r.manifest))
|
|
21
25
|
throw Error('P1_REGISTRATION_REJECTED');
|
|
22
26
|
new RunnerObservedVerifier(this.options({ job, checkoutPath: '', bindingId: 'preflight', signal: new AbortController().signal, current: async () => false, freezeOwnedModel: async () => { throw Error('P1_NOT_FROZEN'); }, assertModelFrozen: async () => { throw Error('P1_NOT_FROZEN'); } })).assertContract();
|
|
23
27
|
}
|
|
24
|
-
verify(input) { return new RunnerObservedVerifier(this.options(input)).verify(); }
|
|
28
|
+
verify(input) { assertVerificationHostVersion(input.job.taskPackage.verification, this.supportedVerificationSchemas); return new RunnerObservedVerifier(this.options(input)).verify(); }
|
|
25
29
|
}
|
|
26
30
|
const record = (v) => { if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
27
31
|
throw Error('P1_CONTRACT_REJECTED'); return v; };
|
|
28
32
|
const equal = (a, b) => a !== undefined && b !== undefined && protocolHash(a) === protocolHash(b);
|
|
29
33
|
export const hasP1 = (p) => {
|
|
30
34
|
const v = p.verification, policy = p.policy;
|
|
31
|
-
return
|
|
35
|
+
return ['local-runner-verified-v1', REVIEWED_POLICY].includes(String(policy?.policy_key)) || !!v && (v.schema_version !== undefined || v.mode !== undefined);
|
|
32
36
|
};
|
|
33
37
|
/** Compare host launch claims to authoritative WEC/bundle fields before start. */
|
|
34
38
|
export function assertP1Bindings(p, canonical, bundle = false) {
|
|
35
|
-
const
|
|
39
|
+
const envelope = record(p.verification), resolved = resolveRunnerVerification(envelope), v = resolved.runnerBinding, r = record(p.repository), limits = record(p.candidate_limits);
|
|
40
|
+
if (resolved.version === 2) {
|
|
41
|
+
const review = record(resolved.completionBinding.review_requirement), profiles = review.permitted_profiles;
|
|
42
|
+
if (typeof p.model_profile !== 'string' || !p.model_profile || !profiles.some(profile => profile.runtime_adapter !== p.runtime_adapter && profile.model_profile !== p.model_profile))
|
|
43
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
44
|
+
}
|
|
36
45
|
if (v.gateway_verification !== undefined)
|
|
37
46
|
assertGatewayVerificationScope(v.gateway_verification, r.key);
|
|
38
47
|
if (p.local_fix_scope !== undefined || p.verification_mode === 'gateway_ci_v1' || p.runtime_adapter !== 'codex_cli' || p.runtime_adapter_version !== '1' || p.runtime_transport !== 'official_exec_v1' || v.schema_version !== 'local-runner-verification-v1' || v.policy_key !== 'local-runner-verified-v1' || v.mode !== 'runner_observed_v1' || v.runtime_adapter !== p.runtime_adapter || v.runtime_transport !== p.runtime_transport || r.access !== 'write' || !isP1WorkingBranch(r.working_branch) || !equal(p.allowed_paths, v.allowed_paths) || !equal(r.allowed_paths, v.allowed_paths) || !Array.isArray(r.blocked_paths))
|
|
@@ -46,7 +55,7 @@ export function assertP1Bindings(p, canonical, bundle = false) {
|
|
|
46
55
|
throw Error('P1_CONTRACT_REJECTED');
|
|
47
56
|
if (!bundle) {
|
|
48
57
|
const policy = record(canonical.policy);
|
|
49
|
-
if (policy.policy_key !== 'local-runner-verified-v1' || policy.loop_type !== 'agent_runtime_implementation')
|
|
58
|
+
if (policy.policy_key !== (resolved.version === 2 ? REVIEWED_POLICY : 'local-runner-verified-v1') || policy.loop_type !== (resolved.version === 2 ? REVIEWED_LOOP_TYPE : 'agent_runtime_implementation'))
|
|
50
59
|
throw Error('P1_CONTRACT_REJECTED');
|
|
51
60
|
}
|
|
52
61
|
const repos = canonical.repositories;
|
|
@@ -54,9 +63,9 @@ export function assertP1Bindings(p, canonical, bundle = false) {
|
|
|
54
63
|
throw Error('P1_CONTRACT_REJECTED');
|
|
55
64
|
const repo = record(repos[0]);
|
|
56
65
|
const bound = bundle ? record(canonical.contract) : canonical;
|
|
57
|
-
if (!equal(
|
|
66
|
+
if (!equal(envelope, bound.verification) || !equal(limits, record(bound.policy).candidate_limits) || repo.repository_key !== r.key || repo.access !== r.access || repo.base_commit_sha !== r.base_commit_sha || repo.working_branch !== r.working_branch || !equal(repo.allowed_paths, r.allowed_paths) || !equal(repo.blocked_paths, r.blocked_paths))
|
|
58
67
|
throw Error('P1_CONTRACT_REJECTED');
|
|
59
|
-
if (bundle && (bound.content_fingerprint !== p.contract_fingerprint || canonical.verification !== undefined && !equal(
|
|
68
|
+
if (bundle && (bound.content_fingerprint !== p.contract_fingerprint || canonical.verification !== undefined && !equal(envelope, canonical.verification) || canonical.candidate_limits !== undefined && !equal(limits, canonical.candidate_limits)))
|
|
60
69
|
throw Error('P1_CONTRACT_REJECTED');
|
|
61
70
|
}
|
|
62
71
|
/** P1 model events carry narrative/progress only. Proof is injected afterwards. */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { protocolHash, protocolJson } from '../publication/candidate.js';
|
|
2
|
+
import { assertGatewayVerificationScope } from './runner-observed-verifier.js';
|
|
3
|
+
export const REVIEWED_VERIFICATION_SCHEMA = 'local-runner-verification-v2';
|
|
4
|
+
export const REVIEWED_POLICY = 'local-runner-reviewed-v1';
|
|
5
|
+
export const REVIEWED_LOOP_TYPE = 'agent_runtime_reviewed_implementation';
|
|
6
|
+
const MAX_CONTRACT_BYTES = 65536;
|
|
7
|
+
const MAX_EVIDENCE_AGE_SECONDS = 86400;
|
|
8
|
+
const MAX_REVIEWER_PROFILES = 100;
|
|
9
|
+
const rejected = () => Error('REVIEWED_RUNNER_VERIFICATION_REJECTED');
|
|
10
|
+
const same = (a, b) => a !== undefined && b !== undefined && protocolHash(a) === protocolHash(b);
|
|
11
|
+
const object = (value) => {
|
|
12
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
13
|
+
throw rejected();
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
function exact(value, keys) {
|
|
17
|
+
if (Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key)))
|
|
18
|
+
throw rejected();
|
|
19
|
+
}
|
|
20
|
+
export const REVIEWED_CRITERIA = {
|
|
21
|
+
targeted_tests: { minimum_trust: 'client_reported', producers: ['runtime_event'], verifiers: ['runner_command_receipt'] },
|
|
22
|
+
required_suite: { minimum_trust: 'gateway_verified', producers: ['gateway_verification'], verifiers: ['ci_pipeline_result'] },
|
|
23
|
+
diff_scope: { minimum_trust: 'gateway_verified', producers: ['gateway_verification'], verifiers: ['verification_ref_diffstat'] },
|
|
24
|
+
independent_review: { minimum_trust: 'client_reported', producers: ['independent_agent_review'], verifiers: ['candidate_bound_review'] },
|
|
25
|
+
};
|
|
26
|
+
/** Only unwraps the versioned envelope. The existing P1 host still validates every inner byte. */
|
|
27
|
+
export function resolveRunnerVerification(value) {
|
|
28
|
+
const envelope = object(value);
|
|
29
|
+
if (envelope.schema_version === 'local-runner-verification-v1') {
|
|
30
|
+
if (envelope.mode !== 'runner_observed_v1' || envelope.policy_key !== 'local-runner-verified-v1')
|
|
31
|
+
throw rejected();
|
|
32
|
+
return { version: 1, runnerBinding: envelope, completionBinding: null };
|
|
33
|
+
}
|
|
34
|
+
exact(envelope, ['schema_version', 'mode', 'policy_key', 'runner_binding', 'completion_binding']);
|
|
35
|
+
if (envelope.schema_version !== REVIEWED_VERIFICATION_SCHEMA || envelope.mode !== 'runner_observed_reviewed_v1' || envelope.policy_key !== REVIEWED_POLICY)
|
|
36
|
+
throw rejected();
|
|
37
|
+
const inner = object(envelope.runner_binding), completion = object(envelope.completion_binding);
|
|
38
|
+
if (inner.schema_version !== 'local-runner-verification-v1' || inner.mode !== 'runner_observed_v1' || inner.policy_key !== 'local-runner-verified-v1')
|
|
39
|
+
throw rejected();
|
|
40
|
+
assertGatewayVerificationScope(inner.gateway_verification);
|
|
41
|
+
exact(completion, ['schema_version', 'frozen_loop_contract', 'completion_criteria_fingerprint', 'runner_criteria_fingerprint', 'review_subject_schema', 'review_requirement', 'execute_attempt_limit']);
|
|
42
|
+
const frozen = object(completion.frozen_loop_contract);
|
|
43
|
+
exact(frozen, ['loop_type', 'policy_key', 'mandatory_criteria', 'criteria', 'max_evidence_age_seconds']);
|
|
44
|
+
if (completion.schema_version !== 'loop-reviewed-completion-v1' || completion.review_subject_schema !== 'loop-candidate-review-subject-v2' || completion.execute_attempt_limit !== 1
|
|
45
|
+
|| completion.runner_criteria_fingerprint !== inner.frozen_criteria_fingerprint || completion.completion_criteria_fingerprint !== protocolHash(frozen)
|
|
46
|
+
|| frozen.loop_type !== REVIEWED_LOOP_TYPE || frozen.policy_key !== REVIEWED_POLICY || !same(frozen.mandatory_criteria, Object.keys(REVIEWED_CRITERIA)) || !same(frozen.criteria, REVIEWED_CRITERIA)
|
|
47
|
+
|| !Number.isSafeInteger(frozen.max_evidence_age_seconds) || Number(frozen.max_evidence_age_seconds) < 1 || Number(frozen.max_evidence_age_seconds) > MAX_EVIDENCE_AGE_SECONDS)
|
|
48
|
+
throw rejected();
|
|
49
|
+
const review = object(completion.review_requirement);
|
|
50
|
+
exact(review, ['option_key', 'role', 'capabilities', 'distinct_fields', 'same_actor_allowed', 'minimum_trust', 'permitted_profiles']);
|
|
51
|
+
if (review.option_key !== 'separated_runtime_review' || review.role !== 'reviewer' || !same(review.capabilities, ['repository_read', 'git_diff'])
|
|
52
|
+
|| !same(review.distinct_fields, ['runtime_adapter', 'model_profile', 'session_reference']) || review.same_actor_allowed !== true || review.minimum_trust !== 'client_reported'
|
|
53
|
+
|| !Array.isArray(review.permitted_profiles) || review.permitted_profiles.length < 1 || review.permitted_profiles.length > MAX_REVIEWER_PROFILES)
|
|
54
|
+
throw rejected();
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const item of review.permitted_profiles) {
|
|
57
|
+
const profile = object(item);
|
|
58
|
+
exact(profile, ['runtime_adapter', 'model_profile', 'registration_fingerprint']);
|
|
59
|
+
for (const field of ['runtime_adapter', 'model_profile'])
|
|
60
|
+
if (typeof profile[field] !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(profile[field]))
|
|
61
|
+
throw rejected();
|
|
62
|
+
const key = profile.runtime_adapter + ':' + profile.model_profile;
|
|
63
|
+
if (seen.has(key) || typeof profile.registration_fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(profile.registration_fingerprint))
|
|
64
|
+
throw rejected();
|
|
65
|
+
seen.add(key);
|
|
66
|
+
}
|
|
67
|
+
const bound = object(inner.bounds).max_contract_bytes;
|
|
68
|
+
if (!Number.isSafeInteger(bound) || Number(bound) < 1 || Number(bound) > MAX_CONTRACT_BYTES || Buffer.byteLength(protocolJson(envelope)) > Number(bound))
|
|
69
|
+
throw rejected();
|
|
70
|
+
return { version: 2, runnerBinding: inner, completionBinding: completion };
|
|
71
|
+
}
|
|
72
|
+
/** Future reviewed host implementations must explicitly advertise V2 support. */
|
|
73
|
+
export function assertVerificationHostVersion(value, supportedSchemas) {
|
|
74
|
+
const resolved = resolveRunnerVerification(value);
|
|
75
|
+
if (resolved.version === 2 && !supportedSchemas?.includes(REVIEWED_VERIFICATION_SCHEMA))
|
|
76
|
+
throw Error('REVIEWED_VERIFICATION_HOST_REQUIRED');
|
|
77
|
+
}
|