witnora 0.13.2 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -353,7 +353,7 @@ Control semantics and attestation format:
353
353
  [release gate checklist](https://github.com/Kakarottoooo/agentcert/blob/main/docs/release-gate-checklist.md).
354
354
 
355
355
  CI users can run Tripwire and Witnora together with
356
- `Kakarottoooo/agentcert/actions/tripwire@v0`.
356
+ `Kakarottoooo/witnora/actions/tripwire@v0`.
357
357
 
358
358
  The public Real Agent Robustness Lab compares browser-use, Stagehand, and
359
359
  Playwright-based agents over the same fault suite:
@@ -10,7 +10,7 @@ const SUPPORTED_TOP_LEVEL_FIELDS = new Set([
10
10
  export async function runEvidenceConformance(input, options) {
11
11
  const checks = [];
12
12
  const schema = validateAgentCertSchema("evidence-bundle", input);
13
- checks.push(check("schema", "Evidence bundle satisfies the AgentCert v0.1 semantic contract.", schema.errors));
13
+ checks.push(check("schema", "Witnora evidence satisfies the stable agentcert v0.1 protocol contract.", schema.errors));
14
14
  const bundle = object(input);
15
15
  const compatibilityErrors = bundle
16
16
  ? Object.keys(bundle).filter((key) => !SUPPORTED_TOP_LEVEL_FIELDS.has(key)).map((key) => `Unsupported top-level field: ${key}.`)
@@ -397,7 +397,7 @@ async function requestJson(request, url, init) {
397
397
  }
398
398
  catch (error) {
399
399
  const message = error instanceof Error ? error.message : String(error);
400
- throw new ControlPlaneRequestError(`AgentCert control plane request failed: ${message}`);
400
+ throw new ControlPlaneRequestError(`Witnora control plane request failed: ${message}`);
401
401
  }
402
402
  const text = await response.text();
403
403
  let value = {};
@@ -407,12 +407,12 @@ async function requestJson(request, url, init) {
407
407
  }
408
408
  catch {
409
409
  if (!response.ok)
410
- throw new ControlPlaneRequestError(`AgentCert control plane returned HTTP ${response.status}.`, response.status);
411
- throw new ControlPlaneRequestError("AgentCert control plane returned invalid JSON.", response.status);
410
+ throw new ControlPlaneRequestError(`Witnora control plane returned HTTP ${response.status}.`, response.status);
411
+ throw new ControlPlaneRequestError("Witnora control plane returned invalid JSON.", response.status);
412
412
  }
413
413
  }
414
414
  if (!response.ok) {
415
- throw new ControlPlaneRequestError([typeof value.error === "string" ? value.error : `AgentCert control plane returned HTTP ${response.status}.`,
415
+ throw new ControlPlaneRequestError([typeof value.error === "string" ? value.error : `Witnora control plane returned HTTP ${response.status}.`,
416
416
  typeof value.recovery === "string" ? value.recovery : undefined,
417
417
  typeof value.requestId === "string" ? `Request ID: ${value.requestId}.` : undefined].filter(Boolean).join(" "), response.status, typeof value.code === "string" ? value.code : undefined, typeof value.requestId === "string" ? value.requestId : response.headers.get("x-request-id") ?? undefined, typeof value.recovery === "string" ? value.recovery : undefined);
418
418
  }
package/dist/gateway.js CHANGED
@@ -1,11 +1,12 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { closeSync, openSync } from "node:fs";
4
4
  import { access, chmod, mkdir, readFile, 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 { inspectIsolatedOutcomeProbe, observeInIsolatedOutcomeProbe } from "./probe-process.js";
9
10
  const CONFIG_SCHEMA = "witnora.customer_gateway_setup.v0.1";
10
11
  const SECRETS_SCHEMA = "witnora.customer_gateway_local_secrets.v0.1";
11
12
  const RUNTIME_SCHEMA = "witnora.managed_gateway_runtime.v0.1";
@@ -36,7 +37,7 @@ export async function initializeCustomerGateway(options) {
36
37
  output,
37
38
  configHome: options.configHome,
38
39
  });
39
- const expectedScopes = ["runs:read", "events:write", "collector:manage"];
40
+ const expectedScopes = ["runs:read", "events:write", "collector:manage", "actions:read", "actions:propose", "actions:execute"];
40
41
  const missingScopes = expectedScopes.filter((scope) => !authorization.scopes.includes(scope));
41
42
  if (missingScopes.length > 0)
42
43
  throw new Error(`Gateway authorization is missing required scope(s): ${missingScopes.join(", ")}.`);
@@ -132,31 +133,67 @@ export async function doctorCustomerGateway(options) {
132
133
  catch (error) {
133
134
  checks.push({ id: "hosted_credential", status: "FAIL", message: message(error) });
134
135
  }
136
+ if (config.runtimeWorker) {
137
+ try {
138
+ const adapterModulePath = resolve(directory, config.runtimeWorker.adapterModulePath);
139
+ const probeModulePath = resolve(directory, config.runtimeWorker.probeModulePath);
140
+ if (adapterModulePath === probeModulePath)
141
+ throw new Error("Adapter and outcome probe must use separate modules.");
142
+ const [adapterDigest, probeDigest] = await Promise.all([
143
+ readFile(adapterModulePath).then((bytes) => createHash("sha256").update(bytes).digest("hex")),
144
+ readFile(probeModulePath).then((bytes) => createHash("sha256").update(bytes).digest("hex")),
145
+ ]);
146
+ if (adapterDigest !== config.runtimeWorker.adapterModuleSha256 || probeDigest !== config.runtimeWorker.probeModuleSha256)
147
+ throw new Error("Runtime adapter or probe module digest does not match gateway.json.");
148
+ const [primary, probe] = await Promise.all([
149
+ loadConnection(config.connectionName, { configHome: options.configHome }),
150
+ loadConnection(config.runtimeWorker.probeConnectionName, { configHome: options.configHome }),
151
+ ]);
152
+ if (!primary || !probe || probe.projectId !== config.projectId || probe.server !== config.server || probe.apiKey === primary.apiKey) {
153
+ throw new Error("A separate outcome-probe credential bound to this project is required.");
154
+ }
155
+ checks.push({ id: "runtime_configuration", status: "PASS", message: "The digest-pinned adapter module and separate outcome-probe credential are present." });
156
+ }
157
+ catch (error) {
158
+ checks.push({ id: "runtime_configuration", status: "FAIL", message: message(error) });
159
+ }
160
+ }
135
161
  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}.`);
162
+ const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, Boolean(config.runtimeWorker));
163
+ if (!health)
164
+ throw new Error("Gateway health or runtime-worker readiness was not established.");
139
165
  checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
140
166
  }
141
167
  catch {
142
168
  checks.push({ id: "process", status: "WARN", message: "Gateway is not running yet. Start managed mode with `witnora gateway start`." });
143
169
  }
144
170
  }
171
+ const runtimeConfigurationReady = checks.some((check) => check.id === "runtime_configuration" && check.status === "PASS")
172
+ && checks.some((check) => check.id === "process" && check.status === "PASS");
145
173
  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." });
174
+ checks.push(config.runtimeWorker && runtimeConfigurationReady
175
+ ? { id: "enforcement", status: "PASS", message: `Exact adapter ${config.runtimeWorker.adapterId}@${config.runtimeWorker.adapterVersion} is configured behind the durable worker.` }
176
+ : { id: "enforcement", status: "WARN", message: "Exact write-credential mediation is not proven ready; current evidence ceiling remains RECORDED." });
177
+ checks.push(config.runtimeWorker && runtimeConfigurationReady
178
+ ? { id: "outcome_probe", status: "PASS", message: `Separate read-only probe ${config.runtimeWorker.probeId} is configured.` }
179
+ : { id: "outcome_probe", status: "WARN", message: "An independent digest-pinned probe and read-only target credential are not proven ready." });
148
180
  }
149
181
  const failed = checks.some((check) => check.status === "FAIL");
150
182
  return {
151
183
  schemaVersion: "witnora.customer_gateway_doctor.v0.1",
152
- overall: failed ? "SETUP_INCOMPLETE" : "READY_TO_RECORD",
184
+ overall: failed ? "SETUP_INCOMPLETE" : runtimeConfigurationReady ? "READY_FOR_RUNTIME" : "READY_TO_RECORD",
153
185
  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.",
186
+ evidenceCeiling: runtimeConfigurationReady ? "outcome_verified" : "recorded",
187
+ 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
188
  };
157
189
  }
158
190
  export async function runCustomerGateway(options) {
159
- const { CustomerSourceKeyRing, RemoteCollectorClient, startCustomerOwnedCollectorGateway, } = await import("agentcert-sdk");
191
+ const remoteCollector = await import(new URL("./internal/control-client/remote-collector.js", import.meta.url).href);
192
+ const collectorGateway = await import(new URL("./internal/control-client/collector-gateway.js", import.meta.url).href);
193
+ const CustomerSourceKeyRing = remoteCollector.CustomerSourceKeyRing;
194
+ const RemoteCollectorClient = remoteCollector.RemoteCollectorClient;
195
+ const startCustomerOwnedCollectorGateway = collectorGateway.startCustomerOwnedCollectorGateway;
196
+ const durableWorker = configRuntimeWorkerImport();
160
197
  const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
161
198
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
162
199
  const secrets = parseSecrets(await readFile(join(directory, "secrets.json"), "utf8"));
@@ -169,6 +206,14 @@ export async function runCustomerGateway(options) {
169
206
  const keyRing = await (await exists(keyRingPath)
170
207
  ? CustomerSourceKeyRing.open(keyRingPath)
171
208
  : 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,
214
+ })
215
+ : undefined;
216
+ actionWorker?.start();
172
217
  const gateway = await startCustomerOwnedCollectorGateway({
173
218
  client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
174
219
  keyRing,
@@ -177,13 +222,143 @@ export async function runCustomerGateway(options) {
177
222
  host: process.env.WITNORA_GATEWAY_HOST?.trim() || config.host,
178
223
  port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, config.port),
179
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
+ } } : {}),
180
229
  });
181
230
  process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
182
- process.stdout.write("Evidence ceiling: RECORDED. No target write credential or outcome-probe credential is loaded by this reference process.\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");
183
234
  for (const signal of ["SIGINT", "SIGTERM"]) {
184
235
  process.once(signal, () => void gateway.close().finally(() => process.exit(0)));
185
236
  }
186
237
  }
238
+ export async function createConfiguredRuntimeActionWorker(input) {
239
+ const workerConfig = input.config.runtimeWorker;
240
+ if (!workerConfig?.enabled)
241
+ throw new Error("Runtime worker is not enabled.");
242
+ const adapterModulePath = resolve(input.directory, workerConfig.adapterModulePath);
243
+ const probeModulePath = resolve(input.directory, workerConfig.probeModulePath);
244
+ if (adapterModulePath === probeModulePath)
245
+ throw new Error("Adapter and outcome probe must use separate modules.");
246
+ const [adapterBytes, probeBytes] = await Promise.all([readFile(adapterModulePath), readFile(probeModulePath)]);
247
+ const adapterModuleDigest = createHash("sha256").update(adapterBytes).digest("hex");
248
+ const probeModuleDigest = createHash("sha256").update(probeBytes).digest("hex");
249
+ if (adapterModuleDigest !== workerConfig.adapterModuleSha256 || probeModuleDigest !== workerConfig.probeModuleSha256)
250
+ throw new Error("Runtime adapter or probe module digest does not match gateway.json; refusing to load it.");
251
+ const adapterModule = await import(`${pathToFileURL(adapterModulePath).href}?sha256=${adapterModuleDigest}`);
252
+ if (typeof adapterModule.createWitnoraRuntimeAdapter !== "function")
253
+ throw new Error("Runtime adapter module must export its factory.");
254
+ const probeConnection = await loadConnection(workerConfig.probeConnectionName, { configHome: input.configHome });
255
+ if (!probeConnection || probeConnection.projectId !== input.config.projectId || probeConnection.server !== input.config.server) {
256
+ throw new Error("The separate outcome-probe credential does not match this Gateway project and server.");
257
+ }
258
+ if (probeConnection.apiKey === input.connection.apiKey)
259
+ throw new Error("The execution Gateway and outcome probe must use separate Hosted credentials.");
260
+ const adapterContext = Object.freeze({
261
+ adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion,
262
+ projectId: input.config.projectId,
263
+ hosted: runtimeHostedTransport(input.connection, input.fetch ?? fetch),
264
+ storageDirectory: resolve(input.directory, "data", "runtime-actions"),
265
+ });
266
+ const isolatedProbeConfig = {
267
+ modulePath: probeModulePath,
268
+ moduleSha256: probeModuleDigest,
269
+ probeId: workerConfig.probeId,
270
+ projectId: input.config.projectId,
271
+ credentialHandle: workerConfig.probeTargetCredentialHandle,
272
+ };
273
+ const runtime = await adapterModule.createWitnoraRuntimeAdapter(adapterContext);
274
+ await inspectIsolatedOutcomeProbe(isolatedProbeConfig);
275
+ if (runtime.id !== workerConfig.adapterId || runtime.version !== workerConfig.adapterVersion || runtime.reconcileReadOnly !== true
276
+ || typeof runtime.prepareClaim !== "function" || typeof runtime.execute !== "function" || typeof runtime.reconcile !== "function") {
277
+ throw new Error("Runtime adapter does not match the exact configured id/version or read-only reconciliation contract.");
278
+ }
279
+ const requestFetch = input.fetch ?? fetch;
280
+ const primary = projectTransport(input.connection, requestFetch);
281
+ const verifier = projectTransport(probeConnection, requestFetch);
282
+ return new input.DurableApprovedActionWorker({
283
+ config: {
284
+ adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion, probeId: workerConfig.probeId,
285
+ probeCredentialHandle: workerConfig.probeTargetCredentialHandle,
286
+ runtimeIdentityId: workerConfig.runtimeIdentityId, grantTtlSeconds: workerConfig.grantTtlSeconds, pollIntervalMs: workerConfig.pollIntervalMs,
287
+ },
288
+ store: new input.FileActionCheckpointStore(resolve(input.directory, "data", "runtime-actions", "checkpoints")),
289
+ hosted: {
290
+ getAction: (actionId) => primary(`actions/${encodeURIComponent(actionId)}`),
291
+ issueExecutionGrant: (actionId, body, idempotencyKey) => primary(`actions/${encodeURIComponent(actionId)}/execution-grant`, { method: "POST", body, idempotencyKey }),
292
+ verifyAction: (actionId, body, idempotencyKey) => verifier(`actions/${encodeURIComponent(actionId)}/verify`, { method: "POST", body, idempotencyKey }),
293
+ listActionReceipts: async (actionId) => (await primary(`actions/${encodeURIComponent(actionId)}/receipts`)).receipts ?? [],
294
+ claimExecutionGrant: (executionGrantId, claim, idempotencyKey) => claimHostedExecutionGrant(input.connection, requestFetch, executionGrantId, claim, idempotencyKey),
295
+ },
296
+ runtime: { reconcileReadOnly: true, prepareClaim: runtime.prepareClaim, execute: runtime.execute, reconcile: runtime.reconcile },
297
+ probe: {
298
+ id: workerConfig.probeId,
299
+ credentialHandle: workerConfig.probeTargetCredentialHandle,
300
+ readOnly: true,
301
+ observe: (request) => observeInIsolatedOutcomeProbe(isolatedProbeConfig, request),
302
+ },
303
+ });
304
+ }
305
+ function projectTransport(connection, requestFetch) {
306
+ return async (suffix, options = {}) => {
307
+ const response = await requestFetch(`${connection.server}/v1/projects/${encodeURIComponent(connection.projectId)}/${suffix}`, {
308
+ method: options.method ?? "GET",
309
+ headers: { authorization: `Bearer ${connection.apiKey}`, ...(options.body ? { "content-type": "application/json" } : {}), ...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}) },
310
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
311
+ });
312
+ const body = await response.json().catch(() => ({}));
313
+ if (!response.ok)
314
+ throw new Error(String(body.error ?? `Witnora Hosted API returned HTTP ${response.status}.`));
315
+ return body;
316
+ };
317
+ }
318
+ function runtimeHostedTransport(connection, requestFetch) {
319
+ return {
320
+ baseUrl: connection.server,
321
+ projectId: connection.projectId,
322
+ async request(suffix, options = {}) {
323
+ if (!/^(execution-grants\/[A-Za-z0-9._:-]+\/consume|execution-attempts\/[A-Za-z0-9._:-]+\/phase|execution-sessions\/[A-Za-z0-9._:-]+\/evidence)$/.test(suffix)) {
324
+ throw new Error("Runtime adapter attempted to access a Hosted path outside its execution boundary.");
325
+ }
326
+ if ((options.method ?? "GET") !== "POST")
327
+ throw new Error("Runtime Hosted execution paths require POST.");
328
+ const response = await requestFetch(`${connection.server}/v1/runtime/projects/${encodeURIComponent(connection.projectId)}/${suffix}`, {
329
+ method: "POST",
330
+ headers: { authorization: `Bearer ${connection.apiKey}`, ...(options.body ? { "content-type": "application/json" } : {}), ...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}) },
331
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
332
+ });
333
+ const body = await response.json().catch(() => ({}));
334
+ if (!response.ok)
335
+ throw new Error(String(body.error ?? `Witnora Hosted runtime API returned HTTP ${response.status}.`));
336
+ return body;
337
+ },
338
+ };
339
+ }
340
+ async function claimHostedExecutionGrant(connection, requestFetch, executionGrantId, claim, idempotencyKey) {
341
+ const response = await requestFetch(`${connection.server}/v1/runtime/projects/${encodeURIComponent(connection.projectId)}/execution-grants/${encodeURIComponent(executionGrantId)}/claim`, {
342
+ method: "POST",
343
+ headers: { authorization: `Bearer ${connection.apiKey}`, "content-type": "application/json", "idempotency-key": idempotencyKey },
344
+ body: JSON.stringify(claim),
345
+ });
346
+ if (response.status === 409)
347
+ return { acquired: false };
348
+ const body = await response.json().catch(() => ({}));
349
+ if (!response.ok)
350
+ throw new Error(String(body.error ?? `Witnora Hosted grant claim returned HTTP ${response.status}.`));
351
+ return { acquired: body.status === "CLAIMED" };
352
+ }
353
+ function configRuntimeWorkerImport() {
354
+ return import(new URL("./internal/control-client/durable-action-worker.js", import.meta.url).href);
355
+ }
356
+ function isConfiguredRuntimeProposal(proposal, config) {
357
+ const intent = proposal.executionIntent;
358
+ return Boolean(intent && typeof intent === "object" && !Array.isArray(intent)
359
+ && intent.adapterId === config.adapterId
360
+ && typeof intent.adapterVersionConstraint === "string");
361
+ }
187
362
  export async function startManagedCustomerGateway(options = {}) {
188
363
  const repository = resolve(options.repository ?? process.cwd());
189
364
  const directory = resolve(repository, options.dir ?? ".witnora/gateway");
@@ -268,7 +443,7 @@ export async function statusManagedCustomerGateway(options = {}) {
268
443
  const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
269
444
  const baseUrl = `http://${config.host}:${config.port}`;
270
445
  const runtime = await readRuntime(directory);
271
- const health = await gatewayHealth(baseUrl, options.fetch ?? fetch);
446
+ const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, Boolean(config.runtimeWorker));
272
447
  const base = {
273
448
  schemaVersion: "witnora.managed_gateway_status.v0.1",
274
449
  baseUrl,
@@ -383,8 +558,30 @@ function parseConfig(raw) {
383
558
  throw new Error("Gateway privacyMode must remain metadata_only.");
384
559
  if (!value.host || !Number.isSafeInteger(value.port) || Number(value.port) < 1 || Number(value.port) > 65_535)
385
560
  throw new Error("Gateway host and port are invalid.");
561
+ if (value.runtimeWorker)
562
+ validateRuntimeWorkerConfig(value.runtimeWorker);
386
563
  return value;
387
564
  }
565
+ function validateRuntimeWorkerConfig(value) {
566
+ if (value.enabled !== true || !value.adapterModulePath || !/^[a-f0-9]{64}$/.test(value.adapterModuleSha256)
567
+ || !value.probeModulePath || !/^[a-f0-9]{64}$/.test(value.probeModuleSha256)
568
+ || !value.adapterId || !/^v?\d+\.\d+\.\d+$/.test(value.adapterVersion) || !value.probeId
569
+ || !value.probeConnectionName || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
570
+ throw new Error("gateway.json runtimeWorker requires separate exact adapter/probe module digests, adapter id/version, probe credentials, and runtime identity.");
571
+ }
572
+ if (value.adapterModulePath === value.probeModulePath || value.adapterModuleSha256 === value.probeModuleSha256)
573
+ throw new Error("runtimeWorker adapter and outcome probe modules must be independently pinned.");
574
+ if (value.adapterId === value.probeId)
575
+ throw new Error("runtimeWorker adapter and outcome probe must be separate.");
576
+ if (value.grantTtlSeconds !== undefined && (!Number.isInteger(value.grantTtlSeconds) || value.grantTtlSeconds < 15 || value.grantTtlSeconds > 300))
577
+ throw new Error("runtimeWorker grantTtlSeconds must be between 15 and 300.");
578
+ if (value.pollIntervalMs !== undefined && (!Number.isInteger(value.pollIntervalMs) || value.pollIntervalMs < 250 || value.pollIntervalMs > 30_000))
579
+ throw new Error("runtimeWorker pollIntervalMs must be between 250 and 30000.");
580
+ }
581
+ function credentialHandle(value) {
582
+ return typeof value === "string" && value.length <= 256 && /^[a-z][a-z0-9+.-]*:\/\/[^\s]+$/i.test(value)
583
+ && !/(?:ac_live_|bearer\s|private.?key|password|token=)/i.test(value);
584
+ }
388
585
  function parseSecrets(raw) {
389
586
  const value = JSON.parse(raw);
390
587
  if (value.schemaVersion !== SECRETS_SCHEMA || typeof value.gatewayToken !== "string" || value.gatewayToken.length < 32) {
@@ -405,6 +602,13 @@ function gatewayReadme(config) {
405
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`;
406
603
  }
407
604
  function gatewayClient(config) {
605
+ 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
+ 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`;
607
+ return recordedGatewayClient(config)
608
+ .replace("export const witnoraGateway = {", `${requestHelper}\nexport const witnoraGateway = {`)
609
+ .replace(/\n};\n$/, `\n${actionMethods}};\n`);
610
+ }
611
+ function recordedGatewayClient(config) {
408
612
  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`;
409
613
  }
410
614
  async function writeExclusive(path, content, force, mode) {
@@ -432,12 +636,14 @@ function gatewayPaths(directory) {
432
636
  join(directory, "README.md"),
433
637
  ];
434
638
  }
435
- async function gatewayHealth(baseUrl, requestFetch) {
639
+ async function gatewayHealth(baseUrl, requestFetch, requireRuntimeWorker = false) {
436
640
  try {
437
641
  const response = await requestFetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(800) });
438
642
  if (!response.ok)
439
643
  return undefined;
440
644
  const value = await response.json();
645
+ if (requireRuntimeWorker && value.actionWorker?.ready !== true)
646
+ return undefined;
441
647
  return typeof value.collectorId === "string" && value.collectorId ? { collectorId: value.collectorId } : undefined;
442
648
  }
443
649
  catch {
@@ -0,0 +1,2 @@
1
+ export declare function canonicalJson(value: unknown): string;
2
+ //# sourceMappingURL=canonical.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canonical.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/canonical.ts"],"names":[],"mappings":"AAAA,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD"}
@@ -0,0 +1,19 @@
1
+ export function canonicalJson(value) {
2
+ return JSON.stringify(canonicalValue(value));
3
+ }
4
+ function canonicalValue(value) {
5
+ if (value === null || typeof value === "string" || typeof value === "boolean")
6
+ return value;
7
+ if (typeof value === "number") {
8
+ if (!Number.isFinite(value))
9
+ throw new Error("Canonical JSON does not support non-finite numbers.");
10
+ return Object.is(value, -0) ? 0 : value;
11
+ }
12
+ if (Array.isArray(value))
13
+ return value.map(canonicalValue);
14
+ if (value && typeof value === "object") {
15
+ const record = value;
16
+ return Object.fromEntries(Object.keys(record).sort().filter((key) => record[key] !== undefined).map((key) => [key, canonicalValue(record[key])]));
17
+ }
18
+ throw new Error(`Canonical JSON does not support ${typeof value}.`);
19
+ }
@@ -0,0 +1,64 @@
1
+ import { CustomerSourceKeyRing, type CustomerSourceSigner, type RemoteCollectorAck, type RemoteTrustedSourceRecord } from "./remote-collector.js";
2
+ export interface RemoteCollectorTransport {
3
+ registerSourceKey(input: {
4
+ collectorId: string;
5
+ keyId: string;
6
+ publicKeyPem: string;
7
+ previousKeyId?: string;
8
+ }): Promise<Record<string, unknown>>;
9
+ append(runId: string, records: RemoteTrustedSourceRecord[], idempotencyKey?: string): Promise<RemoteCollectorAck>;
10
+ heartbeat(input: {
11
+ collectorId: string;
12
+ signer: CustomerSourceSigner;
13
+ pendingRecordCount: number;
14
+ lastAckSequence?: number;
15
+ }): Promise<Record<string, unknown>>;
16
+ reconcile(runId: string, receipt: Record<string, unknown>): Promise<Record<string, unknown>>;
17
+ proposeAction(proposal: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>>;
18
+ getAction(actionId: string): Promise<Record<string, unknown>>;
19
+ issueExecutionGrant(actionId: string, grant: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>>;
20
+ }
21
+ export interface CustomerOwnedCollectorGatewayOptions {
22
+ client: RemoteCollectorTransport;
23
+ keyRing: CustomerSourceKeyRing;
24
+ gatewayToken: string;
25
+ storageDirectory: string;
26
+ collectorVersion?: string;
27
+ environment?: string;
28
+ host?: string;
29
+ port?: number;
30
+ flushIntervalMs?: number;
31
+ heartbeatIntervalMs?: number;
32
+ maxBodyBytes?: number;
33
+ actionWorker?: {
34
+ track(input: {
35
+ actionId: string;
36
+ proposal: Record<string, unknown>;
37
+ }): Promise<unknown>;
38
+ status(): Promise<Record<string, unknown>>;
39
+ close?(): void | Promise<void>;
40
+ };
41
+ }
42
+ export interface CustomerOwnedCollectorGateway {
43
+ baseUrl: string;
44
+ close(): Promise<void>;
45
+ flush(): Promise<{
46
+ delivered: number;
47
+ reconciled: number;
48
+ pending: number;
49
+ }>;
50
+ status(): Promise<CollectorGatewayStatus>;
51
+ }
52
+ export interface CollectorGatewayStatus {
53
+ schemaVersion: "agentcert.customer_collector_gateway_status.v0.2";
54
+ collectorId: string;
55
+ sourceKeyId: string;
56
+ runCount: number;
57
+ pendingRecordCount: number;
58
+ lastAckSequence?: number;
59
+ lastRemoteSuccessAt?: string;
60
+ lastRemoteError?: string;
61
+ actionWorker?: Record<string, unknown>;
62
+ }
63
+ export declare function startCustomerOwnedCollectorGateway(options: CustomerOwnedCollectorGatewayOptions): Promise<CustomerOwnedCollectorGateway>;
64
+ //# sourceMappingURL=collector-gateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collector-gateway.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/collector-gateway.ts"],"names":[],"mappings":"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"}