mtech-ai-local-runner 0.1.30 → 0.1.31
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/dist/src/adapters/codex/adapter.js +15 -0
- package/dist/src/adapters/codex/exec-launch-host.js +22 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/config.js +4 -4
- package/dist/src/daemon/execution-coordinator.js +62 -2
- package/dist/src/daemon/runtime.js +18 -2
- package/dist/src/gateway-client.js +67 -0
- package/dist/src/platform/macos/launchd.js +13 -2
- package/dist/src/process/node-process-group-host.js +36 -1
- package/dist/src/publication/candidate.js +95 -0
- package/dist/src/publication/execution-host.js +55 -0
- package/dist/src/publication/git-credential-broker.js +150 -0
- package/dist/src/publication/git-credential-helper.js +29 -0
- package/dist/src/publication/git-snapshot.js +321 -0
- package/dist/src/publication/git-transport.js +156 -0
- package/dist/src/publication/installed-host.js +90 -0
- package/dist/src/publication/isolated-verifier.js +67 -0
- package/dist/src/publication/journal.js +87 -0
- package/dist/src/publication/production-host.js +151 -0
- package/dist/src/publication/project-registration.js +49 -0
- package/dist/src/publication/publisher.js +166 -0
- package/dist/src/publication/recovery.js +53 -0
- package/dist/src/publication/runtime-release.js +91 -0
- package/dist/src/safety/command-policy-core.js +124 -0
- package/dist/src/safety/command-policy.js +1 -124
- package/dist/src/safety/job-tool-policy.js +9 -0
- package/dist/src/safety/local-tool-policy.js +1 -1
- package/dist/src/workspace/workspace-manager.js +14 -0
- package/package.json +3 -2
- package/verification/README.md +63 -0
- package/verification/runner-policy.cjs +24 -0
|
@@ -185,6 +185,21 @@ export class CodexAdapter {
|
|
|
185
185
|
await this.cancel();
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
+
async freezeForPublication() {
|
|
189
|
+
if (!this.usingOfficialExec || !this.ownedExec)
|
|
190
|
+
throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
|
|
191
|
+
let timer;
|
|
192
|
+
try {
|
|
193
|
+
await Promise.race([Promise.all([this.ownedExec.freezeForPublication(), this.cancel()]), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED')), 10000); })]);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
if (timer)
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
188
203
|
async cancel() {
|
|
189
204
|
this.cancelled = true;
|
|
190
205
|
try {
|
|
@@ -246,6 +246,28 @@ export class CodexExecLaunchHost {
|
|
|
246
246
|
}
|
|
247
247
|
return this.readAudit ? structuredClone(this.readAudit) : undefined;
|
|
248
248
|
}
|
|
249
|
+
/** A kill request is not an exit proof. Publication alone requires the trusted
|
|
250
|
+
* process-group host to observe real exit, closed pipes and group disappearance.
|
|
251
|
+
* This deadline bounds teardown only, never model execution duration. */
|
|
252
|
+
async freezeForPublication() {
|
|
253
|
+
let timer;
|
|
254
|
+
try {
|
|
255
|
+
if (!this.ownsAttempt || !this.handle?.waitForOwnedExit)
|
|
256
|
+
throw fail();
|
|
257
|
+
const handle = this.handle;
|
|
258
|
+
await Promise.race([
|
|
259
|
+
Promise.all([this.cancel(), handle.waitForOwnedExit(10000)]),
|
|
260
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(fail()), 10000); }),
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
if (timer)
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
249
271
|
cancel() {
|
|
250
272
|
if (this.closePromise)
|
|
251
273
|
return this.closePromise;
|
package/dist/src/cli.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { recoverLocalFixPublication } from './publication/recovery.js';
|
|
3
|
+
import { loadInstalledLocalFixHost } from './publication/installed-host.js';
|
|
4
|
+
import { FileDaemonStateStore } from './state-store.js';
|
|
5
|
+
import { ProcessLock } from './daemon/process-lock.js';
|
|
2
6
|
import { runManualCodexJob } from './daemon/manual-codex.js';
|
|
3
7
|
import { unlinkSync, realpathSync } from "node:fs";
|
|
4
8
|
import { join } from "node:path";
|
|
@@ -228,6 +232,25 @@ export async function main(argv, env, io = {}) {
|
|
|
228
232
|
const credentialStore = new FileOAuthCredentialStore(config.stateRoot);
|
|
229
233
|
const oauthClient = new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn });
|
|
230
234
|
const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: local.gatewayMcpUrl, tokenOverride: env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, allowRefresh: config.mode !== 'manual-codex', clock: () => Date.now() });
|
|
235
|
+
if (config.mode === 'publication-recover') {
|
|
236
|
+
const lock = new ProcessLock(join(config.stateRoot, 'daemon.lock'));
|
|
237
|
+
await lock.acquire();
|
|
238
|
+
try {
|
|
239
|
+
const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, runnerId: local.runnerId, tokenProvider, fetchFn });
|
|
240
|
+
const host = await loadInstalledLocalFixHost({ stateRoot: config.stateRoot, runnerId: local.runnerId, gateway });
|
|
241
|
+
if (!host)
|
|
242
|
+
throw new Error('LOCAL_FIX_INSTALLATION_REQUIRED');
|
|
243
|
+
const state = new FileDaemonStateStore(config.stateRoot), stored = await state.load();
|
|
244
|
+
if (!stored)
|
|
245
|
+
throw new Error('LOCAL_PUBLICATION_RECOVERY_STATE_REQUIRED');
|
|
246
|
+
await recoverLocalFixPublication({ jobId: config.manualJobId, stored: stored, host, gateway, state });
|
|
247
|
+
stdout(JSON.stringify({ status: 'completed', jobId: config.manualJobId, modelStarted: false }));
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await lock.release();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
231
254
|
if (config.mode === "doctor") {
|
|
232
255
|
const adapters = doctorAdapters;
|
|
233
256
|
const localChecks = Promise.allSettled([
|
package/dist/src/config.js
CHANGED
|
@@ -6,11 +6,11 @@ export function isExecutionConfig(config) {
|
|
|
6
6
|
return config.mode === "shadow" || config.mode === "once";
|
|
7
7
|
}
|
|
8
8
|
export function isServiceConfig(config) {
|
|
9
|
-
return ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex"].includes(config.mode);
|
|
9
|
+
return ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex", "publication-recover"].includes(config.mode);
|
|
10
10
|
}
|
|
11
11
|
const EXECUTION_MODES = ["shadow", "once"];
|
|
12
12
|
const AUTH_MODES = ["login", "logout"];
|
|
13
|
-
const SERVICE_MODES = ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex"];
|
|
13
|
+
const SERVICE_MODES = ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex", "publication-recover"];
|
|
14
14
|
const ALL_MODES = [...EXECUTION_MODES, ...AUTH_MODES, ...SERVICE_MODES];
|
|
15
15
|
export const DEFAULT_GATEWAY_MCP_URL = "https://cloud.dev.service.ai-gateway.laravelvapor.org/api/v1/web/ai-gateway/mcp";
|
|
16
16
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -101,9 +101,9 @@ export function loadConfig(argv, env) {
|
|
|
101
101
|
const stateRoot = defaultStateRootFromEnv(env);
|
|
102
102
|
if (SERVICE_MODES.includes(mode)) {
|
|
103
103
|
let manualJobId;
|
|
104
|
-
if (mode === 'manual-codex') {
|
|
104
|
+
if (mode === 'manual-codex' || mode === 'publication-recover') {
|
|
105
105
|
if (rest.length !== 1 || !rest[0].startsWith('--job-id=') || !UUID_PATTERN.test(rest[0].slice(9)))
|
|
106
|
-
throw new Error(
|
|
106
|
+
throw new Error(`${mode} requires exactly --job-id=<uuid>`);
|
|
107
107
|
manualJobId = rest[0].slice(9).toLowerCase();
|
|
108
108
|
}
|
|
109
109
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
|
|
1
2
|
import { executionError, executionFailure } from '../safety/execution-failure.js';
|
|
2
3
|
import { requireSecurityRequirements } from '../safety/security-requirements.js';
|
|
3
4
|
import { GatewayToolError } from "../gateway-client.js";
|
|
@@ -42,6 +43,8 @@ function immutable(value) {
|
|
|
42
43
|
}
|
|
43
44
|
class PreparationDeadlineError extends Error {
|
|
44
45
|
}
|
|
46
|
+
class LocalFixHostRequired extends Error {
|
|
47
|
+
}
|
|
45
48
|
export class ExecutionCoordinator {
|
|
46
49
|
deps;
|
|
47
50
|
constructor(deps) {
|
|
@@ -53,6 +56,10 @@ export class ExecutionCoordinator {
|
|
|
53
56
|
let workspace;
|
|
54
57
|
let adapter;
|
|
55
58
|
let binding;
|
|
59
|
+
let fixSession;
|
|
60
|
+
let localFixPublication;
|
|
61
|
+
const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
|
|
62
|
+
const lifecycle = { frozen: false, current: true };
|
|
56
63
|
const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
|
|
57
64
|
const step = async (operation) => {
|
|
58
65
|
const remaining = deadline - Date.now();
|
|
@@ -71,6 +78,8 @@ export class ExecutionCoordinator {
|
|
|
71
78
|
};
|
|
72
79
|
try {
|
|
73
80
|
const pkg = job.taskPackage;
|
|
81
|
+
if (isLocalFix && (!this.deps.localFixHost || pkg.repository?.access !== "write"))
|
|
82
|
+
throw new LocalFixHostRequired();
|
|
74
83
|
const repository = pkg.repository;
|
|
75
84
|
const adapterId = text(pkg.runtime_adapter, "runtime adapter");
|
|
76
85
|
const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
|
|
@@ -85,6 +94,7 @@ export class ExecutionCoordinator {
|
|
|
85
94
|
repositoryKey: text(repository?.key, "repository key"),
|
|
86
95
|
providerUrl: text(repository?.provider_url, "repository provider URL"),
|
|
87
96
|
baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
|
|
97
|
+
...(isLocalFix ? { workingBranch: text(repository?.working_branch, "approved fix branch") } : {})
|
|
88
98
|
}));
|
|
89
99
|
stage = "adapter";
|
|
90
100
|
adapter = await step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }));
|
|
@@ -98,9 +108,13 @@ export class ExecutionCoordinator {
|
|
|
98
108
|
stage = "binding";
|
|
99
109
|
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "binding" });
|
|
100
110
|
binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }));
|
|
111
|
+
if (isLocalFix)
|
|
112
|
+
fixSession = await step(() => this.deps.localFixHost.prepare({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, lifecycle: { isFrozen: () => lifecycle.frozen, isCurrent: () => lifecycle.current, securityReceipt: () => adapter.securityReceipt?.() } }));
|
|
101
113
|
}
|
|
102
114
|
catch (error) {
|
|
103
115
|
const diagnostic = { stage, code: "PREPARATION_FAILED" };
|
|
116
|
+
if (error instanceof LocalFixHostRequired)
|
|
117
|
+
diagnostic.code = "LOCAL_FIX_HOST_REQUIRED";
|
|
104
118
|
if (error instanceof PreparationDeadlineError)
|
|
105
119
|
diagnostic.code = "PREPARATION_DEADLINE_EXCEEDED";
|
|
106
120
|
if (error instanceof GatewayToolError) {
|
|
@@ -141,6 +155,7 @@ export class ExecutionCoordinator {
|
|
|
141
155
|
if (controlsStopped)
|
|
142
156
|
return;
|
|
143
157
|
if (["cancel", "pause", "request_replan"].includes(command.command)) {
|
|
158
|
+
lifecycle.current = false;
|
|
144
159
|
requestedTerminal = { eventType: command.command === "cancel" ? "cancelled" : "blocked", summary: `supervisor ${command.command} acknowledged` };
|
|
145
160
|
await adapter.cancel();
|
|
146
161
|
}
|
|
@@ -167,6 +182,7 @@ export class ExecutionCoordinator {
|
|
|
167
182
|
};
|
|
168
183
|
const heartbeatTimer = this.deps.gateway.heartbeat || this.deps.gateway.readCommands ? setInterval(() => {
|
|
169
184
|
void (async () => { await this.deps.gateway.heartbeat?.(binding.bindingId, job); await readControls(); })().catch(async (error) => {
|
|
185
|
+
lifecycle.current = false;
|
|
170
186
|
heartbeatFailure = executionError(error, 'heartbeat', 'EXECUTION_FAILED');
|
|
171
187
|
await adapter.cancel().catch(() => undefined);
|
|
172
188
|
});
|
|
@@ -181,14 +197,17 @@ export class ExecutionCoordinator {
|
|
|
181
197
|
let payload = event.payload;
|
|
182
198
|
if (isTerminalEventType(event.eventType)) {
|
|
183
199
|
executionStage = 'terminal_evidence';
|
|
184
|
-
const evidence = this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha });
|
|
200
|
+
const evidence = localFixPublication && typeof localFixPublication.diff_hash === 'string' && typeof localFixPublication.changed_file_manifest_hash === 'string' ? { diffHash: localFixPublication.diff_hash, changedFileManifestHash: localFixPublication.changed_file_manifest_hash } : this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha });
|
|
185
201
|
payload = { ...payload, diff_hash: evidence.diffHash, changed_file_manifest_hash: evidence.changedFileManifestHash };
|
|
186
202
|
}
|
|
187
203
|
// A vendor report cannot supply host observations, even when its shape looks valid.
|
|
188
204
|
if (payload) {
|
|
189
205
|
payload = { ...payload };
|
|
190
206
|
delete payload.local_read_observations;
|
|
207
|
+
delete payload.local_fix_publication;
|
|
191
208
|
}
|
|
209
|
+
if (event.eventType === "completed" && localFixPublication)
|
|
210
|
+
payload = { ...payload, local_fix_publication: structuredClone(localFixPublication) };
|
|
192
211
|
if (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict') {
|
|
193
212
|
const reads = safeLocalReadObservations(adapter.localReadObservations?.());
|
|
194
213
|
if (reads)
|
|
@@ -236,6 +255,40 @@ export class ExecutionCoordinator {
|
|
|
236
255
|
await controlsInFlight;
|
|
237
256
|
if (requestedTerminal)
|
|
238
257
|
break;
|
|
258
|
+
if (event.eventType === "completed" && fixSession) {
|
|
259
|
+
// Stop only this owned model before running verified host publication.
|
|
260
|
+
// Controls/heartbeat remain active throughout the publication phase.
|
|
261
|
+
if (this.deps.localFixHost?.requiresOwnedFreeze) {
|
|
262
|
+
if (!adapter.freezeForPublication)
|
|
263
|
+
throw new Error('LOCAL_FIX_OWNED_FREEZE_UNAVAILABLE');
|
|
264
|
+
await adapter.freezeForPublication();
|
|
265
|
+
}
|
|
266
|
+
else
|
|
267
|
+
await adapter.cancel();
|
|
268
|
+
lifecycle.frozen = true;
|
|
269
|
+
await readControls();
|
|
270
|
+
if (requestedTerminal || heartbeatFailure)
|
|
271
|
+
break;
|
|
272
|
+
executionStage = 'local_fix_publication';
|
|
273
|
+
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: 'publication_pending', bindingId: binding.bindingId, eventCursor: sequence, workspaceManifest: workspace.manifest, publicationRecovery: { version: 1, job, checkoutPath: workspace.path, bindingId: binding.bindingId, completedEvent: event, gatewayContext: this.deps.gateway.publicationRecoveryContext?.(binding.bindingId), securityReceipt: adapter.securityReceipt?.(), freezeConfirmed: lifecycle.frozen } });
|
|
274
|
+
localFixPublication = await fixSession.finalize();
|
|
275
|
+
if (this.deps.gateway.syncPublicationCursor) {
|
|
276
|
+
try {
|
|
277
|
+
const cursor = await this.deps.gateway.syncPublicationCursor(binding.bindingId, sequence);
|
|
278
|
+
if (!Number.isSafeInteger(cursor) || cursor < sequence)
|
|
279
|
+
throw new Error('Invalid event cursor');
|
|
280
|
+
sequence = cursor;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
throw new PublicationOutcomeUnknownError();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (controlsInFlight)
|
|
287
|
+
await controlsInFlight;
|
|
288
|
+
if (requestedTerminal || heartbeatFailure)
|
|
289
|
+
break;
|
|
290
|
+
executionStage = 'event_stream';
|
|
291
|
+
}
|
|
239
292
|
controlsStopped = true;
|
|
240
293
|
}
|
|
241
294
|
await append(event);
|
|
@@ -261,6 +314,11 @@ export class ExecutionCoordinator {
|
|
|
261
314
|
}
|
|
262
315
|
}
|
|
263
316
|
catch (error) {
|
|
317
|
+
if (error instanceof PublicationOutcomeUnknownError) {
|
|
318
|
+
// The provider may already have committed/pushed/opened a PR. Do not
|
|
319
|
+
// release this run as failed or let restart begin another model session.
|
|
320
|
+
throw new Error('Local publication outcome requires reconciliation');
|
|
321
|
+
}
|
|
264
322
|
await adapter.cancel().catch(() => undefined);
|
|
265
323
|
// Do not complete a job whose terminal evidence was not acknowledged.
|
|
266
324
|
// A safe bounded reason prevents raw adapter/transport errors leaking.
|
|
@@ -278,6 +336,8 @@ export class ExecutionCoordinator {
|
|
|
278
336
|
controlsStopped = true;
|
|
279
337
|
if (heartbeatTimer)
|
|
280
338
|
clearInterval(heartbeatTimer);
|
|
339
|
+
lifecycle.current = false;
|
|
340
|
+
await fixSession?.close();
|
|
281
341
|
}
|
|
282
342
|
const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
|
|
283
343
|
const diagnostic = status === "failed" || terminal.eventType === "blocked" ? completionReportDiagnostic(terminal.payload?.completion_report) : undefined;
|
|
@@ -285,7 +345,7 @@ export class ExecutionCoordinator {
|
|
|
285
345
|
? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
|
|
286
346
|
const securityReceipt = adapter.securityReceipt?.();
|
|
287
347
|
const reads = safeLocalReadObservations(adapter.localReadObservations?.());
|
|
288
|
-
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, ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
|
|
348
|
+
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) } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
|
|
289
349
|
await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
|
|
290
350
|
phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
|
|
291
351
|
completionRequest: structuredClone(completionRequest) });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
|
|
1
2
|
import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
|
|
2
3
|
import { reconcileTerminalState } from './terminal-recovery.js';
|
|
3
4
|
import { packageVersion } from '../package-version.js';
|
|
@@ -26,6 +27,7 @@ export async function buildDaemon(input) {
|
|
|
26
27
|
const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ 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;
|
|
27
28
|
let pendingCodexClaim;
|
|
28
29
|
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
30
|
+
const localFixHost = input.localFixHost ?? await loadInstalledLocalFixHost({ stateRoot: input.stateRoot, runnerId: input.runnerId, gateway });
|
|
29
31
|
// The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
|
|
30
32
|
const hook = packagedHookCommandParts();
|
|
31
33
|
const plugin = new ClaudeCodePlugin({
|
|
@@ -45,6 +47,7 @@ export async function buildDaemon(input) {
|
|
|
45
47
|
const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
|
|
46
48
|
const state = new FileDaemonStateStore(input.stateRoot);
|
|
47
49
|
const coordinator = new ExecutionCoordinator({
|
|
50
|
+
localFixHost,
|
|
48
51
|
reportExecutionFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "execution_failed", ...diagnostic })}\n`); },
|
|
49
52
|
reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
|
|
50
53
|
terminalEvidenceCollector: buildTerminalEvidenceCollector(),
|
|
@@ -56,6 +59,8 @@ export async function buildDaemon(input) {
|
|
|
56
59
|
jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
|
|
57
60
|
launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
|
|
58
61
|
}),
|
|
62
|
+
publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
|
|
63
|
+
syncPublicationCursor: (bindingId, cursor) => gateway.syncLocalPublicationCursor(bindingId, cursor),
|
|
59
64
|
readCommands: (bindingId, cursor) => gateway.readCommands(bindingId, cursor),
|
|
60
65
|
acknowledgeCommand: (bindingId, sequence) => gateway.acknowledgeCommand(bindingId, sequence),
|
|
61
66
|
appendEvent: (bindingId, event) => gateway.appendEvent(bindingId, event),
|
|
@@ -102,8 +107,7 @@ export async function buildDaemon(input) {
|
|
|
102
107
|
await gateway.heartbeatLocalRunner({
|
|
103
108
|
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
104
109
|
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
105
|
-
capabilities: probes
|
|
106
|
-
capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
|
|
110
|
+
capabilities: localRunnerCapabilities(probes, !!localFixHost),
|
|
107
111
|
});
|
|
108
112
|
},
|
|
109
113
|
wait: async () => {
|
|
@@ -146,6 +150,8 @@ export async function buildDaemon(input) {
|
|
|
146
150
|
export function runnerDisplayName() { return `${hostname()} local AI`; }
|
|
147
151
|
/** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
|
|
148
152
|
export async function reconcilePreparationState(previous, deps) {
|
|
153
|
+
if (["publication_pending", "publication_terminal_pending"].includes(previous.phase ?? ""))
|
|
154
|
+
throw new Error("Publication outcome requires reconciliation before runner polling");
|
|
149
155
|
if (previous.phase === "binding" || previous.phase === "binding_uncertain") {
|
|
150
156
|
throw new Error("Binding outcome requires reconciliation before runner polling");
|
|
151
157
|
}
|
|
@@ -159,3 +165,13 @@ export async function reconcilePreparationState(previous, deps) {
|
|
|
159
165
|
await deps.clear();
|
|
160
166
|
return true;
|
|
161
167
|
}
|
|
168
|
+
/** Vendor capability text cannot claim a host-owned publication facility. */
|
|
169
|
+
export function localRunnerCapabilities(probes, trustedLocalFixHost = false) {
|
|
170
|
+
return probes.map(probe => {
|
|
171
|
+
const authenticated = probe.available && probe.authenticated;
|
|
172
|
+
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1');
|
|
173
|
+
if (trustedLocalFixHost && authenticated && probe.id === 'codex_cli')
|
|
174
|
+
capabilities.push('local_fix_publication_v1');
|
|
175
|
+
return { adapter: probe.id, version: probe.version, authenticated, capabilities };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -480,6 +480,73 @@ export class McpGatewayClient {
|
|
|
480
480
|
resolvedModelId: requiredString(result.binding.resolved_model_id, "resolved model ID"),
|
|
481
481
|
};
|
|
482
482
|
}
|
|
483
|
+
/** Account for the server-authored verification event before appending a
|
|
484
|
+
* terminal event. Never infer a cursor from model output or a local counter. */
|
|
485
|
+
async syncLocalPublicationCursor(bindingId, afterSequence) {
|
|
486
|
+
const page = await this.callTool('agent_runtime_events_read', {
|
|
487
|
+
binding_id: bindingId, after_sequence: afterSequence, limit: 100,
|
|
488
|
+
});
|
|
489
|
+
const events = page.events ?? [];
|
|
490
|
+
if (page.terminal || events.length === 0 || events.length >= 100)
|
|
491
|
+
throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
|
|
492
|
+
let cursor = afterSequence, verified = false;
|
|
493
|
+
for (const event of events) {
|
|
494
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence !== cursor + 1 || isTerminalEventType(String(event.event_type)))
|
|
495
|
+
throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
|
|
496
|
+
cursor = event.sequence;
|
|
497
|
+
const proof = event.payload?.local_publication;
|
|
498
|
+
if (event.event_type === 'test_result' && event.stage === 'local_publication_verification' && typeof proof?.gateway_mac === 'string' && /^[a-f0-9]{64}$/.test(proof.gateway_mac))
|
|
499
|
+
verified = true;
|
|
500
|
+
}
|
|
501
|
+
if (!verified)
|
|
502
|
+
throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
|
|
503
|
+
return cursor;
|
|
504
|
+
}
|
|
505
|
+
/** Host-only publication envelope. This client is never a model MCP relay. */
|
|
506
|
+
localPublicationFence(input) {
|
|
507
|
+
return { job_id: input.jobId, generation: input.generation, lease_token: input.leaseToken,
|
|
508
|
+
launch_grant: input.launchGrant, binding_id: input.bindingId, runner_id: this.runnerId };
|
|
509
|
+
}
|
|
510
|
+
exportLocalPublicationRecoveryContext(bindingId) {
|
|
511
|
+
const c = this.requireContext(bindingId);
|
|
512
|
+
return structuredClone({ ...c, bundle: { contract: { content_fingerprint: requiredString(nested(c.bundle, 'contract.content_fingerprint'), 'contract fingerprint') } } });
|
|
513
|
+
}
|
|
514
|
+
async restoreLocalPublicationRecoveryContext(input, stored, terminalReplay = false) {
|
|
515
|
+
if (terminalReplay) {
|
|
516
|
+
if (stored.bindingId !== input.bindingId || typeof stored.leaseToken !== 'string' || !stored.leaseToken)
|
|
517
|
+
throw new Error('LOCAL_PUBLICATION_RECOVERY_CONTEXT_REJECTED');
|
|
518
|
+
this.context = structuredClone(stored);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const current = (await this.localPublicationContext(input)).context;
|
|
522
|
+
if (stored.bindingId !== input.bindingId || stored.workLoopId !== current.work_loop_id || nested(stored.bundle, 'contract.content_fingerprint') !== current.contract_fingerprint || typeof stored.leaseToken !== 'string' || !stored.leaseToken)
|
|
523
|
+
throw new Error('LOCAL_PUBLICATION_RECOVERY_CONTEXT_REJECTED');
|
|
524
|
+
this.context = structuredClone(stored);
|
|
525
|
+
}
|
|
526
|
+
async localPublicationContext(input) {
|
|
527
|
+
return this.callTool('local_agent_publication_context', this.localPublicationFence(input));
|
|
528
|
+
}
|
|
529
|
+
async executeLocalPublicationPr(input) {
|
|
530
|
+
return this.callTool('local_agent_publication_execute_pr', { ...this.localPublicationFence(input), publication_id: input.publicationId, signed_request: input.signedRequest, ...(input.canonicalApprovalId ? { canonical_approval_id: input.canonicalApprovalId } : {}) });
|
|
531
|
+
}
|
|
532
|
+
async authorizeLocalPublication(input) {
|
|
533
|
+
const receipt = { ...input.securityReceipt };
|
|
534
|
+
if (receipt.transport === 'official_exec_v1' && ['cli-exec-job-security-receipt-v2', 'cli-exec-job-security-receipt-v3'].includes(String(receipt.receipt_version))) {
|
|
535
|
+
delete receipt.preparation_timing;
|
|
536
|
+
delete receipt.launch_process;
|
|
537
|
+
}
|
|
538
|
+
return this.callTool('local_agent_publication_authorize', { ...this.localPublicationFence(input),
|
|
539
|
+
candidate: input.candidate, test_receipts: input.testReceipts, security_receipt: receipt, host_receipt: input.hostReceipt });
|
|
540
|
+
}
|
|
541
|
+
async issueLocalPublicationAction(input) {
|
|
542
|
+
return this.callTool('local_agent_publication_issue_action', { ...this.localPublicationFence(input),
|
|
543
|
+
publication_id: input.publicationId, action: input.action, commit_message: input.commitMessage,
|
|
544
|
+
pull_request_title: input.pullRequestTitle, pull_request_body: input.pullRequestBody });
|
|
545
|
+
}
|
|
546
|
+
async acceptLocalPublicationResult(input) {
|
|
547
|
+
return this.callTool('local_agent_publication_accept_result', { ...this.localPublicationFence(input),
|
|
548
|
+
publication_id: input.publicationId, action: input.action, result: input.result });
|
|
549
|
+
}
|
|
483
550
|
async completeLocalJob(input) {
|
|
484
551
|
let result = input.result;
|
|
485
552
|
const receipt = result.security_receipt;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
@@ -11,6 +11,7 @@ const defaultRun = async (file, args) => {
|
|
|
11
11
|
const result = await execFileAsync(file, args, { encoding: "utf8" });
|
|
12
12
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
13
13
|
};
|
|
14
|
+
const defaultExists = async (path) => access(path).then(() => true, () => false);
|
|
14
15
|
const defaultWriteAtomic = async (path, contents, mode) => {
|
|
15
16
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
16
17
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
@@ -47,8 +48,18 @@ export class LaunchdService {
|
|
|
47
48
|
await (this.deps.run ?? defaultRun)("/bin/launchctl", ["bootout", `gui/${this.deps.uid}/${LAUNCHD_LABEL}`])
|
|
48
49
|
.catch(() => undefined);
|
|
49
50
|
}
|
|
51
|
+
// `stop` boots the agent out of launchd, and kickstart cannot start a service launchd no
|
|
52
|
+
// longer knows, so start loads the installed plist again first. It never writes a plist:
|
|
53
|
+
// a missing one means setup has not run.
|
|
50
54
|
async start() {
|
|
51
|
-
|
|
55
|
+
const run = this.deps.run ?? defaultRun;
|
|
56
|
+
if (!(await this.status()).loaded) {
|
|
57
|
+
if (!(await (this.deps.exists ?? defaultExists)(this.plistPath))) {
|
|
58
|
+
throw new Error(`The runner service is not installed (${this.plistPath} is missing). Run npx mtech-ai-local-runner setup.`);
|
|
59
|
+
}
|
|
60
|
+
await run("/bin/launchctl", ["bootstrap", `gui/${this.deps.uid}`, this.plistPath]);
|
|
61
|
+
}
|
|
62
|
+
await run("/bin/launchctl", ["kickstart", "-k", `gui/${this.deps.uid}/${LAUNCHD_LABEL}`]);
|
|
52
63
|
}
|
|
53
64
|
async status() {
|
|
54
65
|
try {
|
|
@@ -31,6 +31,10 @@ export class NodeProcessGroupHost {
|
|
|
31
31
|
});
|
|
32
32
|
child.stdin.once('finish', () => observations.add('host_stdin_finished'));
|
|
33
33
|
let failure;
|
|
34
|
+
let realExit = false, closed = false;
|
|
35
|
+
let closedResolve;
|
|
36
|
+
const processClosed = new Promise(resolve => { closedResolve = resolve; });
|
|
37
|
+
child.once('close', () => { closed = true; closedResolve(); });
|
|
34
38
|
let owned = true;
|
|
35
39
|
let finished = false, consumed = false, queuedBytes = 0, wake;
|
|
36
40
|
const lines = [];
|
|
@@ -119,6 +123,7 @@ export class NodeProcessGroupHost {
|
|
|
119
123
|
child.stderr.once('end', () => { observations.add('host_stderr_ended'); for (const marker of markers)
|
|
120
124
|
marker.prefix = ''; });
|
|
121
125
|
child.once("exit", (code, signal) => {
|
|
126
|
+
realExit = true;
|
|
122
127
|
observations.add('host_process_exited');
|
|
123
128
|
try {
|
|
124
129
|
kill();
|
|
@@ -131,7 +136,7 @@ export class NodeProcessGroupHost {
|
|
|
131
136
|
// Drain from spawn, not from the first adapter event read: official exec can
|
|
132
137
|
// emit startup events before it initializes MCP, while the adapter awaits
|
|
133
138
|
// that initialization before consuming events. Bound the pre-consumer queue.
|
|
134
|
-
|
|
139
|
+
const stdoutDrained = (async () => {
|
|
135
140
|
let pending = Buffer.alloc(0);
|
|
136
141
|
const enqueue = (line) => {
|
|
137
142
|
if (line.length > 256_000)
|
|
@@ -212,6 +217,36 @@ export class NodeProcessGroupHost {
|
|
|
212
217
|
}
|
|
213
218
|
} },
|
|
214
219
|
kill: (signal) => kill((signal ?? "SIGKILL")),
|
|
220
|
+
waitForOwnedExit: async (timeoutMs) => {
|
|
221
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10000)
|
|
222
|
+
throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
224
|
+
let timer;
|
|
225
|
+
try {
|
|
226
|
+
await Promise.race([Promise.all([processClosed, stdoutDrained]), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('OWNED_PROCESS_EXIT_UNCONFIRMED')), timeoutMs); })]);
|
|
227
|
+
if (!realExit || !closed || !child.pid)
|
|
228
|
+
throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
|
|
229
|
+
// Inspect only the exact process group created by this spawn. Never
|
|
230
|
+
// scan processes or signal an unrelated PID/group during verification.
|
|
231
|
+
for (;;) {
|
|
232
|
+
try {
|
|
233
|
+
process.kill(-child.pid, 0);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error.code === 'ESRCH')
|
|
237
|
+
return;
|
|
238
|
+
throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
|
|
239
|
+
}
|
|
240
|
+
if (Date.now() >= deadline)
|
|
241
|
+
throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
|
|
242
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
if (timer)
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
215
250
|
once: (_event, callback) => { if (termination) {
|
|
216
251
|
const ended = termination;
|
|
217
252
|
queueMicrotask(() => callback(ended.code, ended.signal));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
5
|
+
export function protocolHash(value) {
|
|
6
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, c]) => [k, canonical(c)])) : v;
|
|
7
|
+
return createHash('sha256').update(JSON.stringify(canonical(value)).replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
const denied = () => new Error('CANDIDATE_REJECTED');
|
|
10
|
+
function freeze(v) { if (v && typeof v === 'object') {
|
|
11
|
+
for (const c of Object.values(v))
|
|
12
|
+
freeze(c);
|
|
13
|
+
Object.freeze(v);
|
|
14
|
+
} return v; }
|
|
15
|
+
const sensitive = (s) => /^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s);
|
|
16
|
+
const validPath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\0') && !p.includes('\\') && !path.isAbsolute(p) && p.split('/').every(s => s !== '.' && s !== '..' && s !== '' && !sensitive(s));
|
|
17
|
+
function matches(file, scope) { if (scope.endsWith('/**')) {
|
|
18
|
+
const dir = scope.slice(0, -3);
|
|
19
|
+
return validPath(dir) && (file === dir || file.startsWith(dir + '/'));
|
|
20
|
+
} return validPath(scope) && file === scope; }
|
|
21
|
+
/** Object identity is host authority; serializing an evidence envelope cannot mint it.
|
|
22
|
+
* This is a verification foundation, not host adapter registration or a Gateway attestation. */
|
|
23
|
+
export class CandidateVerifier {
|
|
24
|
+
options;
|
|
25
|
+
issued = new WeakSet();
|
|
26
|
+
constructor(options) { this.options = { ...options, binding: freeze(structuredClone(options.binding)), commands: freeze(structuredClone(options.commands)), allowedPaths: [...options.allowedPaths], blockedPaths: [...options.blockedPaths] }; }
|
|
27
|
+
async snapshot() {
|
|
28
|
+
const o = this.options, b = o.binding;
|
|
29
|
+
if (!await o.current() || !Number.isSafeInteger(b.generation) || b.generation < 1 || !Number.isSafeInteger(o.maxFiles) || o.maxFiles < 1 || !Number.isSafeInteger(o.maxDiffBytes) || o.maxDiffBytes < 1 || !Number.isSafeInteger(o.maxFileBytes) || o.maxFileBytes < 1 || Object.values(b).some(v => v === '') || ![b.contract_fingerprint, b.task_package_fingerprint, b.launch_fingerprint].every(v => /^[a-f0-9]{64}$/.test(v)) || !/^[a-f0-9]{40}$/.test(b.base_commit_sha) || b.working_branch !== `ai-work/local/${b.attempt_id}` || !/^[A-Za-z0-9-]+$/.test(b.attempt_id) || !path.isAbsolute(o.root) || await realpath(o.root) !== o.root)
|
|
30
|
+
throw denied();
|
|
31
|
+
const observation = structuredClone(await o.observe());
|
|
32
|
+
if (observation.base_commit_sha !== b.base_commit_sha || observation.head_commit_sha !== b.base_commit_sha || observation.working_branch !== b.working_branch || Buffer.byteLength(observation.diff) > o.maxDiffBytes || !observation.diff.length || !observation.changed_files.length || observation.changed_files.length > o.maxFiles || new Set(observation.changed_files.map(f => f.path)).size !== observation.changed_files.length)
|
|
33
|
+
throw denied();
|
|
34
|
+
const entries = [];
|
|
35
|
+
for (const f of [...observation.changed_files].sort((a, b) => a.path < b.path ? -1 : 1)) {
|
|
36
|
+
if (!validPath(f.path) || !['added', 'modified', 'deleted'].includes(f.status) || !o.allowedPaths.some(s => matches(f.path, s)) || o.blockedPaths.some(s => s === '.env.*' ? f.path.startsWith('.env.') : matches(f.path, s)))
|
|
37
|
+
throw denied();
|
|
38
|
+
let current = o.root;
|
|
39
|
+
const parts = f.path.split('/');
|
|
40
|
+
for (let i = 0; i < parts.length; i++) {
|
|
41
|
+
current = path.join(current, parts[i]);
|
|
42
|
+
try {
|
|
43
|
+
const stat = await lstat(current);
|
|
44
|
+
if ((i === parts.length - 1 && stat.size > o.maxFileBytes) || stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory()) || (i === parts.length - 1 && f.status === 'deleted'))
|
|
45
|
+
throw denied();
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e.code === 'ENOENT' && i === parts.length - 1 && f.status === 'deleted')
|
|
49
|
+
break;
|
|
50
|
+
throw denied();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
entries.push({ ...f, content_sha256: f.status === 'deleted' ? null : await measureCommandFile(path.join(o.root, f.path)) });
|
|
54
|
+
}
|
|
55
|
+
if (!o.commands.length || new Set(o.commands.map(c => c.id)).size !== o.commands.length || o.commands.some(c => !c.id || !path.isAbsolute(c.executable) || !c.integrity || !c.argv.every(a => typeof a === 'string')))
|
|
56
|
+
throw denied();
|
|
57
|
+
const commands = o.commands.map(c => ({ id: c.id, fingerprint: protocolHash(c) }));
|
|
58
|
+
const diff_hash = createHash('sha256').update(observation.diff).digest('hex');
|
|
59
|
+
const changed_file_manifest_hash = protocolHash(entries.map(({ content_sha256: _, ...f }) => f));
|
|
60
|
+
const snapshot_fingerprint = protocolHash({ binding: b, entries, diff_hash, changed_file_manifest_hash, commands });
|
|
61
|
+
if (!await o.current())
|
|
62
|
+
throw denied();
|
|
63
|
+
return freeze({ snapshot_fingerprint, changed_file_manifest_hash, diff_hash, commands, changed_files: observation.changed_files, diff_bytes: Buffer.byteLength(observation.diff) });
|
|
64
|
+
}
|
|
65
|
+
async verify() {
|
|
66
|
+
try {
|
|
67
|
+
const snapshot = await this.snapshot(), outcomes = structuredClone(await this.options.verify(snapshot));
|
|
68
|
+
if (outcomes.length !== snapshot.commands.length || new Set(outcomes.map(r => r.command_id)).size !== outcomes.length || snapshot.commands.some(c => !outcomes.some(r => r.command_id === c.id && r.command_fingerprint === c.fingerprint && r.snapshot_fingerprint === snapshot.snapshot_fingerprint && r.status === 'passed')) || protocolHash(await this.snapshot()) !== protocolHash(snapshot))
|
|
69
|
+
throw denied();
|
|
70
|
+
const b = this.options.binding, verification_fingerprint = protocolHash({ binding: b, snapshot_fingerprint: snapshot.snapshot_fingerprint, outcomes: [...outcomes].sort((a, b) => a.command_id < b.command_id ? -1 : 1) });
|
|
71
|
+
const evidence = { ...b, ...snapshot, verification_fingerprint, verification_status: 'passed', snapshot_reference: { kind: 'runner_local_snapshot', fingerprint: snapshot.snapshot_fingerprint, runner_id: b.runner_id, job_id: b.job_id, generation: b.generation } };
|
|
72
|
+
const { commands: _, ...wire } = evidence;
|
|
73
|
+
const candidate = freeze({ evidence: { ...wire, candidate_fingerprint: protocolHash(wire) }, testReceipts: outcomes });
|
|
74
|
+
this.issued.add(candidate);
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw denied();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async assertCurrent(candidate) {
|
|
82
|
+
try {
|
|
83
|
+
if (!this.issued.has(candidate) || !await this.options.current())
|
|
84
|
+
throw denied();
|
|
85
|
+
if (this.options.assertRetainedSeal)
|
|
86
|
+
await this.options.assertRetainedSeal();
|
|
87
|
+
else if (candidate.evidence.snapshot_fingerprint !== (await this.snapshot()).snapshot_fingerprint)
|
|
88
|
+
throw denied();
|
|
89
|
+
return candidate.evidence;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw denied();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|