witnora 0.13.4 → 0.13.6

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,238 @@ 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
+ const generatedClient = clientRaw === gatewayClient(current)
135
+ || (!current.runtimeWorker && clientRaw === recordedGatewayClient(current));
136
+ if (!generatedClient || readmeRaw !== gatewayReadme(current))
137
+ throw new Error("Existing Gateway files differ from the Witnora-generated version; refusing to overwrite them.");
138
+ if (current.runtimeWorker && current.runtimeWorker.adapterId !== LOCAL_SANDBOX_ADAPTER_ID) {
139
+ throw new Error("Existing Gateway already has a different Runtime worker; preserve it and reconfigure in Advanced mode.");
140
+ }
141
+ const runtimeKit = await prepareRuntimeSandboxKit(options.runtimeReferences, options.authorization, options.configHome, options.fetch ?? fetch, directory);
142
+ const next = { ...current, coverage: { recorded: "configured", enforced: "configured", outcomeVerified: "configured" }, runtimeWorker: runtimeKit.config };
143
+ next.runtimeWorker.configDigestSha256 = gatewayConfigDigest(next);
144
+ const replacingGeneratedRuntime = current.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID;
145
+ if (replacingGeneratedRuntime) {
146
+ const [adapterBytes, probeBytes] = await Promise.all([
147
+ readFile(resolve(directory, current.runtimeWorker.adapterModulePath)),
148
+ readFile(resolve(directory, current.runtimeWorker.probeModulePath)),
149
+ ]);
150
+ if (createHash("sha256").update(adapterBytes).digest("hex") !== current.runtimeWorker.adapterModuleSha256
151
+ || createHash("sha256").update(probeBytes).digest("hex") !== current.runtimeWorker.probeModuleSha256) {
152
+ throw new Error("Existing generated Runtime modules were modified; refusing to overwrite them.");
153
+ }
154
+ if (sameRuntimeGeneration(current.runtimeWorker, next.runtimeWorker)) {
155
+ return { config: current, generatedFiles: [], changed: false, rollback: async () => undefined };
156
+ }
157
+ }
158
+ const backupDirectory = join(directory, "data", "runtime-upgrade-backups", `${Date.now()}-${randomBytes(4).toString("hex")}`);
159
+ await mkdir(backupDirectory, { recursive: true });
160
+ const previousFiles = new Map([[configPath, configRaw], [clientPath, clientRaw], [readmePath, readmeRaw]]);
161
+ if (replacingGeneratedRuntime) {
162
+ previousFiles.set(resolve(directory, current.runtimeWorker.adapterModulePath), await readFile(resolve(directory, current.runtimeWorker.adapterModulePath)));
163
+ previousFiles.set(resolve(directory, current.runtimeWorker.probeModulePath), await readFile(resolve(directory, current.runtimeWorker.probeModulePath)));
164
+ }
165
+ await Promise.all([...previousFiles.entries()].map(([path, content]) => writeFile(join(backupDirectory, basename(path)), content, { mode: 0o600 })));
166
+ const created = [];
167
+ const restore = async () => {
168
+ await Promise.all([...previousFiles.entries()].map(([path, content]) => writeFile(path, content)));
169
+ for (const path of [...created].reverse())
170
+ await rm(path, { force: true }).catch(() => undefined);
171
+ };
172
+ try {
173
+ if (replacingGeneratedRuntime) {
174
+ await atomicWrite(join(directory, runtimeKit.config.adapterModulePath), runtimeKit.adapterSource, 0o600);
175
+ await atomicWrite(join(directory, runtimeKit.config.probeModulePath), runtimeKit.probeSource, 0o600);
176
+ }
177
+ else {
178
+ for (const [path, content, mode] of [
179
+ ...runtimeKit.secretFiles,
180
+ [join(directory, runtimeKit.config.adapterModulePath), runtimeKit.adapterSource, 0o600],
181
+ [join(directory, runtimeKit.config.probeModulePath), runtimeKit.probeSource, 0o600],
182
+ ]) {
183
+ await writeExclusive(path, content, false, mode);
184
+ created.push(path);
185
+ }
186
+ }
187
+ await atomicWrite(configPath, `${JSON.stringify(next, null, 2)}\n`, 0o644);
188
+ await atomicWrite(clientPath, gatewayClient(next), 0o644);
189
+ await atomicWrite(readmePath, gatewayReadme(next), 0o644);
190
+ }
191
+ catch (error) {
192
+ await restore();
193
+ throw error;
194
+ }
195
+ return { config: next, generatedFiles: [configPath, clientPath, readmePath, ...created, ...(replacingGeneratedRuntime ? [join(directory, runtimeKit.config.adapterModulePath), join(directory, runtimeKit.config.probeModulePath)] : [])], changed: true, rollback: restore };
196
+ }
197
+ function sameRuntimeGeneration(left, right) {
198
+ return JSON.stringify({ ...left, configDigestSha256: undefined }) === JSON.stringify({ ...right, configDigestSha256: undefined });
199
+ }
200
+ export class RuntimeSetupNotReadyError extends Error {
201
+ constructor(message) { super(message); this.name = "RuntimeSetupNotReadyError"; }
202
+ }
203
+ async function prepareRuntimeSandboxKit(references, authorization, configHome, requestFetch, outDir) {
204
+ try {
205
+ return await prepareRuntimeSandboxKitUnchecked(references, authorization, configHome, requestFetch, outDir);
206
+ }
207
+ catch (error) {
208
+ throw new RuntimeSetupNotReadyError(error instanceof Error ? error.message : String(error));
209
+ }
210
+ }
211
+ async function prepareRuntimeSandboxKitUnchecked(references, authorization, configHome, requestFetch, outDir) {
212
+ validateRuntimeReferences(references);
213
+ const localRuntimePublicKeyPem = await ed25519PublicKeyPem(references.runtimeSigningKeyHandle);
214
+ const probe = references.probeConnectionName
215
+ ? await loadConnection(references.probeConnectionName, { configHome })
216
+ : references.probeHostedCredentialHandle
217
+ ? { projectId: authorization.projectId, server: authorization.server, apiKey: await readSecretProviderHandle(references.probeHostedCredentialHandle) }
218
+ : undefined;
219
+ if (!probe || probe.projectId !== authorization.projectId || probe.server !== authorization.server || probe.apiKey === authorization.apiKey) {
220
+ throw new Error("The existing outcome-probe connection must be separate and bound to this project and server.");
221
+ }
222
+ const response = await requestFetch(`${authorization.server}/v1/projects/${encodeURIComponent(authorization.projectId)}/runtime-identities`, {
223
+ headers: { authorization: `Bearer ${authorization.apiKey}` },
224
+ });
225
+ const body = await response.json().catch(() => ({}));
226
+ if (!response.ok)
227
+ throw new Error(String(body.error ?? `Runtime identity lookup failed with HTTP ${response.status}.`));
228
+ const identity = body.runtimeIdentities?.find((item) => item.runtimeIdentityId === references.runtimeIdentityId && item.keyId === references.runtimeKeyId);
229
+ const now = Date.now();
230
+ if (!identity || identity.status !== "ACTIVE" || !Number.isFinite(Date.parse(String(identity.validFrom))) || !Number.isFinite(Date.parse(String(identity.validUntil)))
231
+ || Date.parse(String(identity.validFrom)) > now || Date.parse(String(identity.validUntil)) <= now) {
232
+ throw new Error("The referenced runtime identity and key are not active for this project.");
233
+ }
234
+ if (!new Set(["SETUP_AUTOPILOT_SANDBOX", "DEVELOPMENT_FIXTURE"]).has(String(identity.registrationMethod)) || !Array.isArray(identity.adapterCapabilities)
235
+ || identity.adapterCapabilities.length !== 1 || identity.adapterCapabilities[0] !== LOCAL_SANDBOX_ADAPTER_ID) {
236
+ throw new Error("The Hosted runtime identity is not exclusively bound to the local sandbox adapter fixture.");
237
+ }
238
+ let hostedRuntimePublicKeyPem;
239
+ try {
240
+ hostedRuntimePublicKeyPem = createPublicKey(String(identity.publicKeyPem)).export({ type: "spki", format: "pem" }).toString();
241
+ }
242
+ catch {
243
+ throw new Error("The Hosted runtime identity has no valid public key binding.");
244
+ }
245
+ if (hostedRuntimePublicKeyPem !== localRuntimePublicKeyPem)
246
+ throw new Error("The local Runtime signing key does not match the Hosted runtime identity.");
247
+ const mandateResponse = await requestFetch(`${authorization.server}/v1/projects/${encodeURIComponent(authorization.projectId)}/mandates/${encodeURIComponent(references.mandateId)}`, {
248
+ headers: { authorization: `Bearer ${authorization.apiKey}` },
249
+ });
250
+ const mandate = await mandateResponse.json().catch(() => ({}));
251
+ if (!mandateResponse.ok)
252
+ throw new Error(String(mandate.error ?? `Mandate lookup failed with HTTP ${mandateResponse.status}.`));
253
+ const mandatePayload = mandate.payload && typeof mandate.payload === "object" && !Array.isArray(mandate.payload) ? mandate.payload : {};
254
+ const mandateConstraints = mandatePayload.constraints && typeof mandatePayload.constraints === "object" && !Array.isArray(mandatePayload.constraints)
255
+ ? mandatePayload.constraints : {};
256
+ if (mandate.id !== references.mandateId || mandate.status !== "ACTIVE"
257
+ || !validDigest(String(mandate.digestSha256 ?? ""))
258
+ || !activeWindow(mandatePayload.validFrom, mandatePayload.expiresAt, now)
259
+ || !Number.isInteger(mandatePayload.maxUses) || Number(mandatePayload.maxUses) < 1 || Number(mandatePayload.maxUses) > 1_000
260
+ || mandatePayload.maxDelegationDepth !== 0 || mandatePayload.parentMandateId !== undefined
261
+ || JSON.stringify(mandatePayload.audience) !== JSON.stringify(["WitnoraLocalSandbox"])
262
+ || JSON.stringify(mandatePayload.permittedActionClasses) !== JSON.stringify(["UPDATE"])
263
+ || !Array.isArray(mandatePayload.permittedOperations) || mandatePayload.permittedOperations.length !== 1 || !["UPDATE", "WitnoraLocalSandbox:UPDATE"].includes(String(mandatePayload.permittedOperations[0]))
264
+ || !Array.isArray(mandatePayload.permittedResources) || mandatePayload.permittedResources.length !== 1 || !["mock-state/*", "WitnoraLocalSandbox:mock-state/*"].includes(String(mandatePayload.permittedResources[0]))
265
+ || mandateConstraints.approvalRequirement !== "HUMAN" || mandateConstraints.rollbackRequired !== true
266
+ || mandateConstraints.outcomePredicateRequirement !== "state_subset"
267
+ || !Array.isArray(mandateConstraints.allowedEnvironment) || mandateConstraints.allowedEnvironment.length !== 1 || !["sandbox", "local"].includes(String(mandateConstraints.allowedEnvironment[0]))
268
+ || mandateConstraints.payment !== undefined || mandateConstraints.monetaryLimit !== undefined) {
269
+ throw new Error("The referenced mandate is not an active localhost sandbox UPDATE mandate.");
270
+ }
271
+ if (typeof mandatePayload.granteeIdentityId !== "string" || !mandatePayload.granteeIdentityId)
272
+ throw new Error("The referenced sandbox mandate has no grantee identity binding.");
273
+ const sandboxOrigin = references.sandboxOrigin ?? await findAvailableRuntimeSandboxOrigin();
274
+ const generated = generateRuntimeSandboxKit(sandboxOrigin);
275
+ if (references.adapterDigestSha256 && references.adapterDigestSha256 !== generated.adapterSha256
276
+ || references.probeDigestSha256 && references.probeDigestSha256 !== generated.probeSha256
277
+ || references.fixtureContractDigestSha256 && references.fixtureContractDigestSha256 !== LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256) {
278
+ throw new Error("Generated Runtime modules do not match the Hosted bootstrap contract.");
279
+ }
280
+ if (references.probeApiKeyId) {
281
+ if (!references.bootstrapId || !references.setupPlanId)
282
+ throw new Error("Hosted Runtime bootstrap references are incomplete.");
283
+ await verifyHostedProbeCredential({
284
+ projectId: authorization.projectId, server: authorization.server,
285
+ setupPlanId: references.setupPlanId, bootstrapId: references.bootstrapId,
286
+ probeApiKeyId: references.probeApiKeyId, apiKey: probe.apiKey, fetch: requestFetch,
287
+ });
288
+ }
289
+ const sandboxSecretsDirectory = join(outDir, "data", "runtime-sandbox");
290
+ const readCredentialPath = join(sandboxSecretsDirectory, "read-credential.txt");
291
+ const writeCredentialPath = join(sandboxSecretsDirectory, "write-credential.txt");
292
+ const auditCredentialPath = join(sandboxSecretsDirectory, "audit-reconcile-credential.txt");
293
+ const readCredential = randomBytes(32).toString("base64url");
294
+ const writeCredential = randomBytes(32).toString("base64url");
295
+ const auditCredential = randomBytes(32).toString("base64url");
296
+ return {
297
+ adapterSource: generated.adapterSource,
298
+ probeSource: generated.probeSource,
299
+ config: {
300
+ enabled: true,
301
+ adapterModulePath: "runtime/local-sandbox-adapter.mjs",
302
+ adapterModuleSha256: generated.adapterSha256,
303
+ probeModulePath: "runtime/local-sandbox-probe.mjs",
304
+ probeModuleSha256: generated.probeSha256,
305
+ adapterId: LOCAL_SANDBOX_ADAPTER_ID,
306
+ adapterVersion: LOCAL_SANDBOX_ADAPTER_VERSION,
307
+ probeId: LOCAL_SANDBOX_PROBE_ID,
308
+ ...(references.probeConnectionName ? { probeConnectionName: references.probeConnectionName } : { probeHostedCredentialHandle: references.probeHostedCredentialHandle }),
309
+ ...(references.probeApiKeyId ? { probeApiKeyId: references.probeApiKeyId, runtimeBootstrapId: references.bootstrapId, runtimeSetupPlanId: references.setupPlanId } : {}),
310
+ probeTargetCredentialHandle: pathToFileURL(readCredentialPath).href,
311
+ targetWriteCredentialHandle: pathToFileURL(writeCredentialPath).href,
312
+ reconcileAuditCredentialHandle: pathToFileURL(auditCredentialPath).href,
313
+ sandboxOrigin,
314
+ fixtureContractSha256: LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256,
315
+ mandateDigestSha256: String(mandate.digestSha256),
316
+ runtimeIdentityId: references.runtimeIdentityId,
317
+ mandateId: references.mandateId,
318
+ sandboxPrincipalId: mandatePayload.granteeIdentityId,
319
+ runtimeKeyId: references.runtimeKeyId,
320
+ runtimeSigningKeyHandle: references.runtimeSigningKeyHandle,
321
+ grantTtlSeconds: 120,
322
+ pollIntervalMs: 2_000,
323
+ },
324
+ secretFiles: [[readCredentialPath, `${readCredential}\n`, 0o600], [writeCredentialPath, `${writeCredential}\n`, 0o600], [auditCredentialPath, `${auditCredential}\n`, 0o600]],
325
+ };
326
+ }
327
+ function validateRuntimeReferences(value) {
328
+ if ((!value.probeConnectionName || !/^[A-Za-z0-9._-]{1,64}$/.test(value.probeConnectionName)) && !credentialHandle(value.probeHostedCredentialHandle ?? "")
329
+ || !value.mandateId || !value.runtimeIdentityId || !value.runtimeKeyId || !credentialHandle(value.runtimeSigningKeyHandle)) {
330
+ throw new Error("Runtime setup references must contain a named probe connection, active runtime identity/key, and opaque credential handles.");
331
+ }
332
+ for (const handle of [value.runtimeSigningKeyHandle, ...(value.probeHostedCredentialHandle ? [value.probeHostedCredentialHandle] : [])]) {
333
+ const url = new URL(handle);
334
+ if (url.protocol !== "file:")
335
+ throw new Error("The v0.1 localhost sandbox runtime supports file:// secret-provider handles only.");
336
+ }
337
+ if (value.probeHostedCredentialHandle === value.runtimeSigningKeyHandle)
338
+ throw new Error("Probe and runtime signing credentials must use separate handles.");
339
+ }
92
340
  export async function inspectCustomerGatewayFiles(options) {
93
341
  const repository = resolve(options.repository ?? process.cwd());
94
342
  const directory = resolve(repository, options.outDir ?? ".witnora/gateway");
@@ -147,11 +395,29 @@ export async function doctorCustomerGateway(options) {
147
395
  throw new Error("Runtime adapter or probe module digest does not match gateway.json.");
148
396
  const [primary, probe] = await Promise.all([
149
397
  loadConnection(config.connectionName, { configHome: options.configHome }),
150
- loadConnection(config.runtimeWorker.probeConnectionName, { configHome: options.configHome }),
398
+ loadProbeHostedConnection(config.runtimeWorker, config.projectId, config.server, options.configHome),
151
399
  ]);
152
400
  if (!primary || !probe || probe.projectId !== config.projectId || probe.server !== config.server || probe.apiKey === primary.apiKey) {
153
401
  throw new Error("A separate outcome-probe credential bound to this project is required.");
154
402
  }
403
+ if (config.runtimeWorker.probeApiKeyId)
404
+ await verifyConfiguredProbeCredential(config, probe.apiKey, options.fetch ?? fetch);
405
+ if (config.runtimeWorker.adapterId === LOCAL_SANDBOX_ADAPTER_ID) {
406
+ if (!config.runtimeWorker.runtimeKeyId || !config.runtimeWorker.runtimeSigningKeyHandle || !config.runtimeWorker.targetWriteCredentialHandle
407
+ || !config.runtimeWorker.mandateId || !config.runtimeWorker.sandboxPrincipalId) {
408
+ throw new Error("The generated localhost Runtime identity, mandate, and credential bindings are incomplete.");
409
+ }
410
+ await ed25519PublicKeyPem(config.runtimeWorker.runtimeSigningKeyHandle);
411
+ }
412
+ if (config.runtimeWorker.targetWriteCredentialHandle) {
413
+ const [readTargetCredential, writeTargetCredential, auditCredential] = await Promise.all([
414
+ readSecretProviderHandle(config.runtimeWorker.probeTargetCredentialHandle),
415
+ readSecretProviderHandle(config.runtimeWorker.targetWriteCredentialHandle),
416
+ readSecretProviderHandle(config.runtimeWorker.reconcileAuditCredentialHandle),
417
+ ]);
418
+ if (new Set([readTargetCredential, writeTargetCredential, auditCredential]).size !== 3)
419
+ throw new Error("Sandbox read, write, and reconciliation credentials must contain separate values.");
420
+ }
155
421
  checks.push({ id: "runtime_configuration", status: "PASS", message: "The digest-pinned adapter module and separate outcome-probe credential are present." });
156
422
  }
157
423
  catch (error) {
@@ -159,7 +425,7 @@ export async function doctorCustomerGateway(options) {
159
425
  }
160
426
  }
161
427
  try {
162
- const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, Boolean(config.runtimeWorker));
428
+ const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, config.runtimeWorker);
163
429
  if (!health)
164
430
  throw new Error("Gateway health or runtime-worker readiness was not established.");
165
431
  checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
@@ -206,35 +472,122 @@ export async function runCustomerGateway(options) {
206
472
  const keyRing = await (await exists(keyRingPath)
207
473
  ? CustomerSourceKeyRing.open(keyRingPath)
208
474
  : 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,
475
+ const sandboxFixture = config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID
476
+ ? await startRuntimeSandboxFixture({
477
+ origin: config.runtimeWorker.sandboxOrigin, directory: join(dataDirectory, "runtime-sandbox", "fixture"),
478
+ readCredentialHandle: config.runtimeWorker.probeTargetCredentialHandle,
479
+ writeCredentialHandle: config.runtimeWorker.targetWriteCredentialHandle,
480
+ auditCredentialHandle: config.runtimeWorker.reconcileAuditCredentialHandle,
481
+ contractSha256: config.runtimeWorker.fixtureContractSha256,
214
482
  })
215
483
  : 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)));
484
+ try {
485
+ const actionWorker = config.runtimeWorker
486
+ ? await createConfiguredRuntimeActionWorker({
487
+ directory, config, connection, configHome: options.configHome,
488
+ DurableApprovedActionWorker: (await durableWorker).DurableApprovedActionWorker,
489
+ FileActionCheckpointStore: (await durableWorker).FileActionCheckpointStore,
490
+ })
491
+ : undefined;
492
+ actionWorker?.start();
493
+ const gateway = await startCustomerOwnedCollectorGateway({
494
+ client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
495
+ keyRing,
496
+ gatewayToken: secrets.gatewayToken,
497
+ storageDirectory: dataDirectory,
498
+ host: process.env.WITNORA_GATEWAY_HOST?.trim() || config.host,
499
+ port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, config.port),
500
+ environment: "customer-owned",
501
+ ...(actionWorker && config.runtimeWorker ? { actionWorker: {
502
+ track: (input) => isConfiguredRuntimeProposal(input.proposal, config.runtimeWorker) ? actionWorker.track(input) : Promise.resolve(undefined),
503
+ status: async () => {
504
+ const status = await actionWorker.status();
505
+ const probeReady = await runtimeProbeCredentialReady(config, options.configHome);
506
+ return {
507
+ ...status,
508
+ ready: status.ready === true && probeReady,
509
+ runtimeBinding: {
510
+ configDigestSha256: config.runtimeWorker.configDigestSha256,
511
+ ...(config.runtimeWorker.probeApiKeyId ? { probeApiKeyId: config.runtimeWorker.probeApiKeyId } : {}),
512
+ },
513
+ ...(!probeReady ? { lastError: "Hosted outcome-probe credential is not active for this Runtime generation." } : {}),
514
+ ...(config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID
515
+ ? { fixtureReady: await runtimeSandboxFixtureReady(config.runtimeWorker) }
516
+ : {}),
517
+ };
518
+ },
519
+ close: () => actionWorker.stop(),
520
+ ...(config.runtimeWorker.adapterId === LOCAL_SANDBOX_ADAPTER_ID ? { runtimeBinding: {
521
+ classification: "LOCAL_SANDBOX_ONLY",
522
+ binding: {
523
+ collectorId: config.collectorId, sourceKeyId: keyRing.activeSigner().keyId,
524
+ configDigestSha256: config.runtimeWorker.configDigestSha256,
525
+ runtimeIdentity: { id: config.runtimeWorker.runtimeIdentityId },
526
+ mandateDigestSha256: config.runtimeWorker.mandateDigestSha256,
527
+ adapterDigestSha256: config.runtimeWorker.adapterModuleSha256,
528
+ probeDigestSha256: config.runtimeWorker.probeModuleSha256,
529
+ fixtureContractDigestSha256: config.runtimeWorker.fixtureContractSha256,
530
+ ...(config.runtimeWorker.probeApiKeyId ? { probeApiKeyId: config.runtimeWorker.probeApiKeyId } : {}),
531
+ },
532
+ fixtureReady: false,
533
+ } } : {}),
534
+ } } : {}),
535
+ });
536
+ process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
537
+ process.stdout.write(config.runtimeWorker
538
+ ? "Runtime worker: READY. Approved exact configured actions execute automatically, then use the separate read-only probe and Hosted signed receipt.\n"
539
+ : "Evidence ceiling: RECORDED. No exact target adapter and separate outcome probe are configured, so runtime writes remain fail-closed.\n");
540
+ for (const signal of ["SIGINT", "SIGTERM"]) {
541
+ process.once(signal, () => void Promise.allSettled([gateway.close(), ...(sandboxFixture ? [sandboxFixture.close()] : [])]).finally(() => process.exit(0)));
542
+ }
543
+ }
544
+ catch (error) {
545
+ await sandboxFixture?.close().catch(() => undefined);
546
+ throw error;
547
+ }
548
+ }
549
+ async function runtimeSandboxFixtureReady(config) {
550
+ try {
551
+ if (config.adapterId !== LOCAL_SANDBOX_ADAPTER_ID || !config.sandboxOrigin || !config.probeTargetCredentialHandle || !config.fixtureContractSha256)
552
+ return false;
553
+ const credential = await readSecretProviderHandle(config.probeTargetCredentialHandle);
554
+ const response = await fetch(`${config.sandboxOrigin}/healthz`, {
555
+ redirect: "error",
556
+ headers: { "x-sandbox-read-credential": credential },
557
+ signal: AbortSignal.timeout(2_000),
558
+ });
559
+ const body = await response.json().catch(() => ({}));
560
+ return response.ok && body.origin === config.sandboxOrigin && body.contractSha256 === config.fixtureContractSha256;
561
+ }
562
+ catch {
563
+ return false;
236
564
  }
237
565
  }
566
+ async function runtimeProbeCredentialReady(config, configHome) {
567
+ try {
568
+ const worker = config.runtimeWorker;
569
+ if (!worker?.probeApiKeyId)
570
+ return true;
571
+ const probe = await loadProbeHostedConnection(worker, config.projectId, config.server, configHome);
572
+ if (!probe)
573
+ return false;
574
+ await verifyConfiguredProbeCredential(config, probe.apiKey, fetch);
575
+ return true;
576
+ }
577
+ catch {
578
+ return false;
579
+ }
580
+ }
581
+ async function verifyConfiguredProbeCredential(config, apiKey, requestFetch) {
582
+ const worker = config.runtimeWorker;
583
+ if (!worker?.probeApiKeyId || !worker.runtimeBootstrapId || !worker.runtimeSetupPlanId)
584
+ throw new Error("Hosted Runtime probe generation binding is incomplete.");
585
+ await verifyHostedProbeCredential({
586
+ projectId: config.projectId, server: config.server,
587
+ setupPlanId: worker.runtimeSetupPlanId, bootstrapId: worker.runtimeBootstrapId,
588
+ probeApiKeyId: worker.probeApiKeyId, apiKey, fetch: requestFetch,
589
+ });
590
+ }
238
591
  export async function createConfiguredRuntimeActionWorker(input) {
239
592
  const workerConfig = input.config.runtimeWorker;
240
593
  if (!workerConfig?.enabled)
@@ -251,17 +604,25 @@ export async function createConfiguredRuntimeActionWorker(input) {
251
604
  const adapterModule = await import(`${pathToFileURL(adapterModulePath).href}?sha256=${adapterModuleDigest}`);
252
605
  if (typeof adapterModule.createWitnoraRuntimeAdapter !== "function")
253
606
  throw new Error("Runtime adapter module must export its factory.");
254
- const probeConnection = await loadConnection(workerConfig.probeConnectionName, { configHome: input.configHome });
607
+ const probeConnection = await loadProbeHostedConnection(workerConfig, input.config.projectId, input.config.server, input.configHome);
255
608
  if (!probeConnection || probeConnection.projectId !== input.config.projectId || probeConnection.server !== input.config.server) {
256
609
  throw new Error("The separate outcome-probe credential does not match this Gateway project and server.");
257
610
  }
258
611
  if (probeConnection.apiKey === input.connection.apiKey)
259
612
  throw new Error("The execution Gateway and outcome probe must use separate Hosted credentials.");
613
+ if (workerConfig.probeApiKeyId)
614
+ await verifyConfiguredProbeCredential(input.config, probeConnection.apiKey, input.fetch ?? fetch);
260
615
  const adapterContext = Object.freeze({
261
616
  adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion,
262
617
  projectId: input.config.projectId,
263
618
  hosted: runtimeHostedTransport(input.connection, input.fetch ?? fetch),
264
619
  storageDirectory: resolve(input.directory, "data", "runtime-actions"),
620
+ ...(workerConfig.runtimeKeyId && workerConfig.runtimeSigningKeyHandle && workerConfig.targetWriteCredentialHandle ? {
621
+ runtimeIdentity: Object.freeze({ id: workerConfig.runtimeIdentityId, keyId: workerConfig.runtimeKeyId }),
622
+ runtimeSigningKey: Object.freeze({ handle: workerConfig.runtimeSigningKeyHandle, access: "READ_ONLY" }),
623
+ targetWriteCredential: Object.freeze({ handle: workerConfig.targetWriteCredentialHandle, access: "WRITE_ONLY" }),
624
+ reconcileAuditCredential: Object.freeze({ handle: workerConfig.reconcileAuditCredentialHandle, access: "READ_ONLY" }),
625
+ } : {}),
265
626
  });
266
627
  const isolatedProbeConfig = {
267
628
  modulePath: probeModulePath,
@@ -276,6 +637,10 @@ export async function createConfiguredRuntimeActionWorker(input) {
276
637
  || typeof runtime.prepareClaim !== "function" || typeof runtime.execute !== "function" || typeof runtime.reconcile !== "function") {
277
638
  throw new Error("Runtime adapter does not match the exact configured id/version or read-only reconciliation contract.");
278
639
  }
640
+ if (workerConfig.adapterId === LOCAL_SANDBOX_ADAPTER_ID && JSON.stringify(runtime.capabilities) !== JSON.stringify({
641
+ targetSystem: "WitnoraLocalSandbox", operations: ["UPDATE"], origins: [workerConfig.sandboxOrigin], resources: ["mock-state/*"],
642
+ }))
643
+ throw new Error("Generated Runtime adapter capabilities do not match the exact localhost fixture contract.");
279
644
  const requestFetch = input.fetch ?? fetch;
280
645
  const primary = projectTransport(input.connection, requestFetch);
281
646
  const verifier = projectTransport(probeConnection, requestFetch);
@@ -357,7 +722,9 @@ function isConfiguredRuntimeProposal(proposal, config) {
357
722
  const intent = proposal.executionIntent;
358
723
  return Boolean(intent && typeof intent === "object" && !Array.isArray(intent)
359
724
  && intent.adapterId === config.adapterId
360
- && typeof intent.adapterVersionConstraint === "string");
725
+ && typeof intent.adapterVersionConstraint === "string"
726
+ && (config.adapterId !== LOCAL_SANDBOX_ADAPTER_ID || proposal.targetSystem === "WitnoraLocalSandbox")
727
+ && (!config.mandateId || proposal.mandateId === config.mandateId));
361
728
  }
362
729
  export async function startManagedCustomerGateway(options = {}) {
363
730
  const repository = resolve(options.repository ?? process.cwd());
@@ -443,7 +810,7 @@ export async function statusManagedCustomerGateway(options = {}) {
443
810
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
444
811
  const baseUrl = `http://${config.host}:${config.port}`;
445
812
  const runtime = await readRuntime(directory);
446
- const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, Boolean(config.runtimeWorker));
813
+ const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, config.runtimeWorker);
447
814
  const base = {
448
815
  schemaVersion: "witnora.managed_gateway_status.v0.1",
449
816
  baseUrl,
@@ -549,6 +916,9 @@ export function renderGatewayDoctor(result) {
549
916
  "",
550
917
  ].join("\n");
551
918
  }
919
+ export function isGatewayDoctorReady(result) {
920
+ return result.overall === "READY_TO_RECORD" || result.overall === "READY_FOR_RUNTIME";
921
+ }
552
922
  function parseConfig(raw) {
553
923
  const value = JSON.parse(raw);
554
924
  if (value.schemaVersion !== CONFIG_SCHEMA || !value.projectId || !value.server || !value.connectionName || !value.collectorId) {
@@ -560,19 +930,34 @@ function parseConfig(raw) {
560
930
  throw new Error("Gateway host and port are invalid.");
561
931
  if (value.runtimeWorker)
562
932
  validateRuntimeWorkerConfig(value.runtimeWorker);
563
- return value;
933
+ const config = value;
934
+ if (config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID && gatewayConfigDigest(config) !== config.runtimeWorker.configDigestSha256) {
935
+ throw new Error("Generated localhost Runtime config digest does not match gateway.json.");
936
+ }
937
+ return config;
564
938
  }
565
939
  function validateRuntimeWorkerConfig(value) {
566
940
  if (value.enabled !== true || !value.adapterModulePath || !/^[a-f0-9]{64}$/.test(value.adapterModuleSha256)
567
941
  || !value.probeModulePath || !/^[a-f0-9]{64}$/.test(value.probeModuleSha256)
568
942
  || !value.adapterId || !/^v?\d+\.\d+\.\d+$/.test(value.adapterVersion) || !value.probeId
569
- || !value.probeConnectionName || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
943
+ || (!value.probeConnectionName && !credentialHandle(value.probeHostedCredentialHandle ?? ""))
944
+ || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
570
945
  throw new Error("gateway.json runtimeWorker requires separate exact adapter/probe module digests, adapter id/version, probe credentials, and runtime identity.");
571
946
  }
572
947
  if (value.adapterModulePath === value.probeModulePath || value.adapterModuleSha256 === value.probeModuleSha256)
573
948
  throw new Error("runtimeWorker adapter and outcome probe modules must be independently pinned.");
574
949
  if (value.adapterId === value.probeId)
575
950
  throw new Error("runtimeWorker adapter and outcome probe must be separate.");
951
+ const bootstrapFields = [value.probeApiKeyId, value.runtimeBootstrapId, value.runtimeSetupPlanId].filter(Boolean);
952
+ if (bootstrapFields.length !== 0 && (bootstrapFields.length !== 3 || bootstrapFields.some((field) => !/^[A-Za-z0-9._:-]{1,200}$/.test(field)))) {
953
+ throw new Error("runtimeWorker Hosted bootstrap probe generation binding is incomplete.");
954
+ }
955
+ if (value.adapterId === LOCAL_SANDBOX_ADAPTER_ID && (!value.mandateId || !value.sandboxPrincipalId || !value.runtimeKeyId
956
+ || !credentialHandle(value.runtimeSigningKeyHandle ?? "") || !credentialHandle(value.targetWriteCredentialHandle ?? "") || !credentialHandle(value.reconcileAuditCredentialHandle ?? "")
957
+ || !localSandboxOrigin(value.sandboxOrigin) || value.fixtureContractSha256 !== LOCAL_SANDBOX_FIXTURE_CONTRACT_SHA256
958
+ || !validDigest(value.mandateDigestSha256 ?? "") || !validDigest(value.configDigestSha256 ?? ""))) {
959
+ throw new Error("The generated localhost sandbox adapter requires exact mandate, runtime signing-key, and target write-credential references.");
960
+ }
576
961
  if (value.grantTtlSeconds !== undefined && (!Number.isInteger(value.grantTtlSeconds) || value.grantTtlSeconds < 15 || value.grantTtlSeconds > 300))
577
962
  throw new Error("runtimeWorker grantTtlSeconds must be between 15 and 300.");
578
963
  if (value.pollIntervalMs !== undefined && (!Number.isInteger(value.pollIntervalMs) || value.pollIntervalMs < 250 || value.pollIntervalMs > 30_000))
@@ -599,14 +984,75 @@ function gatewayPort(value, fallback) {
599
984
  return parsed;
600
985
  }
601
986
  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`;
987
+ const assuranceBoundary = config.runtimeWorker
988
+ ? "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."
989
+ : "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.";
990
+ 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`;
991
+ }
992
+ function gatewayConfigDigest(config) {
993
+ const runtimeWorker = config.runtimeWorker ? { ...config.runtimeWorker, configDigestSha256: undefined } : undefined;
994
+ return createHash("sha256").update(JSON.stringify(canonical({ ...config, runtimeWorker }))).digest("hex");
995
+ }
996
+ function canonical(value) {
997
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number")
998
+ return value;
999
+ if (Array.isArray(value))
1000
+ return value.map(canonical);
1001
+ if (value && typeof value === "object")
1002
+ return Object.fromEntries(Object.entries(value)
1003
+ .filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonical(item)]));
1004
+ return undefined;
1005
+ }
1006
+ function activeWindow(validFrom, expiresAt, now) {
1007
+ const starts = Date.parse(String(validFrom));
1008
+ const expires = Date.parse(String(expiresAt));
1009
+ return Number.isFinite(starts) && Number.isFinite(expires) && starts <= now && expires > now;
1010
+ }
1011
+ function validDigest(value) { return /^[a-f0-9]{64}$/.test(value); }
1012
+ function localSandboxOrigin(value) {
1013
+ try {
1014
+ if (!value)
1015
+ return false;
1016
+ const url = new URL(value);
1017
+ return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port) && url.pathname === "/" && !url.search && !url.hash && !url.username && !url.password;
1018
+ }
1019
+ catch {
1020
+ return false;
1021
+ }
603
1022
  }
604
1023
  function gatewayClient(config) {
605
1024
  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
1025
  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`;
1026
+ const localSandboxMethod = config.runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID && config.runtimeWorker.mandateId && config.runtimeWorker.sandboxPrincipalId
1027
+ ? ` 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`
1028
+ : "";
607
1029
  return recordedGatewayClient(config)
608
1030
  .replace("export const witnoraGateway = {", `${requestHelper}\nexport const witnoraGateway = {`)
609
- .replace(/\n};\n$/, `\n${actionMethods}};\n`);
1031
+ .replace(/\n};\n$/, `\n${actionMethods}${localSandboxMethod}};\n`);
1032
+ }
1033
+ async function loadProbeHostedConnection(config, projectId, server, configHome) {
1034
+ if (config.probeConnectionName)
1035
+ return loadConnection(config.probeConnectionName, { configHome });
1036
+ if (!config.probeHostedCredentialHandle)
1037
+ return undefined;
1038
+ return { projectId, server, apiKey: await readSecretProviderHandle(config.probeHostedCredentialHandle) };
1039
+ }
1040
+ async function readSecretProviderHandle(handle) {
1041
+ if (!credentialHandle(handle))
1042
+ throw new Error("A valid opaque file:// credential handle is required.");
1043
+ const url = new URL(handle);
1044
+ if (url.protocol !== "file:")
1045
+ throw new Error("Only file:// credential handles are supported by the localhost sandbox Runtime.");
1046
+ const value = (await readFile(fileURLToPath(url), "utf8")).trim();
1047
+ if (!value)
1048
+ throw new Error("The referenced local credential is empty.");
1049
+ return value;
1050
+ }
1051
+ async function ed25519PublicKeyPem(handle) {
1052
+ const key = createPrivateKey(await readSecretProviderHandle(handle));
1053
+ if (key.asymmetricKeyType !== "ed25519")
1054
+ throw new Error("The Runtime signing-key reference must resolve to an Ed25519 private key.");
1055
+ return createPublicKey(key).export({ type: "spki", format: "pem" }).toString();
610
1056
  }
611
1057
  function recordedGatewayClient(config) {
612
1058
  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 +1064,12 @@ async function writeExclusive(path, content, force, mode) {
618
1064
  await writeFile(path, content, { encoding: "utf8", mode });
619
1065
  await chmod(path, mode).catch(() => undefined);
620
1066
  }
1067
+ async function atomicWrite(path, content, mode) {
1068
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1069
+ await writeFile(temporary, content, { encoding: "utf8", mode });
1070
+ await rename(temporary, path);
1071
+ await chmod(path, mode).catch(() => undefined);
1072
+ }
621
1073
  async function exists(path) {
622
1074
  try {
623
1075
  await access(path);
@@ -636,14 +1088,29 @@ function gatewayPaths(directory) {
636
1088
  join(directory, "README.md"),
637
1089
  ];
638
1090
  }
639
- async function gatewayHealth(baseUrl, requestFetch, requireRuntimeWorker = false) {
1091
+ async function gatewayHealth(baseUrl, requestFetch, runtimeWorker) {
640
1092
  try {
641
1093
  const response = await requestFetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(800) });
642
1094
  if (!response.ok)
643
1095
  return undefined;
644
1096
  const value = await response.json();
645
- if (requireRuntimeWorker && value.actionWorker?.ready !== true)
1097
+ if (runtimeWorker && value.actionWorker?.ready !== true)
646
1098
  return undefined;
1099
+ const processBinding = value.actionWorker?.runtimeBinding;
1100
+ if (runtimeWorker?.probeApiKeyId && (processBinding?.configDigestSha256 !== runtimeWorker.configDigestSha256
1101
+ || processBinding?.probeApiKeyId !== runtimeWorker.probeApiKeyId))
1102
+ return undefined;
1103
+ if (runtimeWorker?.adapterId === LOCAL_SANDBOX_ADAPTER_ID) {
1104
+ const readCredential = await readSecretProviderHandle(runtimeWorker.probeTargetCredentialHandle);
1105
+ const fixtureResponse = await requestFetch(`${runtimeWorker.sandboxOrigin}/healthz`, {
1106
+ headers: { "x-sandbox-read-credential": readCredential }, signal: AbortSignal.timeout(800), redirect: "error",
1107
+ });
1108
+ if (!fixtureResponse.ok)
1109
+ return undefined;
1110
+ const fixture = await fixtureResponse.json();
1111
+ if (fixture.contractSha256 !== runtimeWorker.fixtureContractSha256 || fixture.origin !== runtimeWorker.sandboxOrigin)
1112
+ return undefined;
1113
+ }
647
1114
  return typeof value.collectorId === "string" && value.collectorId ? { collectorId: value.collectorId } : undefined;
648
1115
  }
649
1116
  catch {