witnora 0.13.3 → 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,11 +1,15 @@
1
- import { 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
- import { fileURLToPath } from "node:url";
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";
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";
9
13
  const CONFIG_SCHEMA = "witnora.customer_gateway_setup.v0.1";
10
14
  const SECRETS_SCHEMA = "witnora.customer_gateway_local_secrets.v0.1";
11
15
  const RUNTIME_SCHEMA = "witnora.managed_gateway_runtime.v0.1";
@@ -40,6 +44,9 @@ export async function initializeCustomerGateway(options) {
40
44
  const missingScopes = expectedScopes.filter((scope) => !authorization.scopes.includes(scope));
41
45
  if (missingScopes.length > 0)
42
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;
43
50
  const config = {
44
51
  schemaVersion: CONFIG_SCHEMA,
45
52
  projectId: authorization.projectId,
@@ -50,8 +57,13 @@ export async function initializeCustomerGateway(options) {
50
57
  port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, 8787),
51
58
  storageDirectory: "data",
52
59
  privacyMode: "metadata_only",
53
- 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 } : {}),
54
64
  };
65
+ if (config.runtimeWorker)
66
+ config.runtimeWorker.configDigestSha256 = gatewayConfigDigest(config);
55
67
  const secrets = {
56
68
  schemaVersion: SECRETS_SCHEMA,
57
69
  gatewayToken: randomBytes(32).toString("base64url"),
@@ -66,6 +78,11 @@ export async function initializeCustomerGateway(options) {
66
78
  [gitignorePath, "secrets.json\ndata/\nruntime/\n", 0o644],
67
79
  [clientPath, gatewayClient(config), 0o644],
68
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
+ ] : []),
69
86
  ]) {
70
87
  await writeExclusive(path, content, force, mode);
71
88
  if (!force)
@@ -88,6 +105,236 @@ export async function initializeCustomerGateway(options) {
88
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");
89
106
  return { configPath, secretsPath, config, generatedFiles };
90
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
+ }
91
338
  export async function inspectCustomerGatewayFiles(options) {
92
339
  const repository = resolve(options.repository ?? process.cwd());
93
340
  const directory = resolve(repository, options.outDir ?? ".witnora/gateway");
@@ -132,27 +379,76 @@ export async function doctorCustomerGateway(options) {
132
379
  catch (error) {
133
380
  checks.push({ id: "hosted_credential", status: "FAIL", message: message(error) });
134
381
  }
382
+ if (config.runtimeWorker) {
383
+ try {
384
+ const adapterModulePath = resolve(directory, config.runtimeWorker.adapterModulePath);
385
+ const probeModulePath = resolve(directory, config.runtimeWorker.probeModulePath);
386
+ if (adapterModulePath === probeModulePath)
387
+ throw new Error("Adapter and outcome probe must use separate modules.");
388
+ const [adapterDigest, probeDigest] = await Promise.all([
389
+ readFile(adapterModulePath).then((bytes) => createHash("sha256").update(bytes).digest("hex")),
390
+ readFile(probeModulePath).then((bytes) => createHash("sha256").update(bytes).digest("hex")),
391
+ ]);
392
+ if (adapterDigest !== config.runtimeWorker.adapterModuleSha256 || probeDigest !== config.runtimeWorker.probeModuleSha256)
393
+ throw new Error("Runtime adapter or probe module digest does not match gateway.json.");
394
+ const [primary, probe] = await Promise.all([
395
+ loadConnection(config.connectionName, { configHome: options.configHome }),
396
+ loadProbeHostedConnection(config.runtimeWorker, config.projectId, config.server, options.configHome),
397
+ ]);
398
+ if (!primary || !probe || probe.projectId !== config.projectId || probe.server !== config.server || probe.apiKey === primary.apiKey) {
399
+ throw new Error("A separate outcome-probe credential bound to this project is required.");
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
+ }
419
+ checks.push({ id: "runtime_configuration", status: "PASS", message: "The digest-pinned adapter module and separate outcome-probe credential are present." });
420
+ }
421
+ catch (error) {
422
+ checks.push({ id: "runtime_configuration", status: "FAIL", message: message(error) });
423
+ }
424
+ }
135
425
  try {
136
- const response = await (options.fetch ?? fetch)(`http://${config.host}:${config.port}/healthz`, { signal: AbortSignal.timeout(800) });
137
- if (!response.ok)
138
- throw new Error(`Gateway health returned HTTP ${response.status}.`);
426
+ const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, config.runtimeWorker);
427
+ if (!health)
428
+ throw new Error("Gateway health or runtime-worker readiness was not established.");
139
429
  checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
140
430
  }
141
431
  catch {
142
432
  checks.push({ id: "process", status: "WARN", message: "Gateway is not running yet. Start managed mode with `witnora gateway start`." });
143
433
  }
144
434
  }
435
+ const runtimeConfigurationReady = checks.some((check) => check.id === "runtime_configuration" && check.status === "PASS")
436
+ && checks.some((check) => check.id === "process" && check.status === "PASS");
145
437
  if (config && secrets) {
146
- checks.push({ id: "enforcement", status: "WARN", message: "Write-credential mediation is not configured; current evidence ceiling is RECORDED." });
147
- checks.push({ id: "outcome_probe", status: "WARN", message: "An independent read-only outcome probe is not configured." });
438
+ checks.push(config.runtimeWorker && runtimeConfigurationReady
439
+ ? { id: "enforcement", status: "PASS", message: `Exact adapter ${config.runtimeWorker.adapterId}@${config.runtimeWorker.adapterVersion} is configured behind the durable worker.` }
440
+ : { id: "enforcement", status: "WARN", message: "Exact write-credential mediation is not proven ready; current evidence ceiling remains RECORDED." });
441
+ checks.push(config.runtimeWorker && runtimeConfigurationReady
442
+ ? { id: "outcome_probe", status: "PASS", message: `Separate read-only probe ${config.runtimeWorker.probeId} is configured.` }
443
+ : { id: "outcome_probe", status: "WARN", message: "An independent digest-pinned probe and read-only target credential are not proven ready." });
148
444
  }
149
445
  const failed = checks.some((check) => check.status === "FAIL");
150
446
  return {
151
447
  schemaVersion: "witnora.customer_gateway_doctor.v0.1",
152
- overall: failed ? "SETUP_INCOMPLETE" : "READY_TO_RECORD",
448
+ overall: failed ? "SETUP_INCOMPLETE" : runtimeConfigurationReady ? "READY_FOR_RUNTIME" : "READY_TO_RECORD",
153
449
  checks,
154
- evidenceCeiling: "recorded",
155
- nextAction: failed ? "Run `witnora gateway init --project <project-id>` again after resolving failed checks." : "Ensure the managed Gateway is healthy, then send one sandbox run through its local event API.",
450
+ evidenceCeiling: runtimeConfigurationReady ? "outcome_verified" : "recorded",
451
+ nextAction: failed ? "Resolve the failed Gateway checks before sending another action." : runtimeConfigurationReady ? "Run the Agent normally; approved configured actions continue automatically after the human decision." : "Ensure the managed Gateway is healthy, then send one sandbox run through its local event API.",
156
452
  };
157
453
  }
158
454
  export async function runCustomerGateway(options) {
@@ -161,6 +457,7 @@ export async function runCustomerGateway(options) {
161
457
  const CustomerSourceKeyRing = remoteCollector.CustomerSourceKeyRing;
162
458
  const RemoteCollectorClient = remoteCollector.RemoteCollectorClient;
163
459
  const startCustomerOwnedCollectorGateway = collectorGateway.startCustomerOwnedCollectorGateway;
460
+ const durableWorker = configRuntimeWorkerImport();
164
461
  const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
165
462
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
166
463
  const secrets = parseSecrets(await readFile(join(directory, "secrets.json"), "utf8"));
@@ -173,20 +470,259 @@ export async function runCustomerGateway(options) {
173
470
  const keyRing = await (await exists(keyRingPath)
174
471
  ? CustomerSourceKeyRing.open(keyRingPath)
175
472
  : CustomerSourceKeyRing.create(keyRingPath, config.collectorId));
176
- const gateway = await startCustomerOwnedCollectorGateway({
177
- client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
178
- keyRing,
179
- gatewayToken: secrets.gatewayToken,
180
- storageDirectory: dataDirectory,
181
- host: process.env.WITNORA_GATEWAY_HOST?.trim() || config.host,
182
- port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, config.port),
183
- environment: "customer-owned",
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,
480
+ })
481
+ : undefined;
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
+ }
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
+ });
588
+ }
589
+ export async function createConfiguredRuntimeActionWorker(input) {
590
+ const workerConfig = input.config.runtimeWorker;
591
+ if (!workerConfig?.enabled)
592
+ throw new Error("Runtime worker is not enabled.");
593
+ const adapterModulePath = resolve(input.directory, workerConfig.adapterModulePath);
594
+ const probeModulePath = resolve(input.directory, workerConfig.probeModulePath);
595
+ if (adapterModulePath === probeModulePath)
596
+ throw new Error("Adapter and outcome probe must use separate modules.");
597
+ const [adapterBytes, probeBytes] = await Promise.all([readFile(adapterModulePath), readFile(probeModulePath)]);
598
+ const adapterModuleDigest = createHash("sha256").update(adapterBytes).digest("hex");
599
+ const probeModuleDigest = createHash("sha256").update(probeBytes).digest("hex");
600
+ if (adapterModuleDigest !== workerConfig.adapterModuleSha256 || probeModuleDigest !== workerConfig.probeModuleSha256)
601
+ throw new Error("Runtime adapter or probe module digest does not match gateway.json; refusing to load it.");
602
+ const adapterModule = await import(`${pathToFileURL(adapterModulePath).href}?sha256=${adapterModuleDigest}`);
603
+ if (typeof adapterModule.createWitnoraRuntimeAdapter !== "function")
604
+ throw new Error("Runtime adapter module must export its factory.");
605
+ const probeConnection = await loadProbeHostedConnection(workerConfig, input.config.projectId, input.config.server, input.configHome);
606
+ if (!probeConnection || probeConnection.projectId !== input.config.projectId || probeConnection.server !== input.config.server) {
607
+ throw new Error("The separate outcome-probe credential does not match this Gateway project and server.");
608
+ }
609
+ if (probeConnection.apiKey === input.connection.apiKey)
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);
613
+ const adapterContext = Object.freeze({
614
+ adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion,
615
+ projectId: input.config.projectId,
616
+ hosted: runtimeHostedTransport(input.connection, input.fetch ?? fetch),
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
+ } : {}),
184
624
  });
185
- process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
186
- process.stdout.write("Evidence ceiling: RECORDED. No target write credential or outcome-probe credential is loaded by this reference process.\n");
187
- for (const signal of ["SIGINT", "SIGTERM"]) {
188
- process.once(signal, () => void gateway.close().finally(() => process.exit(0)));
625
+ const isolatedProbeConfig = {
626
+ modulePath: probeModulePath,
627
+ moduleSha256: probeModuleDigest,
628
+ probeId: workerConfig.probeId,
629
+ projectId: input.config.projectId,
630
+ credentialHandle: workerConfig.probeTargetCredentialHandle,
631
+ };
632
+ const runtime = await adapterModule.createWitnoraRuntimeAdapter(adapterContext);
633
+ await inspectIsolatedOutcomeProbe(isolatedProbeConfig);
634
+ if (runtime.id !== workerConfig.adapterId || runtime.version !== workerConfig.adapterVersion || runtime.reconcileReadOnly !== true
635
+ || typeof runtime.prepareClaim !== "function" || typeof runtime.execute !== "function" || typeof runtime.reconcile !== "function") {
636
+ throw new Error("Runtime adapter does not match the exact configured id/version or read-only reconciliation contract.");
189
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.");
642
+ const requestFetch = input.fetch ?? fetch;
643
+ const primary = projectTransport(input.connection, requestFetch);
644
+ const verifier = projectTransport(probeConnection, requestFetch);
645
+ return new input.DurableApprovedActionWorker({
646
+ config: {
647
+ adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion, probeId: workerConfig.probeId,
648
+ probeCredentialHandle: workerConfig.probeTargetCredentialHandle,
649
+ runtimeIdentityId: workerConfig.runtimeIdentityId, grantTtlSeconds: workerConfig.grantTtlSeconds, pollIntervalMs: workerConfig.pollIntervalMs,
650
+ },
651
+ store: new input.FileActionCheckpointStore(resolve(input.directory, "data", "runtime-actions", "checkpoints")),
652
+ hosted: {
653
+ getAction: (actionId) => primary(`actions/${encodeURIComponent(actionId)}`),
654
+ issueExecutionGrant: (actionId, body, idempotencyKey) => primary(`actions/${encodeURIComponent(actionId)}/execution-grant`, { method: "POST", body, idempotencyKey }),
655
+ verifyAction: (actionId, body, idempotencyKey) => verifier(`actions/${encodeURIComponent(actionId)}/verify`, { method: "POST", body, idempotencyKey }),
656
+ listActionReceipts: async (actionId) => (await primary(`actions/${encodeURIComponent(actionId)}/receipts`)).receipts ?? [],
657
+ claimExecutionGrant: (executionGrantId, claim, idempotencyKey) => claimHostedExecutionGrant(input.connection, requestFetch, executionGrantId, claim, idempotencyKey),
658
+ },
659
+ runtime: { reconcileReadOnly: true, prepareClaim: runtime.prepareClaim, execute: runtime.execute, reconcile: runtime.reconcile },
660
+ probe: {
661
+ id: workerConfig.probeId,
662
+ credentialHandle: workerConfig.probeTargetCredentialHandle,
663
+ readOnly: true,
664
+ observe: (request) => observeInIsolatedOutcomeProbe(isolatedProbeConfig, request),
665
+ },
666
+ });
667
+ }
668
+ function projectTransport(connection, requestFetch) {
669
+ return async (suffix, options = {}) => {
670
+ const response = await requestFetch(`${connection.server}/v1/projects/${encodeURIComponent(connection.projectId)}/${suffix}`, {
671
+ method: options.method ?? "GET",
672
+ headers: { authorization: `Bearer ${connection.apiKey}`, ...(options.body ? { "content-type": "application/json" } : {}), ...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}) },
673
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
674
+ });
675
+ const body = await response.json().catch(() => ({}));
676
+ if (!response.ok)
677
+ throw new Error(String(body.error ?? `Witnora Hosted API returned HTTP ${response.status}.`));
678
+ return body;
679
+ };
680
+ }
681
+ function runtimeHostedTransport(connection, requestFetch) {
682
+ return {
683
+ baseUrl: connection.server,
684
+ projectId: connection.projectId,
685
+ async request(suffix, options = {}) {
686
+ if (!/^(execution-grants\/[A-Za-z0-9._:-]+\/consume|execution-attempts\/[A-Za-z0-9._:-]+\/phase|execution-sessions\/[A-Za-z0-9._:-]+\/evidence)$/.test(suffix)) {
687
+ throw new Error("Runtime adapter attempted to access a Hosted path outside its execution boundary.");
688
+ }
689
+ if ((options.method ?? "GET") !== "POST")
690
+ throw new Error("Runtime Hosted execution paths require POST.");
691
+ const response = await requestFetch(`${connection.server}/v1/runtime/projects/${encodeURIComponent(connection.projectId)}/${suffix}`, {
692
+ method: "POST",
693
+ headers: { authorization: `Bearer ${connection.apiKey}`, ...(options.body ? { "content-type": "application/json" } : {}), ...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}) },
694
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
695
+ });
696
+ const body = await response.json().catch(() => ({}));
697
+ if (!response.ok)
698
+ throw new Error(String(body.error ?? `Witnora Hosted runtime API returned HTTP ${response.status}.`));
699
+ return body;
700
+ },
701
+ };
702
+ }
703
+ async function claimHostedExecutionGrant(connection, requestFetch, executionGrantId, claim, idempotencyKey) {
704
+ const response = await requestFetch(`${connection.server}/v1/runtime/projects/${encodeURIComponent(connection.projectId)}/execution-grants/${encodeURIComponent(executionGrantId)}/claim`, {
705
+ method: "POST",
706
+ headers: { authorization: `Bearer ${connection.apiKey}`, "content-type": "application/json", "idempotency-key": idempotencyKey },
707
+ body: JSON.stringify(claim),
708
+ });
709
+ if (response.status === 409)
710
+ return { acquired: false };
711
+ const body = await response.json().catch(() => ({}));
712
+ if (!response.ok)
713
+ throw new Error(String(body.error ?? `Witnora Hosted grant claim returned HTTP ${response.status}.`));
714
+ return { acquired: body.status === "CLAIMED" };
715
+ }
716
+ function configRuntimeWorkerImport() {
717
+ return import(new URL("./internal/control-client/durable-action-worker.js", import.meta.url).href);
718
+ }
719
+ function isConfiguredRuntimeProposal(proposal, config) {
720
+ const intent = proposal.executionIntent;
721
+ return Boolean(intent && typeof intent === "object" && !Array.isArray(intent)
722
+ && intent.adapterId === config.adapterId
723
+ && typeof intent.adapterVersionConstraint === "string"
724
+ && (config.adapterId !== LOCAL_SANDBOX_ADAPTER_ID || proposal.targetSystem === "WitnoraLocalSandbox")
725
+ && (!config.mandateId || proposal.mandateId === config.mandateId));
190
726
  }
191
727
  export async function startManagedCustomerGateway(options = {}) {
192
728
  const repository = resolve(options.repository ?? process.cwd());
@@ -272,7 +808,7 @@ export async function statusManagedCustomerGateway(options = {}) {
272
808
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
273
809
  const baseUrl = `http://${config.host}:${config.port}`;
274
810
  const runtime = await readRuntime(directory);
275
- const health = await gatewayHealth(baseUrl, options.fetch ?? fetch);
811
+ const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, config.runtimeWorker);
276
812
  const base = {
277
813
  schemaVersion: "witnora.managed_gateway_status.v0.1",
278
814
  baseUrl,
@@ -387,7 +923,44 @@ function parseConfig(raw) {
387
923
  throw new Error("Gateway privacyMode must remain metadata_only.");
388
924
  if (!value.host || !Number.isSafeInteger(value.port) || Number(value.port) < 1 || Number(value.port) > 65_535)
389
925
  throw new Error("Gateway host and port are invalid.");
390
- return value;
926
+ if (value.runtimeWorker)
927
+ validateRuntimeWorkerConfig(value.runtimeWorker);
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;
933
+ }
934
+ function validateRuntimeWorkerConfig(value) {
935
+ if (value.enabled !== true || !value.adapterModulePath || !/^[a-f0-9]{64}$/.test(value.adapterModuleSha256)
936
+ || !value.probeModulePath || !/^[a-f0-9]{64}$/.test(value.probeModuleSha256)
937
+ || !value.adapterId || !/^v?\d+\.\d+\.\d+$/.test(value.adapterVersion) || !value.probeId
938
+ || (!value.probeConnectionName && !credentialHandle(value.probeHostedCredentialHandle ?? ""))
939
+ || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
940
+ throw new Error("gateway.json runtimeWorker requires separate exact adapter/probe module digests, adapter id/version, probe credentials, and runtime identity.");
941
+ }
942
+ if (value.adapterModulePath === value.probeModulePath || value.adapterModuleSha256 === value.probeModuleSha256)
943
+ throw new Error("runtimeWorker adapter and outcome probe modules must be independently pinned.");
944
+ if (value.adapterId === value.probeId)
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
+ }
956
+ if (value.grantTtlSeconds !== undefined && (!Number.isInteger(value.grantTtlSeconds) || value.grantTtlSeconds < 15 || value.grantTtlSeconds > 300))
957
+ throw new Error("runtimeWorker grantTtlSeconds must be between 15 and 300.");
958
+ if (value.pollIntervalMs !== undefined && (!Number.isInteger(value.pollIntervalMs) || value.pollIntervalMs < 250 || value.pollIntervalMs > 30_000))
959
+ throw new Error("runtimeWorker pollIntervalMs must be between 250 and 30000.");
960
+ }
961
+ function credentialHandle(value) {
962
+ return typeof value === "string" && value.length <= 256 && /^[a-z][a-z0-9+.-]*:\/\/[^\s]+$/i.test(value)
963
+ && !/(?:ac_live_|bearer\s|private.?key|password|token=)/i.test(value);
391
964
  }
392
965
  function parseSecrets(raw) {
393
966
  const value = JSON.parse(raw);
@@ -406,14 +979,75 @@ function gatewayPort(value, fallback) {
406
979
  return parsed;
407
980
  }
408
981
  function gatewayReadme(config) {
409
- 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
+ }
410
1017
  }
411
1018
  function gatewayClient(config) {
412
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`;
413
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
+ : "";
414
1024
  return recordedGatewayClient(config)
415
1025
  .replace("export const witnoraGateway = {", `${requestHelper}\nexport const witnoraGateway = {`)
416
- .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();
417
1051
  }
418
1052
  function recordedGatewayClient(config) {
419
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`;
@@ -425,6 +1059,12 @@ async function writeExclusive(path, content, force, mode) {
425
1059
  await writeFile(path, content, { encoding: "utf8", mode });
426
1060
  await chmod(path, mode).catch(() => undefined);
427
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
+ }
428
1068
  async function exists(path) {
429
1069
  try {
430
1070
  await access(path);
@@ -443,12 +1083,29 @@ function gatewayPaths(directory) {
443
1083
  join(directory, "README.md"),
444
1084
  ];
445
1085
  }
446
- async function gatewayHealth(baseUrl, requestFetch) {
1086
+ async function gatewayHealth(baseUrl, requestFetch, runtimeWorker) {
447
1087
  try {
448
1088
  const response = await requestFetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(800) });
449
1089
  if (!response.ok)
450
1090
  return undefined;
451
1091
  const value = await response.json();
1092
+ if (runtimeWorker && value.actionWorker?.ready !== true)
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
+ }
452
1109
  return typeof value.collectorId === "string" && value.collectorId ? { collectorId: value.collectorId } : undefined;
453
1110
  }
454
1111
  catch {