witnora 0.13.4 → 0.13.5

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/gateway.js CHANGED
@@ -1,12 +1,15 @@
1
- import { createHash, randomBytes } from "node:crypto";
1
+ import { createHash, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { closeSync, openSync } from "node:fs";
4
- import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { loadConnection } from "./credentials.js";
8
8
  import { authorizeProjectConnection } from "./device-authorization.js";
9
+ import { verifyHostedProbeCredential } from "./runtime-bootstrap.js";
9
10
  import { inspectIsolatedOutcomeProbe, observeInIsolatedOutcomeProbe } from "./probe-process.js";
11
+ import { findAvailableRuntimeSandboxOrigin, LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256, startRuntimeSandboxFixture, } from "./runtime-sandbox-fixture.js";
12
+ import { generateRuntimeSandboxKit, LOCAL_SANDBOX_ADAPTER_ID, LOCAL_SANDBOX_ADAPTER_VERSION, LOCAL_SANDBOX_PROBE_ID, } from "./runtime-sandbox-kit.js";
10
13
  const CONFIG_SCHEMA = "witnora.customer_gateway_setup.v0.1";
11
14
  const SECRETS_SCHEMA = "witnora.customer_gateway_local_secrets.v0.1";
12
15
  const RUNTIME_SCHEMA = "witnora.managed_gateway_runtime.v0.1";
@@ -41,6 +44,9 @@ export async function initializeCustomerGateway(options) {
41
44
  const missingScopes = expectedScopes.filter((scope) => !authorization.scopes.includes(scope));
42
45
  if (missingScopes.length > 0)
43
46
  throw new Error(`Gateway authorization is missing required scope(s): ${missingScopes.join(", ")}.`);
47
+ const runtimeKit = options.runtimeReferences
48
+ ? await prepareRuntimeSandboxKit(options.runtimeReferences, authorization, options.configHome, options.fetch ?? fetch, outDir)
49
+ : undefined;
44
50
  const config = {
45
51
  schemaVersion: CONFIG_SCHEMA,
46
52
  projectId: authorization.projectId,
@@ -51,8 +57,13 @@ export async function initializeCustomerGateway(options) {
51
57
  port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, 8787),
52
58
  storageDirectory: "data",
53
59
  privacyMode: "metadata_only",
54
- coverage: { recorded: "configured", enforced: "not_configured", outcomeVerified: "not_configured" },
60
+ coverage: runtimeKit
61
+ ? { recorded: "configured", enforced: "configured", outcomeVerified: "configured" }
62
+ : { recorded: "configured", enforced: "not_configured", outcomeVerified: "not_configured" },
63
+ ...(runtimeKit ? { runtimeWorker: runtimeKit.config } : {}),
55
64
  };
65
+ if (config.runtimeWorker)
66
+ config.runtimeWorker.configDigestSha256 = gatewayConfigDigest(config);
56
67
  const secrets = {
57
68
  schemaVersion: SECRETS_SCHEMA,
58
69
  gatewayToken: randomBytes(32).toString("base64url"),
@@ -67,6 +78,11 @@ export async function initializeCustomerGateway(options) {
67
78
  [gitignorePath, "secrets.json\ndata/\nruntime/\n", 0o644],
68
79
  [clientPath, gatewayClient(config), 0o644],
69
80
  [readmePath, gatewayReadme(config), 0o644],
81
+ ...(runtimeKit ? [
82
+ ...runtimeKit.secretFiles,
83
+ [join(outDir, runtimeKit.config.adapterModulePath), runtimeKit.adapterSource, 0o600],
84
+ [join(outDir, runtimeKit.config.probeModulePath), runtimeKit.probeSource, 0o600],
85
+ ] : []),
70
86
  ]) {
71
87
  await writeExclusive(path, content, force, mode);
72
88
  if (!force)
@@ -89,6 +105,236 @@ export async function initializeCustomerGateway(options) {
89
105
  output("This reference path establishes RECORDED evidence only. ENFORCED requires target write credentials behind a controlled execution adapter; OUTCOME VERIFIED requires a separate read-only probe.\n");
90
106
  return { configPath, secretsPath, config, generatedFiles };
91
107
  }
108
+ export async function inspectLocalRuntimeBinding(options) {
109
+ const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
110
+ const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
111
+ const worker = config.runtimeWorker;
112
+ if (worker?.adapterId !== LOCAL_SANDBOX_ADAPTER_ID)
113
+ return undefined;
114
+ const ring = JSON.parse(await readFile(resolve(directory, config.storageDirectory, "source-keys.json"), "utf8"));
115
+ const sourceKeyId = typeof ring.activeKeyId === "string" && ring.keys?.some((key) => key.keyId === ring.activeKeyId && key.status === "active") ? ring.activeKeyId : undefined;
116
+ if (ring.collectorId !== config.collectorId || !sourceKeyId)
117
+ return undefined;
118
+ return { classification: "LOCAL_SANDBOX_ONLY", binding: {
119
+ collectorId: config.collectorId, sourceKeyId, configDigestSha256: worker.configDigestSha256, runtimeIdentity: { id: worker.runtimeIdentityId },
120
+ mandateDigestSha256: worker.mandateDigestSha256, adapterDigestSha256: worker.adapterModuleSha256,
121
+ probeDigestSha256: worker.probeModuleSha256, fixtureContractDigestSha256: worker.fixtureContractSha256,
122
+ ...(worker.probeApiKeyId ? { probeApiKeyId: worker.probeApiKeyId } : {}),
123
+ }, workerReady: true, fixtureReady: true };
124
+ }
125
+ export async function upgradeCustomerGatewayRuntime(options) {
126
+ const directory = resolve(options.repository, ".witnora/gateway");
127
+ const configPath = join(directory, "gateway.json");
128
+ const clientPath = join(directory, "client.mjs");
129
+ const readmePath = join(directory, "README.md");
130
+ const [configRaw, clientRaw, readmeRaw] = await Promise.all([readFile(configPath, "utf8"), readFile(clientPath, "utf8"), readFile(readmePath, "utf8")]);
131
+ const current = parseConfig(configRaw);
132
+ if (current.projectId !== options.authorization.projectId || current.server !== options.authorization.server)
133
+ throw new Error("Existing Gateway project/server binding does not match this authorized Runtime upgrade.");
134
+ if (clientRaw !== gatewayClient(current) || readmeRaw !== gatewayReadme(current))
135
+ throw new Error("Existing Gateway files differ from the Witnora-generated version; refusing to overwrite them.");
136
+ if (current.runtimeWorker && current.runtimeWorker.adapterId !== LOCAL_SANDBOX_ADAPTER_ID) {
137
+ throw new Error("Existing Gateway already has a different Runtime worker; preserve it and reconfigure in Advanced mode.");
138
+ }
139
+ const runtimeKit = await prepareRuntimeSandboxKit(options.runtimeReferences, options.authorization, options.configHome, options.fetch ?? fetch, directory);
140
+ const next = { ...current, coverage: { recorded: "configured", enforced: "configured", outcomeVerified: "configured" }, runtimeWorker: runtimeKit.config };
141
+ next.runtimeWorker.configDigestSha256 = gatewayConfigDigest(next);
142
+ const replacingGeneratedRuntime = current.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID;
143
+ if (replacingGeneratedRuntime) {
144
+ const [adapterBytes, probeBytes] = await Promise.all([
145
+ readFile(resolve(directory, current.runtimeWorker.adapterModulePath)),
146
+ readFile(resolve(directory, current.runtimeWorker.probeModulePath)),
147
+ ]);
148
+ if (createHash("sha256").update(adapterBytes).digest("hex") !== current.runtimeWorker.adapterModuleSha256
149
+ || createHash("sha256").update(probeBytes).digest("hex") !== current.runtimeWorker.probeModuleSha256) {
150
+ throw new Error("Existing generated Runtime modules were modified; refusing to overwrite them.");
151
+ }
152
+ if (sameRuntimeGeneration(current.runtimeWorker, next.runtimeWorker)) {
153
+ return { config: current, generatedFiles: [], changed: false, rollback: async () => undefined };
154
+ }
155
+ }
156
+ const backupDirectory = join(directory, "data", "runtime-upgrade-backups", `${Date.now()}-${randomBytes(4).toString("hex")}`);
157
+ await mkdir(backupDirectory, { recursive: true });
158
+ const previousFiles = new Map([[configPath, configRaw], [clientPath, clientRaw], [readmePath, readmeRaw]]);
159
+ if (replacingGeneratedRuntime) {
160
+ previousFiles.set(resolve(directory, current.runtimeWorker.adapterModulePath), await readFile(resolve(directory, current.runtimeWorker.adapterModulePath)));
161
+ previousFiles.set(resolve(directory, current.runtimeWorker.probeModulePath), await readFile(resolve(directory, current.runtimeWorker.probeModulePath)));
162
+ }
163
+ await Promise.all([...previousFiles.entries()].map(([path, content]) => writeFile(join(backupDirectory, basename(path)), content, { mode: 0o600 })));
164
+ const created = [];
165
+ const restore = async () => {
166
+ await Promise.all([...previousFiles.entries()].map(([path, content]) => writeFile(path, content)));
167
+ for (const path of [...created].reverse())
168
+ await rm(path, { force: true }).catch(() => undefined);
169
+ };
170
+ try {
171
+ if (replacingGeneratedRuntime) {
172
+ await atomicWrite(join(directory, runtimeKit.config.adapterModulePath), runtimeKit.adapterSource, 0o600);
173
+ await atomicWrite(join(directory, runtimeKit.config.probeModulePath), runtimeKit.probeSource, 0o600);
174
+ }
175
+ else {
176
+ for (const [path, content, mode] of [
177
+ ...runtimeKit.secretFiles,
178
+ [join(directory, runtimeKit.config.adapterModulePath), runtimeKit.adapterSource, 0o600],
179
+ [join(directory, runtimeKit.config.probeModulePath), runtimeKit.probeSource, 0o600],
180
+ ]) {
181
+ await writeExclusive(path, content, false, mode);
182
+ created.push(path);
183
+ }
184
+ }
185
+ await atomicWrite(configPath, `${JSON.stringify(next, null, 2)}\n`, 0o644);
186
+ await atomicWrite(clientPath, gatewayClient(next), 0o644);
187
+ await atomicWrite(readmePath, gatewayReadme(next), 0o644);
188
+ }
189
+ catch (error) {
190
+ await restore();
191
+ throw error;
192
+ }
193
+ return { config: next, generatedFiles: [configPath, clientPath, readmePath, ...created, ...(replacingGeneratedRuntime ? [join(directory, runtimeKit.config.adapterModulePath), join(directory, runtimeKit.config.probeModulePath)] : [])], changed: true, rollback: restore };
194
+ }
195
+ function sameRuntimeGeneration(left, right) {
196
+ return JSON.stringify({ ...left, configDigestSha256: undefined }) === JSON.stringify({ ...right, configDigestSha256: undefined });
197
+ }
198
+ export class RuntimeSetupNotReadyError extends Error {
199
+ constructor(message) { super(message); this.name = "RuntimeSetupNotReadyError"; }
200
+ }
201
+ async function prepareRuntimeSandboxKit(references, authorization, configHome, requestFetch, outDir) {
202
+ try {
203
+ return await prepareRuntimeSandboxKitUnchecked(references, authorization, configHome, requestFetch, outDir);
204
+ }
205
+ catch (error) {
206
+ throw new RuntimeSetupNotReadyError(error instanceof Error ? error.message : String(error));
207
+ }
208
+ }
209
+ async function prepareRuntimeSandboxKitUnchecked(references, authorization, configHome, requestFetch, outDir) {
210
+ validateRuntimeReferences(references);
211
+ const localRuntimePublicKeyPem = await ed25519PublicKeyPem(references.runtimeSigningKeyHandle);
212
+ const probe = references.probeConnectionName
213
+ ? await loadConnection(references.probeConnectionName, { configHome })
214
+ : references.probeHostedCredentialHandle
215
+ ? { projectId: authorization.projectId, server: authorization.server, apiKey: await readSecretProviderHandle(references.probeHostedCredentialHandle) }
216
+ : undefined;
217
+ if (!probe || probe.projectId !== authorization.projectId || probe.server !== authorization.server || probe.apiKey === authorization.apiKey) {
218
+ throw new Error("The existing outcome-probe connection must be separate and bound to this project and server.");
219
+ }
220
+ const response = await requestFetch(`${authorization.server}/v1/projects/${encodeURIComponent(authorization.projectId)}/runtime-identities`, {
221
+ headers: { authorization: `Bearer ${authorization.apiKey}` },
222
+ });
223
+ const body = await response.json().catch(() => ({}));
224
+ if (!response.ok)
225
+ throw new Error(String(body.error ?? `Runtime identity lookup failed with HTTP ${response.status}.`));
226
+ const identity = body.runtimeIdentities?.find((item) => item.runtimeIdentityId === references.runtimeIdentityId && item.keyId === references.runtimeKeyId);
227
+ const now = Date.now();
228
+ if (!identity || identity.status !== "ACTIVE" || !Number.isFinite(Date.parse(String(identity.validFrom))) || !Number.isFinite(Date.parse(String(identity.validUntil)))
229
+ || Date.parse(String(identity.validFrom)) > now || Date.parse(String(identity.validUntil)) <= now) {
230
+ throw new Error("The referenced runtime identity and key are not active for this project.");
231
+ }
232
+ if (!new Set(["SETUP_AUTOPILOT_SANDBOX", "DEVELOPMENT_FIXTURE"]).has(String(identity.registrationMethod)) || !Array.isArray(identity.adapterCapabilities)
233
+ || identity.adapterCapabilities.length !== 1 || identity.adapterCapabilities[0] !== LOCAL_SANDBOX_ADAPTER_ID) {
234
+ throw new Error("The Hosted runtime identity is not exclusively bound to the local sandbox adapter fixture.");
235
+ }
236
+ let hostedRuntimePublicKeyPem;
237
+ try {
238
+ hostedRuntimePublicKeyPem = createPublicKey(String(identity.publicKeyPem)).export({ type: "spki", format: "pem" }).toString();
239
+ }
240
+ catch {
241
+ throw new Error("The Hosted runtime identity has no valid public key binding.");
242
+ }
243
+ if (hostedRuntimePublicKeyPem !== localRuntimePublicKeyPem)
244
+ throw new Error("The local Runtime signing key does not match the Hosted runtime identity.");
245
+ const mandateResponse = await requestFetch(`${authorization.server}/v1/projects/${encodeURIComponent(authorization.projectId)}/mandates/${encodeURIComponent(references.mandateId)}`, {
246
+ headers: { authorization: `Bearer ${authorization.apiKey}` },
247
+ });
248
+ const mandate = await mandateResponse.json().catch(() => ({}));
249
+ if (!mandateResponse.ok)
250
+ throw new Error(String(mandate.error ?? `Mandate lookup failed with HTTP ${mandateResponse.status}.`));
251
+ const mandatePayload = mandate.payload && typeof mandate.payload === "object" && !Array.isArray(mandate.payload) ? mandate.payload : {};
252
+ const mandateConstraints = mandatePayload.constraints && typeof mandatePayload.constraints === "object" && !Array.isArray(mandatePayload.constraints)
253
+ ? mandatePayload.constraints : {};
254
+ if (mandate.id !== references.mandateId || mandate.status !== "ACTIVE"
255
+ || !validDigest(String(mandate.digestSha256 ?? ""))
256
+ || !activeWindow(mandatePayload.validFrom, mandatePayload.expiresAt, now)
257
+ || !Number.isInteger(mandatePayload.maxUses) || Number(mandatePayload.maxUses) < 1 || Number(mandatePayload.maxUses) > 1_000
258
+ || mandatePayload.maxDelegationDepth !== 0 || mandatePayload.parentMandateId !== undefined
259
+ || JSON.stringify(mandatePayload.audience) !== JSON.stringify(["WitnoraLocalSandbox"])
260
+ || JSON.stringify(mandatePayload.permittedActionClasses) !== JSON.stringify(["UPDATE"])
261
+ || !Array.isArray(mandatePayload.permittedOperations) || mandatePayload.permittedOperations.length !== 1 || !["UPDATE", "WitnoraLocalSandbox:UPDATE"].includes(String(mandatePayload.permittedOperations[0]))
262
+ || !Array.isArray(mandatePayload.permittedResources) || mandatePayload.permittedResources.length !== 1 || !["mock-state/*", "WitnoraLocalSandbox:mock-state/*"].includes(String(mandatePayload.permittedResources[0]))
263
+ || mandateConstraints.approvalRequirement !== "HUMAN" || mandateConstraints.rollbackRequired !== true
264
+ || mandateConstraints.outcomePredicateRequirement !== "state_subset"
265
+ || !Array.isArray(mandateConstraints.allowedEnvironment) || mandateConstraints.allowedEnvironment.length !== 1 || !["sandbox", "local"].includes(String(mandateConstraints.allowedEnvironment[0]))
266
+ || mandateConstraints.payment !== undefined || mandateConstraints.monetaryLimit !== undefined) {
267
+ throw new Error("The referenced mandate is not an active localhost sandbox UPDATE mandate.");
268
+ }
269
+ if (typeof mandatePayload.granteeIdentityId !== "string" || !mandatePayload.granteeIdentityId)
270
+ throw new Error("The referenced sandbox mandate has no grantee identity binding.");
271
+ const sandboxOrigin = references.sandboxOrigin ?? await findAvailableRuntimeSandboxOrigin();
272
+ const generated = generateRuntimeSandboxKit(sandboxOrigin);
273
+ if (references.adapterDigestSha256 && references.adapterDigestSha256 !== generated.adapterSha256
274
+ || references.probeDigestSha256 && references.probeDigestSha256 !== generated.probeSha256
275
+ || references.fixtureContractDigestSha256 && references.fixtureContractDigestSha256 !== LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256) {
276
+ throw new Error("Generated Runtime modules do not match the Hosted bootstrap contract.");
277
+ }
278
+ if (references.probeApiKeyId) {
279
+ if (!references.bootstrapId || !references.setupPlanId)
280
+ throw new Error("Hosted Runtime bootstrap references are incomplete.");
281
+ await verifyHostedProbeCredential({
282
+ projectId: authorization.projectId, server: authorization.server,
283
+ setupPlanId: references.setupPlanId, bootstrapId: references.bootstrapId,
284
+ probeApiKeyId: references.probeApiKeyId, apiKey: probe.apiKey, fetch: requestFetch,
285
+ });
286
+ }
287
+ const sandboxSecretsDirectory = join(outDir, "data", "runtime-sandbox");
288
+ const readCredentialPath = join(sandboxSecretsDirectory, "read-credential.txt");
289
+ const writeCredentialPath = join(sandboxSecretsDirectory, "write-credential.txt");
290
+ const auditCredentialPath = join(sandboxSecretsDirectory, "audit-reconcile-credential.txt");
291
+ const readCredential = randomBytes(32).toString("base64url");
292
+ const writeCredential = randomBytes(32).toString("base64url");
293
+ const auditCredential = randomBytes(32).toString("base64url");
294
+ return {
295
+ adapterSource: generated.adapterSource,
296
+ probeSource: generated.probeSource,
297
+ config: {
298
+ enabled: true,
299
+ adapterModulePath: "runtime/local-sandbox-adapter.mjs",
300
+ adapterModuleSha256: generated.adapterSha256,
301
+ probeModulePath: "runtime/local-sandbox-probe.mjs",
302
+ probeModuleSha256: generated.probeSha256,
303
+ adapterId: LOCAL_SANDBOX_ADAPTER_ID,
304
+ adapterVersion: LOCAL_SANDBOX_ADAPTER_VERSION,
305
+ probeId: LOCAL_SANDBOX_PROBE_ID,
306
+ ...(references.probeConnectionName ? { probeConnectionName: references.probeConnectionName } : { probeHostedCredentialHandle: references.probeHostedCredentialHandle }),
307
+ ...(references.probeApiKeyId ? { probeApiKeyId: references.probeApiKeyId, runtimeBootstrapId: references.bootstrapId, runtimeSetupPlanId: references.setupPlanId } : {}),
308
+ probeTargetCredentialHandle: pathToFileURL(readCredentialPath).href,
309
+ targetWriteCredentialHandle: pathToFileURL(writeCredentialPath).href,
310
+ reconcileAuditCredentialHandle: pathToFileURL(auditCredentialPath).href,
311
+ sandboxOrigin,
312
+ fixtureContractSha256: LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256,
313
+ mandateDigestSha256: String(mandate.digestSha256),
314
+ runtimeIdentityId: references.runtimeIdentityId,
315
+ mandateId: references.mandateId,
316
+ sandboxPrincipalId: mandatePayload.granteeIdentityId,
317
+ runtimeKeyId: references.runtimeKeyId,
318
+ runtimeSigningKeyHandle: references.runtimeSigningKeyHandle,
319
+ grantTtlSeconds: 120,
320
+ pollIntervalMs: 2_000,
321
+ },
322
+ secretFiles: [[readCredentialPath, `${readCredential}\n`, 0o600], [writeCredentialPath, `${writeCredential}\n`, 0o600], [auditCredentialPath, `${auditCredential}\n`, 0o600]],
323
+ };
324
+ }
325
+ function validateRuntimeReferences(value) {
326
+ if ((!value.probeConnectionName || !/^[A-Za-z0-9._-]{1,64}$/.test(value.probeConnectionName)) && !credentialHandle(value.probeHostedCredentialHandle ?? "")
327
+ || !value.mandateId || !value.runtimeIdentityId || !value.runtimeKeyId || !credentialHandle(value.runtimeSigningKeyHandle)) {
328
+ throw new Error("Runtime setup references must contain a named probe connection, active runtime identity/key, and opaque credential handles.");
329
+ }
330
+ for (const handle of [value.runtimeSigningKeyHandle, ...(value.probeHostedCredentialHandle ? [value.probeHostedCredentialHandle] : [])]) {
331
+ const url = new URL(handle);
332
+ if (url.protocol !== "file:")
333
+ throw new Error("The v0.1 localhost sandbox runtime supports file:// secret-provider handles only.");
334
+ }
335
+ if (value.probeHostedCredentialHandle === value.runtimeSigningKeyHandle)
336
+ throw new Error("Probe and runtime signing credentials must use separate handles.");
337
+ }
92
338
  export async function inspectCustomerGatewayFiles(options) {
93
339
  const repository = resolve(options.repository ?? process.cwd());
94
340
  const directory = resolve(repository, options.outDir ?? ".witnora/gateway");
@@ -147,11 +393,29 @@ export async function doctorCustomerGateway(options) {
147
393
  throw new Error("Runtime adapter or probe module digest does not match gateway.json.");
148
394
  const [primary, probe] = await Promise.all([
149
395
  loadConnection(config.connectionName, { configHome: options.configHome }),
150
- loadConnection(config.runtimeWorker.probeConnectionName, { configHome: options.configHome }),
396
+ loadProbeHostedConnection(config.runtimeWorker, config.projectId, config.server, options.configHome),
151
397
  ]);
152
398
  if (!primary || !probe || probe.projectId !== config.projectId || probe.server !== config.server || probe.apiKey === primary.apiKey) {
153
399
  throw new Error("A separate outcome-probe credential bound to this project is required.");
154
400
  }
401
+ if (config.runtimeWorker.probeApiKeyId)
402
+ await verifyConfiguredProbeCredential(config, probe.apiKey, options.fetch ?? fetch);
403
+ if (config.runtimeWorker.adapterId === LOCAL_SANDBOX_ADAPTER_ID) {
404
+ if (!config.runtimeWorker.runtimeKeyId || !config.runtimeWorker.runtimeSigningKeyHandle || !config.runtimeWorker.targetWriteCredentialHandle
405
+ || !config.runtimeWorker.mandateId || !config.runtimeWorker.sandboxPrincipalId) {
406
+ throw new Error("The generated localhost Runtime identity, mandate, and credential bindings are incomplete.");
407
+ }
408
+ await ed25519PublicKeyPem(config.runtimeWorker.runtimeSigningKeyHandle);
409
+ }
410
+ if (config.runtimeWorker.targetWriteCredentialHandle) {
411
+ const [readTargetCredential, writeTargetCredential, auditCredential] = await Promise.all([
412
+ readSecretProviderHandle(config.runtimeWorker.probeTargetCredentialHandle),
413
+ readSecretProviderHandle(config.runtimeWorker.targetWriteCredentialHandle),
414
+ readSecretProviderHandle(config.runtimeWorker.reconcileAuditCredentialHandle),
415
+ ]);
416
+ if (new Set([readTargetCredential, writeTargetCredential, auditCredential]).size !== 3)
417
+ throw new Error("Sandbox read, write, and reconciliation credentials must contain separate values.");
418
+ }
155
419
  checks.push({ id: "runtime_configuration", status: "PASS", message: "The digest-pinned adapter module and separate outcome-probe credential are present." });
156
420
  }
157
421
  catch (error) {
@@ -159,7 +423,7 @@ export async function doctorCustomerGateway(options) {
159
423
  }
160
424
  }
161
425
  try {
162
- const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, Boolean(config.runtimeWorker));
426
+ const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, config.runtimeWorker);
163
427
  if (!health)
164
428
  throw new Error("Gateway health or runtime-worker readiness was not established.");
165
429
  checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
@@ -206,34 +470,121 @@ export async function runCustomerGateway(options) {
206
470
  const keyRing = await (await exists(keyRingPath)
207
471
  ? CustomerSourceKeyRing.open(keyRingPath)
208
472
  : CustomerSourceKeyRing.create(keyRingPath, config.collectorId));
209
- const actionWorker = config.runtimeWorker
210
- ? await createConfiguredRuntimeActionWorker({
211
- directory, config, connection, configHome: options.configHome,
212
- DurableApprovedActionWorker: (await durableWorker).DurableApprovedActionWorker,
213
- FileActionCheckpointStore: (await durableWorker).FileActionCheckpointStore,
473
+ const sandboxFixture = config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID
474
+ ? await startRuntimeSandboxFixture({
475
+ origin: config.runtimeWorker.sandboxOrigin, directory: join(dataDirectory, "runtime-sandbox", "fixture"),
476
+ readCredentialHandle: config.runtimeWorker.probeTargetCredentialHandle,
477
+ writeCredentialHandle: config.runtimeWorker.targetWriteCredentialHandle,
478
+ auditCredentialHandle: config.runtimeWorker.reconcileAuditCredentialHandle,
479
+ contractSha256: config.runtimeWorker.fixtureContractSha256,
214
480
  })
215
481
  : undefined;
216
- actionWorker?.start();
217
- const gateway = await startCustomerOwnedCollectorGateway({
218
- client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
219
- keyRing,
220
- gatewayToken: secrets.gatewayToken,
221
- storageDirectory: dataDirectory,
222
- host: process.env.WITNORA_GATEWAY_HOST?.trim() || config.host,
223
- port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, config.port),
224
- environment: "customer-owned",
225
- ...(actionWorker && config.runtimeWorker ? { actionWorker: {
226
- track: (input) => isConfiguredRuntimeProposal(input.proposal, config.runtimeWorker) ? actionWorker.track(input) : Promise.resolve(undefined),
227
- status: () => actionWorker.status(), close: () => actionWorker.stop(),
228
- } } : {}),
229
- });
230
- process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
231
- process.stdout.write(config.runtimeWorker
232
- ? "Runtime worker: READY. Approved exact configured actions execute automatically, then use the separate read-only probe and Hosted signed receipt.\n"
233
- : "Evidence ceiling: RECORDED. No exact target adapter and separate outcome probe are configured, so runtime writes remain fail-closed.\n");
234
- for (const signal of ["SIGINT", "SIGTERM"]) {
235
- process.once(signal, () => void gateway.close().finally(() => process.exit(0)));
482
+ try {
483
+ const actionWorker = config.runtimeWorker
484
+ ? await createConfiguredRuntimeActionWorker({
485
+ directory, config, connection, configHome: options.configHome,
486
+ DurableApprovedActionWorker: (await durableWorker).DurableApprovedActionWorker,
487
+ FileActionCheckpointStore: (await durableWorker).FileActionCheckpointStore,
488
+ })
489
+ : undefined;
490
+ actionWorker?.start();
491
+ const gateway = await startCustomerOwnedCollectorGateway({
492
+ client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
493
+ keyRing,
494
+ gatewayToken: secrets.gatewayToken,
495
+ storageDirectory: dataDirectory,
496
+ host: process.env.WITNORA_GATEWAY_HOST?.trim() || config.host,
497
+ port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, config.port),
498
+ environment: "customer-owned",
499
+ ...(actionWorker && config.runtimeWorker ? { actionWorker: {
500
+ track: (input) => isConfiguredRuntimeProposal(input.proposal, config.runtimeWorker) ? actionWorker.track(input) : Promise.resolve(undefined),
501
+ status: async () => {
502
+ const status = await actionWorker.status();
503
+ const probeReady = await runtimeProbeCredentialReady(config, options.configHome);
504
+ return {
505
+ ...status,
506
+ ready: status.ready === true && probeReady,
507
+ runtimeBinding: {
508
+ configDigestSha256: config.runtimeWorker.configDigestSha256,
509
+ ...(config.runtimeWorker.probeApiKeyId ? { probeApiKeyId: config.runtimeWorker.probeApiKeyId } : {}),
510
+ },
511
+ ...(!probeReady ? { lastError: "Hosted outcome-probe credential is not active for this Runtime generation." } : {}),
512
+ ...(config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID
513
+ ? { fixtureReady: await runtimeSandboxFixtureReady(config.runtimeWorker) }
514
+ : {}),
515
+ };
516
+ },
517
+ close: () => actionWorker.stop(),
518
+ ...(config.runtimeWorker.adapterId === LOCAL_SANDBOX_ADAPTER_ID ? { runtimeBinding: {
519
+ classification: "LOCAL_SANDBOX_ONLY",
520
+ binding: {
521
+ collectorId: config.collectorId, sourceKeyId: keyRing.activeSigner().keyId,
522
+ configDigestSha256: config.runtimeWorker.configDigestSha256,
523
+ runtimeIdentity: { id: config.runtimeWorker.runtimeIdentityId },
524
+ mandateDigestSha256: config.runtimeWorker.mandateDigestSha256,
525
+ adapterDigestSha256: config.runtimeWorker.adapterModuleSha256,
526
+ probeDigestSha256: config.runtimeWorker.probeModuleSha256,
527
+ fixtureContractDigestSha256: config.runtimeWorker.fixtureContractSha256,
528
+ ...(config.runtimeWorker.probeApiKeyId ? { probeApiKeyId: config.runtimeWorker.probeApiKeyId } : {}),
529
+ },
530
+ fixtureReady: false,
531
+ } } : {}),
532
+ } } : {}),
533
+ });
534
+ process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
535
+ process.stdout.write(config.runtimeWorker
536
+ ? "Runtime worker: READY. Approved exact configured actions execute automatically, then use the separate read-only probe and Hosted signed receipt.\n"
537
+ : "Evidence ceiling: RECORDED. No exact target adapter and separate outcome probe are configured, so runtime writes remain fail-closed.\n");
538
+ for (const signal of ["SIGINT", "SIGTERM"]) {
539
+ process.once(signal, () => void Promise.allSettled([gateway.close(), ...(sandboxFixture ? [sandboxFixture.close()] : [])]).finally(() => process.exit(0)));
540
+ }
236
541
  }
542
+ catch (error) {
543
+ await sandboxFixture?.close().catch(() => undefined);
544
+ throw error;
545
+ }
546
+ }
547
+ async function runtimeSandboxFixtureReady(config) {
548
+ try {
549
+ if (config.adapterId !== LOCAL_SANDBOX_ADAPTER_ID || !config.sandboxOrigin || !config.probeTargetCredentialHandle || !config.fixtureContractSha256)
550
+ return false;
551
+ const credential = await readSecretProviderHandle(config.probeTargetCredentialHandle);
552
+ const response = await fetch(`${config.sandboxOrigin}/healthz`, {
553
+ redirect: "error",
554
+ headers: { "x-sandbox-read-credential": credential },
555
+ signal: AbortSignal.timeout(2_000),
556
+ });
557
+ const body = await response.json().catch(() => ({}));
558
+ return response.ok && body.origin === config.sandboxOrigin && body.contractSha256 === config.fixtureContractSha256;
559
+ }
560
+ catch {
561
+ return false;
562
+ }
563
+ }
564
+ async function runtimeProbeCredentialReady(config, configHome) {
565
+ try {
566
+ const worker = config.runtimeWorker;
567
+ if (!worker?.probeApiKeyId)
568
+ return true;
569
+ const probe = await loadProbeHostedConnection(worker, config.projectId, config.server, configHome);
570
+ if (!probe)
571
+ return false;
572
+ await verifyConfiguredProbeCredential(config, probe.apiKey, fetch);
573
+ return true;
574
+ }
575
+ catch {
576
+ return false;
577
+ }
578
+ }
579
+ async function verifyConfiguredProbeCredential(config, apiKey, requestFetch) {
580
+ const worker = config.runtimeWorker;
581
+ if (!worker?.probeApiKeyId || !worker.runtimeBootstrapId || !worker.runtimeSetupPlanId)
582
+ throw new Error("Hosted Runtime probe generation binding is incomplete.");
583
+ await verifyHostedProbeCredential({
584
+ projectId: config.projectId, server: config.server,
585
+ setupPlanId: worker.runtimeSetupPlanId, bootstrapId: worker.runtimeBootstrapId,
586
+ probeApiKeyId: worker.probeApiKeyId, apiKey, fetch: requestFetch,
587
+ });
237
588
  }
238
589
  export async function createConfiguredRuntimeActionWorker(input) {
239
590
  const workerConfig = input.config.runtimeWorker;
@@ -251,17 +602,25 @@ export async function createConfiguredRuntimeActionWorker(input) {
251
602
  const adapterModule = await import(`${pathToFileURL(adapterModulePath).href}?sha256=${adapterModuleDigest}`);
252
603
  if (typeof adapterModule.createWitnoraRuntimeAdapter !== "function")
253
604
  throw new Error("Runtime adapter module must export its factory.");
254
- const probeConnection = await loadConnection(workerConfig.probeConnectionName, { configHome: input.configHome });
605
+ const probeConnection = await loadProbeHostedConnection(workerConfig, input.config.projectId, input.config.server, input.configHome);
255
606
  if (!probeConnection || probeConnection.projectId !== input.config.projectId || probeConnection.server !== input.config.server) {
256
607
  throw new Error("The separate outcome-probe credential does not match this Gateway project and server.");
257
608
  }
258
609
  if (probeConnection.apiKey === input.connection.apiKey)
259
610
  throw new Error("The execution Gateway and outcome probe must use separate Hosted credentials.");
611
+ if (workerConfig.probeApiKeyId)
612
+ await verifyConfiguredProbeCredential(input.config, probeConnection.apiKey, input.fetch ?? fetch);
260
613
  const adapterContext = Object.freeze({
261
614
  adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion,
262
615
  projectId: input.config.projectId,
263
616
  hosted: runtimeHostedTransport(input.connection, input.fetch ?? fetch),
264
617
  storageDirectory: resolve(input.directory, "data", "runtime-actions"),
618
+ ...(workerConfig.runtimeKeyId && workerConfig.runtimeSigningKeyHandle && workerConfig.targetWriteCredentialHandle ? {
619
+ runtimeIdentity: Object.freeze({ id: workerConfig.runtimeIdentityId, keyId: workerConfig.runtimeKeyId }),
620
+ runtimeSigningKey: Object.freeze({ handle: workerConfig.runtimeSigningKeyHandle, access: "READ_ONLY" }),
621
+ targetWriteCredential: Object.freeze({ handle: workerConfig.targetWriteCredentialHandle, access: "WRITE_ONLY" }),
622
+ reconcileAuditCredential: Object.freeze({ handle: workerConfig.reconcileAuditCredentialHandle, access: "READ_ONLY" }),
623
+ } : {}),
265
624
  });
266
625
  const isolatedProbeConfig = {
267
626
  modulePath: probeModulePath,
@@ -276,6 +635,10 @@ export async function createConfiguredRuntimeActionWorker(input) {
276
635
  || typeof runtime.prepareClaim !== "function" || typeof runtime.execute !== "function" || typeof runtime.reconcile !== "function") {
277
636
  throw new Error("Runtime adapter does not match the exact configured id/version or read-only reconciliation contract.");
278
637
  }
638
+ if (workerConfig.adapterId === LOCAL_SANDBOX_ADAPTER_ID && JSON.stringify(runtime.capabilities) !== JSON.stringify({
639
+ targetSystem: "WitnoraLocalSandbox", operations: ["UPDATE"], origins: [workerConfig.sandboxOrigin], resources: ["mock-state/*"],
640
+ }))
641
+ throw new Error("Generated Runtime adapter capabilities do not match the exact localhost fixture contract.");
279
642
  const requestFetch = input.fetch ?? fetch;
280
643
  const primary = projectTransport(input.connection, requestFetch);
281
644
  const verifier = projectTransport(probeConnection, requestFetch);
@@ -357,7 +720,9 @@ function isConfiguredRuntimeProposal(proposal, config) {
357
720
  const intent = proposal.executionIntent;
358
721
  return Boolean(intent && typeof intent === "object" && !Array.isArray(intent)
359
722
  && intent.adapterId === config.adapterId
360
- && typeof intent.adapterVersionConstraint === "string");
723
+ && typeof intent.adapterVersionConstraint === "string"
724
+ && (config.adapterId !== LOCAL_SANDBOX_ADAPTER_ID || proposal.targetSystem === "WitnoraLocalSandbox")
725
+ && (!config.mandateId || proposal.mandateId === config.mandateId));
361
726
  }
362
727
  export async function startManagedCustomerGateway(options = {}) {
363
728
  const repository = resolve(options.repository ?? process.cwd());
@@ -443,7 +808,7 @@ export async function statusManagedCustomerGateway(options = {}) {
443
808
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
444
809
  const baseUrl = `http://${config.host}:${config.port}`;
445
810
  const runtime = await readRuntime(directory);
446
- const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, Boolean(config.runtimeWorker));
811
+ const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, config.runtimeWorker);
447
812
  const base = {
448
813
  schemaVersion: "witnora.managed_gateway_status.v0.1",
449
814
  baseUrl,
@@ -560,19 +925,34 @@ function parseConfig(raw) {
560
925
  throw new Error("Gateway host and port are invalid.");
561
926
  if (value.runtimeWorker)
562
927
  validateRuntimeWorkerConfig(value.runtimeWorker);
563
- return value;
928
+ const config = value;
929
+ if (config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID && gatewayConfigDigest(config) !== config.runtimeWorker.configDigestSha256) {
930
+ throw new Error("Generated localhost Runtime config digest does not match gateway.json.");
931
+ }
932
+ return config;
564
933
  }
565
934
  function validateRuntimeWorkerConfig(value) {
566
935
  if (value.enabled !== true || !value.adapterModulePath || !/^[a-f0-9]{64}$/.test(value.adapterModuleSha256)
567
936
  || !value.probeModulePath || !/^[a-f0-9]{64}$/.test(value.probeModuleSha256)
568
937
  || !value.adapterId || !/^v?\d+\.\d+\.\d+$/.test(value.adapterVersion) || !value.probeId
569
- || !value.probeConnectionName || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
938
+ || (!value.probeConnectionName && !credentialHandle(value.probeHostedCredentialHandle ?? ""))
939
+ || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
570
940
  throw new Error("gateway.json runtimeWorker requires separate exact adapter/probe module digests, adapter id/version, probe credentials, and runtime identity.");
571
941
  }
572
942
  if (value.adapterModulePath === value.probeModulePath || value.adapterModuleSha256 === value.probeModuleSha256)
573
943
  throw new Error("runtimeWorker adapter and outcome probe modules must be independently pinned.");
574
944
  if (value.adapterId === value.probeId)
575
945
  throw new Error("runtimeWorker adapter and outcome probe must be separate.");
946
+ const bootstrapFields = [value.probeApiKeyId, value.runtimeBootstrapId, value.runtimeSetupPlanId].filter(Boolean);
947
+ if (bootstrapFields.length !== 0 && (bootstrapFields.length !== 3 || bootstrapFields.some((field) => !/^[A-Za-z0-9._:-]{1,200}$/.test(field)))) {
948
+ throw new Error("runtimeWorker Hosted bootstrap probe generation binding is incomplete.");
949
+ }
950
+ if (value.adapterId === LOCAL_SANDBOX_ADAPTER_ID && (!value.mandateId || !value.sandboxPrincipalId || !value.runtimeKeyId
951
+ || !credentialHandle(value.runtimeSigningKeyHandle ?? "") || !credentialHandle(value.targetWriteCredentialHandle ?? "") || !credentialHandle(value.reconcileAuditCredentialHandle ?? "")
952
+ || !localSandboxOrigin(value.sandboxOrigin) || value.fixtureContractSha256 !== LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256
953
+ || !validDigest(value.mandateDigestSha256 ?? "") || !validDigest(value.configDigestSha256 ?? ""))) {
954
+ throw new Error("The generated localhost sandbox adapter requires exact mandate, runtime signing-key, and target write-credential references.");
955
+ }
576
956
  if (value.grantTtlSeconds !== undefined && (!Number.isInteger(value.grantTtlSeconds) || value.grantTtlSeconds < 15 || value.grantTtlSeconds > 300))
577
957
  throw new Error("runtimeWorker grantTtlSeconds must be between 15 and 300.");
578
958
  if (value.pollIntervalMs !== undefined && (!Number.isInteger(value.pollIntervalMs) || value.pollIntervalMs < 250 || value.pollIntervalMs > 30_000))
@@ -599,14 +979,75 @@ function gatewayPort(value, fallback) {
599
979
  return parsed;
600
980
  }
601
981
  function gatewayReadme(config) {
602
- return `# Witnora customer-owned Gateway\n\nThis directory configures a metadata-only Gateway for project \`${config.projectId}\`. The Setup Autopilot starts it in the background after browser authorization.\n\n## Operations\n\n\`\`\`bash\nnpx witnora@latest gateway status\nnpx witnora@latest gateway logs\nnpx witnora@latest gateway restart\nnpx witnora@latest gateway stop\n\`\`\`\n\n\`gateway run\` remains available as a foreground debugging command. In the Agent repository, import the generated client at one meaningful sandbox workflow boundary:\n\n\`\`\`js\nimport { randomUUID } from "node:crypto";\nimport { witnoraGateway } from "./.witnora/gateway/client.mjs";\n\nconst runId = randomUUID();\nawait witnoraGateway.start(runId, { workflow: "sandbox-workflow" });\ntry {\n // Run the existing customer workflow here. Do not add raw inputs or outputs.\n await witnoraGateway.event(runId, "workflow.step.completed", { step: "meaningful-boundary" });\n await witnoraGateway.complete(runId, { status: "completed" });\n} catch (error) {\n await witnoraGateway.event(runId, "workflow.failed", { errorType: error?.name ?? "Error" });\n await witnoraGateway.complete(runId, { status: "failed" });\n throw error;\n}\n\`\`\`\n\nThe generated client reads only the local ignored Gateway token and sends metadata to \`http://${config.host}:${config.port}\`. The Hosted API key and source-signing key stay in the Gateway process. Do not commit \`secrets.json\`, \`data/\`, or \`runtime/\`.\n\nThis reference process creates source-signed, durable **RECORDED** evidence. It does not claim complete mediation. **ENFORCED** requires the target write credential to be removed from the Agent and placed behind a controlled execution adapter. **OUTCOME VERIFIED** requires a separate read-only credential and independent probe.\n`;
982
+ const assuranceBoundary = config.runtimeWorker
983
+ ? "The generated localhost sandbox Runtime has a digest-pinned adapter, separate child read-only probe, and local credential boundary. Confirm `gateway doctor` reports **READY_FOR_RUNTIME**. This readiness does not establish coverage of real customer paths, CURRENT status, or a verified outcome."
984
+ : "This reference process creates source-signed, durable **RECORDED** evidence. It does not claim complete mediation. **ENFORCED** requires the target write credential to be removed from the Agent and placed behind a controlled execution adapter. **OUTCOME VERIFIED** requires a separate read-only credential and independent probe.";
985
+ return `# Witnora customer-owned Gateway\n\nThis directory configures a metadata-only Gateway for project \`${config.projectId}\`. The Setup Autopilot starts it in the background after browser authorization.\n\n## Operations\n\n\`\`\`bash\nnpx witnora@latest gateway status\nnpx witnora@latest gateway logs\nnpx witnora@latest gateway restart\nnpx witnora@latest gateway stop\n\`\`\`\n\n\`gateway run\` remains available as a foreground debugging command. In the Agent repository, import the generated client at one meaningful sandbox workflow boundary:\n\n\`\`\`js\nimport { randomUUID } from "node:crypto";\nimport { witnoraGateway } from "./.witnora/gateway/client.mjs";\n\nconst runId = randomUUID();\nawait witnoraGateway.start(runId, { workflow: "sandbox-workflow" });\ntry {\n // Run the existing customer workflow here. Do not add raw inputs or outputs.\n await witnoraGateway.event(runId, "workflow.step.completed", { step: "meaningful-boundary" });\n await witnoraGateway.complete(runId, { status: "completed" });\n} catch (error) {\n await witnoraGateway.event(runId, "workflow.failed", { errorType: error?.name ?? "Error" });\n await witnoraGateway.complete(runId, { status: "failed" });\n throw error;\n}\n\`\`\`\n\nThe generated client reads only the local ignored Gateway token and sends metadata to \`http://${config.host}:${config.port}\`. The Hosted API key and source-signing key stay in the Gateway process. Do not commit \`secrets.json\`, \`data/\`, or \`runtime/\`.\n\n${assuranceBoundary}\n`;
986
+ }
987
+ function gatewayConfigDigest(config) {
988
+ const runtimeWorker = config.runtimeWorker ? { ...config.runtimeWorker, configDigestSha256: undefined } : undefined;
989
+ return createHash("sha256").update(JSON.stringify(canonical({ ...config, runtimeWorker }))).digest("hex");
990
+ }
991
+ function canonical(value) {
992
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number")
993
+ return value;
994
+ if (Array.isArray(value))
995
+ return value.map(canonical);
996
+ if (value && typeof value === "object")
997
+ return Object.fromEntries(Object.entries(value)
998
+ .filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonical(item)]));
999
+ return undefined;
1000
+ }
1001
+ function activeWindow(validFrom, expiresAt, now) {
1002
+ const starts = Date.parse(String(validFrom));
1003
+ const expires = Date.parse(String(expiresAt));
1004
+ return Number.isFinite(starts) && Number.isFinite(expires) && starts <= now && expires > now;
1005
+ }
1006
+ function validDigest(value) { return /^[a-f0-9]{64}$/.test(value); }
1007
+ function localSandboxOrigin(value) {
1008
+ try {
1009
+ if (!value)
1010
+ return false;
1011
+ const url = new URL(value);
1012
+ return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port) && url.pathname === "/" && !url.search && !url.hash && !url.username && !url.password;
1013
+ }
1014
+ catch {
1015
+ return false;
1016
+ }
603
1017
  }
604
1018
  function gatewayClient(config) {
605
1019
  const requestHelper = `async function actionRequest(path, init = {}) {\n const headers = new Headers(init.headers);\n headers.set("authorization", \`Bearer \${await token()}\`);\n if (init.body) headers.set("content-type", "application/json");\n const response = await fetch(\`\${baseUrl}\${path}\`, { ...init, headers });\n const result = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(result.error ?? \`Witnora Gateway returned HTTP \${response.status}.\`);\n return result;\n}\n`;
606
1020
  const actionMethods = ` proposeAction(proposal, idempotencyKey = proposal?.externalId) {\n if (typeof idempotencyKey !== "string" || !idempotencyKey) throw new Error("Witnora action proposal requires an idempotency key or externalId.");\n return actionRequest("/v1/actions", { method: "POST", body: JSON.stringify({ proposal, idempotencyKey }) });\n },\n getAction(actionId) {\n if (!/^[A-Za-z0-9._:-]+$/.test(actionId)) throw new Error("Witnora actionId contains unsupported characters.");\n return actionRequest(\`/v1/actions/\${encodeURIComponent(actionId)}\`);\n },\n issueExecutionGrant(actionId, grant, idempotencyKey = \`grant:\${actionId}\`) {\n if (!/^[A-Za-z0-9._:-]+$/.test(actionId)) throw new Error("Witnora actionId contains unsupported characters.");\n return actionRequest(\`/v1/actions/\${encodeURIComponent(actionId)}/execution-grant\`, { method: "POST", body: JSON.stringify({ grant, idempotencyKey }) });\n },\n`;
1021
+ const localSandboxMethod = config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID && config.runtimeWorker.mandateId && config.runtimeWorker.sandboxPrincipalId
1022
+ ? ` async proposeLocalSandboxUpdate({ resourceId, status = "UPDATED", externalId = \`runtime-sandbox-\${crypto.randomUUID()}\`, agentBuildId = "witnora-local-sandbox-task@1.0.0" }) {\n if (!/^[A-Za-z0-9._:-]+$/.test(resourceId)) throw new Error("Local sandbox resourceId contains unsupported characters.");\n const approvedParameters = { resourceId, status };\n const digest = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(agentBuildId))).toString("hex");\n const proposal = {\n externalId, principal: { id: "${config.runtimeWorker.sandboxPrincipalId}", version: "sandbox-v1" },\n actionType: "UPDATE", targetSystem: "WitnoraLocalSandbox", requestedPermissions: [], sensitive: true, expectedState: approvedParameters,\n mandateId: "${config.runtimeWorker.mandateId}", requireMandate: true,\n executionIntent: { adapterId: "${LOCAL_SANDBOX_ADAPTER_ID}", adapterVersionConstraint: "^${LOCAL_SANDBOX_ADAPTER_VERSION}", allowedOrigins: ["${config.runtimeWorker.sandboxOrigin}"], approvedParameters, outcomePredicate: { type: "state_subset", expected: approvedParameters }, agentBuildId, agentBuildDigest: digest, allowedOperation: "UPDATE", allowedResource: \`mock-state/\${resourceId}\` },\n };\n return this.proposeAction(proposal, externalId);\n },\n`
1023
+ : "";
607
1024
  return recordedGatewayClient(config)
608
1025
  .replace("export const witnoraGateway = {", `${requestHelper}\nexport const witnoraGateway = {`)
609
- .replace(/\n};\n$/, `\n${actionMethods}};\n`);
1026
+ .replace(/\n};\n$/, `\n${actionMethods}${localSandboxMethod}};\n`);
1027
+ }
1028
+ async function loadProbeHostedConnection(config, projectId, server, configHome) {
1029
+ if (config.probeConnectionName)
1030
+ return loadConnection(config.probeConnectionName, { configHome });
1031
+ if (!config.probeHostedCredentialHandle)
1032
+ return undefined;
1033
+ return { projectId, server, apiKey: await readSecretProviderHandle(config.probeHostedCredentialHandle) };
1034
+ }
1035
+ async function readSecretProviderHandle(handle) {
1036
+ if (!credentialHandle(handle))
1037
+ throw new Error("A valid opaque file:// credential handle is required.");
1038
+ const url = new URL(handle);
1039
+ if (url.protocol !== "file:")
1040
+ throw new Error("Only file:// credential handles are supported by the localhost sandbox Runtime.");
1041
+ const value = (await readFile(fileURLToPath(url), "utf8")).trim();
1042
+ if (!value)
1043
+ throw new Error("The referenced local credential is empty.");
1044
+ return value;
1045
+ }
1046
+ async function ed25519PublicKeyPem(handle) {
1047
+ const key = createPrivateKey(await readSecretProviderHandle(handle));
1048
+ if (key.asymmetricKeyType !== "ed25519")
1049
+ throw new Error("The Runtime signing-key reference must resolve to an Ed25519 private key.");
1050
+ return createPublicKey(key).export({ type: "spki", format: "pem" }).toString();
610
1051
  }
611
1052
  function recordedGatewayClient(config) {
612
1053
  return `import { readFile } from "node:fs/promises";\n\nconst baseUrl = "http://${config.host}:${config.port}";\nlet gatewayToken;\n\nasync function token() {\n if (gatewayToken) return gatewayToken;\n const secrets = JSON.parse(await readFile(new URL("./secrets.json", import.meta.url), "utf8"));\n if (typeof secrets.gatewayToken !== "string" || secrets.gatewayToken.length < 32) {\n throw new Error("Witnora local Gateway token is missing or invalid.");\n }\n gatewayToken = secrets.gatewayToken;\n return gatewayToken;\n}\n\nasync function post(runId, operation, body) {\n if (!/^[A-Za-z0-9._:-]+$/.test(runId)) throw new Error("Witnora runId contains unsupported characters.");\n const response = await fetch(\`\${baseUrl}/v1/runs/\${encodeURIComponent(runId)}/\${operation}\`, {\n method: "POST",\n headers: { authorization: \`Bearer \${await token()}\`, "content-type": "application/json" },\n body: JSON.stringify(body),\n });\n const result = await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(result.error ?? \`Witnora Gateway returned HTTP \${response.status}.\`);\n return result;\n}\n\nexport const witnoraGateway = {\n start(runId, metadata = {}) {\n return post(runId, "start", { payload: metadata, idempotencyKey: "run-start" });\n },\n event(runId, type, metadata = {}, idempotencyKey = \`\${type}-\${crypto.randomUUID()}\`) {\n return post(runId, "events", { type, payload: metadata, idempotencyKey });\n },\n complete(runId, metadata = {}) {\n return post(runId, "complete", {\n payload: metadata,\n evidenceStrength: {\n schemaVersion: "agentcert.evidence_strength.v0.1",\n level: "recorded",\n claims: [],\n limitations: ["No write-credential mediation or independent outcome probe is configured."],\n },\n idempotencyKey: "run-complete",\n });\n },\n};\n`;
@@ -618,6 +1059,12 @@ async function writeExclusive(path, content, force, mode) {
618
1059
  await writeFile(path, content, { encoding: "utf8", mode });
619
1060
  await chmod(path, mode).catch(() => undefined);
620
1061
  }
1062
+ async function atomicWrite(path, content, mode) {
1063
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1064
+ await writeFile(temporary, content, { encoding: "utf8", mode });
1065
+ await rename(temporary, path);
1066
+ await chmod(path, mode).catch(() => undefined);
1067
+ }
621
1068
  async function exists(path) {
622
1069
  try {
623
1070
  await access(path);
@@ -636,14 +1083,29 @@ function gatewayPaths(directory) {
636
1083
  join(directory, "README.md"),
637
1084
  ];
638
1085
  }
639
- async function gatewayHealth(baseUrl, requestFetch, requireRuntimeWorker = false) {
1086
+ async function gatewayHealth(baseUrl, requestFetch, runtimeWorker) {
640
1087
  try {
641
1088
  const response = await requestFetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(800) });
642
1089
  if (!response.ok)
643
1090
  return undefined;
644
1091
  const value = await response.json();
645
- if (requireRuntimeWorker && value.actionWorker?.ready !== true)
1092
+ if (runtimeWorker && value.actionWorker?.ready !== true)
646
1093
  return undefined;
1094
+ const processBinding = value.actionWorker?.runtimeBinding;
1095
+ if (runtimeWorker?.probeApiKeyId && (processBinding?.configDigestSha256 !== runtimeWorker.configDigestSha256
1096
+ || processBinding?.probeApiKeyId !== runtimeWorker.probeApiKeyId))
1097
+ return undefined;
1098
+ if (runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID) {
1099
+ const readCredential = await readSecretProviderHandle(runtimeWorker.probeTargetCredentialHandle);
1100
+ const fixtureResponse = await requestFetch(`${runtimeWorker.sandboxOrigin}/healthz`, {
1101
+ headers: { "x-sandbox-read-credential": readCredential }, signal: AbortSignal.timeout(800), redirect: "error",
1102
+ });
1103
+ if (!fixtureResponse.ok)
1104
+ return undefined;
1105
+ const fixture = await fixtureResponse.json();
1106
+ if (fixture.contractSha256 !== runtimeWorker.fixtureContractSha256 || fixture.origin !== runtimeWorker.sandboxOrigin)
1107
+ return undefined;
1108
+ }
647
1109
  return typeof value.collectorId === "string" && value.collectorId ? { collectorId: value.collectorId } : undefined;
648
1110
  }
649
1111
  catch {