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