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