witnora 0.13.3 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gateway.js +208 -13
- package/dist/internal/control-client/collector-gateway.d.ts +9 -0
- package/dist/internal/control-client/collector-gateway.d.ts.map +1 -1
- package/dist/internal/control-client/collector-gateway.js +7 -1
- package/dist/internal/control-client/durable-action-worker.d.ts +194 -0
- package/dist/internal/control-client/durable-action-worker.d.ts.map +1 -0
- package/dist/internal/control-client/durable-action-worker.js +359 -0
- package/dist/probe-process.js +135 -0
- package/package.json +1 -1
package/dist/gateway.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { closeSync, openSync } from "node:fs";
|
|
4
4
|
import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import { loadConnection } from "./credentials.js";
|
|
8
8
|
import { authorizeProjectConnection } from "./device-authorization.js";
|
|
9
|
+
import { inspectIsolatedOutcomeProbe, observeInIsolatedOutcomeProbe } from "./probe-process.js";
|
|
9
10
|
const CONFIG_SCHEMA = "witnora.customer_gateway_setup.v0.1";
|
|
10
11
|
const SECRETS_SCHEMA = "witnora.customer_gateway_local_secrets.v0.1";
|
|
11
12
|
const RUNTIME_SCHEMA = "witnora.managed_gateway_runtime.v0.1";
|
|
@@ -132,27 +133,58 @@ export async function doctorCustomerGateway(options) {
|
|
|
132
133
|
catch (error) {
|
|
133
134
|
checks.push({ id: "hosted_credential", status: "FAIL", message: message(error) });
|
|
134
135
|
}
|
|
136
|
+
if (config.runtimeWorker) {
|
|
137
|
+
try {
|
|
138
|
+
const adapterModulePath = resolve(directory, config.runtimeWorker.adapterModulePath);
|
|
139
|
+
const probeModulePath = resolve(directory, config.runtimeWorker.probeModulePath);
|
|
140
|
+
if (adapterModulePath === probeModulePath)
|
|
141
|
+
throw new Error("Adapter and outcome probe must use separate modules.");
|
|
142
|
+
const [adapterDigest, probeDigest] = await Promise.all([
|
|
143
|
+
readFile(adapterModulePath).then((bytes) => createHash("sha256").update(bytes).digest("hex")),
|
|
144
|
+
readFile(probeModulePath).then((bytes) => createHash("sha256").update(bytes).digest("hex")),
|
|
145
|
+
]);
|
|
146
|
+
if (adapterDigest !== config.runtimeWorker.adapterModuleSha256 || probeDigest !== config.runtimeWorker.probeModuleSha256)
|
|
147
|
+
throw new Error("Runtime adapter or probe module digest does not match gateway.json.");
|
|
148
|
+
const [primary, probe] = await Promise.all([
|
|
149
|
+
loadConnection(config.connectionName, { configHome: options.configHome }),
|
|
150
|
+
loadConnection(config.runtimeWorker.probeConnectionName, { configHome: options.configHome }),
|
|
151
|
+
]);
|
|
152
|
+
if (!primary || !probe || probe.projectId !== config.projectId || probe.server !== config.server || probe.apiKey === primary.apiKey) {
|
|
153
|
+
throw new Error("A separate outcome-probe credential bound to this project is required.");
|
|
154
|
+
}
|
|
155
|
+
checks.push({ id: "runtime_configuration", status: "PASS", message: "The digest-pinned adapter module and separate outcome-probe credential are present." });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
checks.push({ id: "runtime_configuration", status: "FAIL", message: message(error) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
135
161
|
try {
|
|
136
|
-
const
|
|
137
|
-
if (!
|
|
138
|
-
throw new Error(
|
|
162
|
+
const health = await gatewayHealth(`http://${config.host}:${config.port}`, options.fetch ?? fetch, Boolean(config.runtimeWorker));
|
|
163
|
+
if (!health)
|
|
164
|
+
throw new Error("Gateway health or runtime-worker readiness was not established.");
|
|
139
165
|
checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
|
|
140
166
|
}
|
|
141
167
|
catch {
|
|
142
168
|
checks.push({ id: "process", status: "WARN", message: "Gateway is not running yet. Start managed mode with `witnora gateway start`." });
|
|
143
169
|
}
|
|
144
170
|
}
|
|
171
|
+
const runtimeConfigurationReady = checks.some((check) => check.id === "runtime_configuration" && check.status === "PASS")
|
|
172
|
+
&& checks.some((check) => check.id === "process" && check.status === "PASS");
|
|
145
173
|
if (config && secrets) {
|
|
146
|
-
checks.push(
|
|
147
|
-
|
|
174
|
+
checks.push(config.runtimeWorker && runtimeConfigurationReady
|
|
175
|
+
? { id: "enforcement", status: "PASS", message: `Exact adapter ${config.runtimeWorker.adapterId}@${config.runtimeWorker.adapterVersion} is configured behind the durable worker.` }
|
|
176
|
+
: { id: "enforcement", status: "WARN", message: "Exact write-credential mediation is not proven ready; current evidence ceiling remains RECORDED." });
|
|
177
|
+
checks.push(config.runtimeWorker && runtimeConfigurationReady
|
|
178
|
+
? { id: "outcome_probe", status: "PASS", message: `Separate read-only probe ${config.runtimeWorker.probeId} is configured.` }
|
|
179
|
+
: { id: "outcome_probe", status: "WARN", message: "An independent digest-pinned probe and read-only target credential are not proven ready." });
|
|
148
180
|
}
|
|
149
181
|
const failed = checks.some((check) => check.status === "FAIL");
|
|
150
182
|
return {
|
|
151
183
|
schemaVersion: "witnora.customer_gateway_doctor.v0.1",
|
|
152
|
-
overall: failed ? "SETUP_INCOMPLETE" : "READY_TO_RECORD",
|
|
184
|
+
overall: failed ? "SETUP_INCOMPLETE" : runtimeConfigurationReady ? "READY_FOR_RUNTIME" : "READY_TO_RECORD",
|
|
153
185
|
checks,
|
|
154
|
-
evidenceCeiling: "recorded",
|
|
155
|
-
nextAction: failed ? "Run
|
|
186
|
+
evidenceCeiling: runtimeConfigurationReady ? "outcome_verified" : "recorded",
|
|
187
|
+
nextAction: failed ? "Resolve the failed Gateway checks before sending another action." : runtimeConfigurationReady ? "Run the Agent normally; approved configured actions continue automatically after the human decision." : "Ensure the managed Gateway is healthy, then send one sandbox run through its local event API.",
|
|
156
188
|
};
|
|
157
189
|
}
|
|
158
190
|
export async function runCustomerGateway(options) {
|
|
@@ -161,6 +193,7 @@ export async function runCustomerGateway(options) {
|
|
|
161
193
|
const CustomerSourceKeyRing = remoteCollector.CustomerSourceKeyRing;
|
|
162
194
|
const RemoteCollectorClient = remoteCollector.RemoteCollectorClient;
|
|
163
195
|
const startCustomerOwnedCollectorGateway = collectorGateway.startCustomerOwnedCollectorGateway;
|
|
196
|
+
const durableWorker = configRuntimeWorkerImport();
|
|
164
197
|
const directory = resolve(options.repository ?? process.cwd(), options.dir ?? ".witnora/gateway");
|
|
165
198
|
const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
|
|
166
199
|
const secrets = parseSecrets(await readFile(join(directory, "secrets.json"), "utf8"));
|
|
@@ -173,6 +206,14 @@ export async function runCustomerGateway(options) {
|
|
|
173
206
|
const keyRing = await (await exists(keyRingPath)
|
|
174
207
|
? CustomerSourceKeyRing.open(keyRingPath)
|
|
175
208
|
: CustomerSourceKeyRing.create(keyRingPath, config.collectorId));
|
|
209
|
+
const actionWorker = config.runtimeWorker
|
|
210
|
+
? await createConfiguredRuntimeActionWorker({
|
|
211
|
+
directory, config, connection, configHome: options.configHome,
|
|
212
|
+
DurableApprovedActionWorker: (await durableWorker).DurableApprovedActionWorker,
|
|
213
|
+
FileActionCheckpointStore: (await durableWorker).FileActionCheckpointStore,
|
|
214
|
+
})
|
|
215
|
+
: undefined;
|
|
216
|
+
actionWorker?.start();
|
|
176
217
|
const gateway = await startCustomerOwnedCollectorGateway({
|
|
177
218
|
client: new RemoteCollectorClient({ baseUrl: connection.server, projectId: connection.projectId, apiKey: connection.apiKey }),
|
|
178
219
|
keyRing,
|
|
@@ -181,13 +222,143 @@ export async function runCustomerGateway(options) {
|
|
|
181
222
|
host: process.env.WITNORA_GATEWAY_HOST?.trim() || config.host,
|
|
182
223
|
port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, config.port),
|
|
183
224
|
environment: "customer-owned",
|
|
225
|
+
...(actionWorker && config.runtimeWorker ? { actionWorker: {
|
|
226
|
+
track: (input) => isConfiguredRuntimeProposal(input.proposal, config.runtimeWorker) ? actionWorker.track(input) : Promise.resolve(undefined),
|
|
227
|
+
status: () => actionWorker.status(), close: () => actionWorker.stop(),
|
|
228
|
+
} } : {}),
|
|
184
229
|
});
|
|
185
230
|
process.stdout.write(`Witnora customer-owned Gateway listening on ${gateway.baseUrl}\n`);
|
|
186
|
-
process.stdout.write(
|
|
231
|
+
process.stdout.write(config.runtimeWorker
|
|
232
|
+
? "Runtime worker: READY. Approved exact configured actions execute automatically, then use the separate read-only probe and Hosted signed receipt.\n"
|
|
233
|
+
: "Evidence ceiling: RECORDED. No exact target adapter and separate outcome probe are configured, so runtime writes remain fail-closed.\n");
|
|
187
234
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
188
235
|
process.once(signal, () => void gateway.close().finally(() => process.exit(0)));
|
|
189
236
|
}
|
|
190
237
|
}
|
|
238
|
+
export async function createConfiguredRuntimeActionWorker(input) {
|
|
239
|
+
const workerConfig = input.config.runtimeWorker;
|
|
240
|
+
if (!workerConfig?.enabled)
|
|
241
|
+
throw new Error("Runtime worker is not enabled.");
|
|
242
|
+
const adapterModulePath = resolve(input.directory, workerConfig.adapterModulePath);
|
|
243
|
+
const probeModulePath = resolve(input.directory, workerConfig.probeModulePath);
|
|
244
|
+
if (adapterModulePath === probeModulePath)
|
|
245
|
+
throw new Error("Adapter and outcome probe must use separate modules.");
|
|
246
|
+
const [adapterBytes, probeBytes] = await Promise.all([readFile(adapterModulePath), readFile(probeModulePath)]);
|
|
247
|
+
const adapterModuleDigest = createHash("sha256").update(adapterBytes).digest("hex");
|
|
248
|
+
const probeModuleDigest = createHash("sha256").update(probeBytes).digest("hex");
|
|
249
|
+
if (adapterModuleDigest !== workerConfig.adapterModuleSha256 || probeModuleDigest !== workerConfig.probeModuleSha256)
|
|
250
|
+
throw new Error("Runtime adapter or probe module digest does not match gateway.json; refusing to load it.");
|
|
251
|
+
const adapterModule = await import(`${pathToFileURL(adapterModulePath).href}?sha256=${adapterModuleDigest}`);
|
|
252
|
+
if (typeof adapterModule.createWitnoraRuntimeAdapter !== "function")
|
|
253
|
+
throw new Error("Runtime adapter module must export its factory.");
|
|
254
|
+
const probeConnection = await loadConnection(workerConfig.probeConnectionName, { configHome: input.configHome });
|
|
255
|
+
if (!probeConnection || probeConnection.projectId !== input.config.projectId || probeConnection.server !== input.config.server) {
|
|
256
|
+
throw new Error("The separate outcome-probe credential does not match this Gateway project and server.");
|
|
257
|
+
}
|
|
258
|
+
if (probeConnection.apiKey === input.connection.apiKey)
|
|
259
|
+
throw new Error("The execution Gateway and outcome probe must use separate Hosted credentials.");
|
|
260
|
+
const adapterContext = Object.freeze({
|
|
261
|
+
adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion,
|
|
262
|
+
projectId: input.config.projectId,
|
|
263
|
+
hosted: runtimeHostedTransport(input.connection, input.fetch ?? fetch),
|
|
264
|
+
storageDirectory: resolve(input.directory, "data", "runtime-actions"),
|
|
265
|
+
});
|
|
266
|
+
const isolatedProbeConfig = {
|
|
267
|
+
modulePath: probeModulePath,
|
|
268
|
+
moduleSha256: probeModuleDigest,
|
|
269
|
+
probeId: workerConfig.probeId,
|
|
270
|
+
projectId: input.config.projectId,
|
|
271
|
+
credentialHandle: workerConfig.probeTargetCredentialHandle,
|
|
272
|
+
};
|
|
273
|
+
const runtime = await adapterModule.createWitnoraRuntimeAdapter(adapterContext);
|
|
274
|
+
await inspectIsolatedOutcomeProbe(isolatedProbeConfig);
|
|
275
|
+
if (runtime.id !== workerConfig.adapterId || runtime.version !== workerConfig.adapterVersion || runtime.reconcileReadOnly !== true
|
|
276
|
+
|| typeof runtime.prepareClaim !== "function" || typeof runtime.execute !== "function" || typeof runtime.reconcile !== "function") {
|
|
277
|
+
throw new Error("Runtime adapter does not match the exact configured id/version or read-only reconciliation contract.");
|
|
278
|
+
}
|
|
279
|
+
const requestFetch = input.fetch ?? fetch;
|
|
280
|
+
const primary = projectTransport(input.connection, requestFetch);
|
|
281
|
+
const verifier = projectTransport(probeConnection, requestFetch);
|
|
282
|
+
return new input.DurableApprovedActionWorker({
|
|
283
|
+
config: {
|
|
284
|
+
adapterId: workerConfig.adapterId, adapterVersion: workerConfig.adapterVersion, probeId: workerConfig.probeId,
|
|
285
|
+
probeCredentialHandle: workerConfig.probeTargetCredentialHandle,
|
|
286
|
+
runtimeIdentityId: workerConfig.runtimeIdentityId, grantTtlSeconds: workerConfig.grantTtlSeconds, pollIntervalMs: workerConfig.pollIntervalMs,
|
|
287
|
+
},
|
|
288
|
+
store: new input.FileActionCheckpointStore(resolve(input.directory, "data", "runtime-actions", "checkpoints")),
|
|
289
|
+
hosted: {
|
|
290
|
+
getAction: (actionId) => primary(`actions/${encodeURIComponent(actionId)}`),
|
|
291
|
+
issueExecutionGrant: (actionId, body, idempotencyKey) => primary(`actions/${encodeURIComponent(actionId)}/execution-grant`, { method: "POST", body, idempotencyKey }),
|
|
292
|
+
verifyAction: (actionId, body, idempotencyKey) => verifier(`actions/${encodeURIComponent(actionId)}/verify`, { method: "POST", body, idempotencyKey }),
|
|
293
|
+
listActionReceipts: async (actionId) => (await primary(`actions/${encodeURIComponent(actionId)}/receipts`)).receipts ?? [],
|
|
294
|
+
claimExecutionGrant: (executionGrantId, claim, idempotencyKey) => claimHostedExecutionGrant(input.connection, requestFetch, executionGrantId, claim, idempotencyKey),
|
|
295
|
+
},
|
|
296
|
+
runtime: { reconcileReadOnly: true, prepareClaim: runtime.prepareClaim, execute: runtime.execute, reconcile: runtime.reconcile },
|
|
297
|
+
probe: {
|
|
298
|
+
id: workerConfig.probeId,
|
|
299
|
+
credentialHandle: workerConfig.probeTargetCredentialHandle,
|
|
300
|
+
readOnly: true,
|
|
301
|
+
observe: (request) => observeInIsolatedOutcomeProbe(isolatedProbeConfig, request),
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function projectTransport(connection, requestFetch) {
|
|
306
|
+
return async (suffix, options = {}) => {
|
|
307
|
+
const response = await requestFetch(`${connection.server}/v1/projects/${encodeURIComponent(connection.projectId)}/${suffix}`, {
|
|
308
|
+
method: options.method ?? "GET",
|
|
309
|
+
headers: { authorization: `Bearer ${connection.apiKey}`, ...(options.body ? { "content-type": "application/json" } : {}), ...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}) },
|
|
310
|
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
311
|
+
});
|
|
312
|
+
const body = await response.json().catch(() => ({}));
|
|
313
|
+
if (!response.ok)
|
|
314
|
+
throw new Error(String(body.error ?? `Witnora Hosted API returned HTTP ${response.status}.`));
|
|
315
|
+
return body;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function runtimeHostedTransport(connection, requestFetch) {
|
|
319
|
+
return {
|
|
320
|
+
baseUrl: connection.server,
|
|
321
|
+
projectId: connection.projectId,
|
|
322
|
+
async request(suffix, options = {}) {
|
|
323
|
+
if (!/^(execution-grants\/[A-Za-z0-9._:-]+\/consume|execution-attempts\/[A-Za-z0-9._:-]+\/phase|execution-sessions\/[A-Za-z0-9._:-]+\/evidence)$/.test(suffix)) {
|
|
324
|
+
throw new Error("Runtime adapter attempted to access a Hosted path outside its execution boundary.");
|
|
325
|
+
}
|
|
326
|
+
if ((options.method ?? "GET") !== "POST")
|
|
327
|
+
throw new Error("Runtime Hosted execution paths require POST.");
|
|
328
|
+
const response = await requestFetch(`${connection.server}/v1/runtime/projects/${encodeURIComponent(connection.projectId)}/${suffix}`, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: { authorization: `Bearer ${connection.apiKey}`, ...(options.body ? { "content-type": "application/json" } : {}), ...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}) },
|
|
331
|
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
332
|
+
});
|
|
333
|
+
const body = await response.json().catch(() => ({}));
|
|
334
|
+
if (!response.ok)
|
|
335
|
+
throw new Error(String(body.error ?? `Witnora Hosted runtime API returned HTTP ${response.status}.`));
|
|
336
|
+
return body;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async function claimHostedExecutionGrant(connection, requestFetch, executionGrantId, claim, idempotencyKey) {
|
|
341
|
+
const response = await requestFetch(`${connection.server}/v1/runtime/projects/${encodeURIComponent(connection.projectId)}/execution-grants/${encodeURIComponent(executionGrantId)}/claim`, {
|
|
342
|
+
method: "POST",
|
|
343
|
+
headers: { authorization: `Bearer ${connection.apiKey}`, "content-type": "application/json", "idempotency-key": idempotencyKey },
|
|
344
|
+
body: JSON.stringify(claim),
|
|
345
|
+
});
|
|
346
|
+
if (response.status === 409)
|
|
347
|
+
return { acquired: false };
|
|
348
|
+
const body = await response.json().catch(() => ({}));
|
|
349
|
+
if (!response.ok)
|
|
350
|
+
throw new Error(String(body.error ?? `Witnora Hosted grant claim returned HTTP ${response.status}.`));
|
|
351
|
+
return { acquired: body.status === "CLAIMED" };
|
|
352
|
+
}
|
|
353
|
+
function configRuntimeWorkerImport() {
|
|
354
|
+
return import(new URL("./internal/control-client/durable-action-worker.js", import.meta.url).href);
|
|
355
|
+
}
|
|
356
|
+
function isConfiguredRuntimeProposal(proposal, config) {
|
|
357
|
+
const intent = proposal.executionIntent;
|
|
358
|
+
return Boolean(intent && typeof intent === "object" && !Array.isArray(intent)
|
|
359
|
+
&& intent.adapterId === config.adapterId
|
|
360
|
+
&& typeof intent.adapterVersionConstraint === "string");
|
|
361
|
+
}
|
|
191
362
|
export async function startManagedCustomerGateway(options = {}) {
|
|
192
363
|
const repository = resolve(options.repository ?? process.cwd());
|
|
193
364
|
const directory = resolve(repository, options.dir ?? ".witnora/gateway");
|
|
@@ -272,7 +443,7 @@ export async function statusManagedCustomerGateway(options = {}) {
|
|
|
272
443
|
const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
|
|
273
444
|
const baseUrl = `http://${config.host}:${config.port}`;
|
|
274
445
|
const runtime = await readRuntime(directory);
|
|
275
|
-
const health = await gatewayHealth(baseUrl, options.fetch ?? fetch);
|
|
446
|
+
const health = await gatewayHealth(baseUrl, options.fetch ?? fetch, Boolean(config.runtimeWorker));
|
|
276
447
|
const base = {
|
|
277
448
|
schemaVersion: "witnora.managed_gateway_status.v0.1",
|
|
278
449
|
baseUrl,
|
|
@@ -387,8 +558,30 @@ function parseConfig(raw) {
|
|
|
387
558
|
throw new Error("Gateway privacyMode must remain metadata_only.");
|
|
388
559
|
if (!value.host || !Number.isSafeInteger(value.port) || Number(value.port) < 1 || Number(value.port) > 65_535)
|
|
389
560
|
throw new Error("Gateway host and port are invalid.");
|
|
561
|
+
if (value.runtimeWorker)
|
|
562
|
+
validateRuntimeWorkerConfig(value.runtimeWorker);
|
|
390
563
|
return value;
|
|
391
564
|
}
|
|
565
|
+
function validateRuntimeWorkerConfig(value) {
|
|
566
|
+
if (value.enabled !== true || !value.adapterModulePath || !/^[a-f0-9]{64}$/.test(value.adapterModuleSha256)
|
|
567
|
+
|| !value.probeModulePath || !/^[a-f0-9]{64}$/.test(value.probeModuleSha256)
|
|
568
|
+
|| !value.adapterId || !/^v?\d+\.\d+\.\d+$/.test(value.adapterVersion) || !value.probeId
|
|
569
|
+
|| !value.probeConnectionName || !credentialHandle(value.probeTargetCredentialHandle) || !value.runtimeIdentityId) {
|
|
570
|
+
throw new Error("gateway.json runtimeWorker requires separate exact adapter/probe module digests, adapter id/version, probe credentials, and runtime identity.");
|
|
571
|
+
}
|
|
572
|
+
if (value.adapterModulePath === value.probeModulePath || value.adapterModuleSha256 === value.probeModuleSha256)
|
|
573
|
+
throw new Error("runtimeWorker adapter and outcome probe modules must be independently pinned.");
|
|
574
|
+
if (value.adapterId === value.probeId)
|
|
575
|
+
throw new Error("runtimeWorker adapter and outcome probe must be separate.");
|
|
576
|
+
if (value.grantTtlSeconds !== undefined && (!Number.isInteger(value.grantTtlSeconds) || value.grantTtlSeconds < 15 || value.grantTtlSeconds > 300))
|
|
577
|
+
throw new Error("runtimeWorker grantTtlSeconds must be between 15 and 300.");
|
|
578
|
+
if (value.pollIntervalMs !== undefined && (!Number.isInteger(value.pollIntervalMs) || value.pollIntervalMs < 250 || value.pollIntervalMs > 30_000))
|
|
579
|
+
throw new Error("runtimeWorker pollIntervalMs must be between 250 and 30000.");
|
|
580
|
+
}
|
|
581
|
+
function credentialHandle(value) {
|
|
582
|
+
return typeof value === "string" && value.length <= 256 && /^[a-z][a-z0-9+.-]*:\/\/[^\s]+$/i.test(value)
|
|
583
|
+
&& !/(?:ac_live_|bearer\s|private.?key|password|token=)/i.test(value);
|
|
584
|
+
}
|
|
392
585
|
function parseSecrets(raw) {
|
|
393
586
|
const value = JSON.parse(raw);
|
|
394
587
|
if (value.schemaVersion !== SECRETS_SCHEMA || typeof value.gatewayToken !== "string" || value.gatewayToken.length < 32) {
|
|
@@ -443,12 +636,14 @@ function gatewayPaths(directory) {
|
|
|
443
636
|
join(directory, "README.md"),
|
|
444
637
|
];
|
|
445
638
|
}
|
|
446
|
-
async function gatewayHealth(baseUrl, requestFetch) {
|
|
639
|
+
async function gatewayHealth(baseUrl, requestFetch, requireRuntimeWorker = false) {
|
|
447
640
|
try {
|
|
448
641
|
const response = await requestFetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(800) });
|
|
449
642
|
if (!response.ok)
|
|
450
643
|
return undefined;
|
|
451
644
|
const value = await response.json();
|
|
645
|
+
if (requireRuntimeWorker && value.actionWorker?.ready !== true)
|
|
646
|
+
return undefined;
|
|
452
647
|
return typeof value.collectorId === "string" && value.collectorId ? { collectorId: value.collectorId } : undefined;
|
|
453
648
|
}
|
|
454
649
|
catch {
|
|
@@ -30,6 +30,14 @@ export interface CustomerOwnedCollectorGatewayOptions {
|
|
|
30
30
|
flushIntervalMs?: number;
|
|
31
31
|
heartbeatIntervalMs?: number;
|
|
32
32
|
maxBodyBytes?: number;
|
|
33
|
+
actionWorker?: {
|
|
34
|
+
track(input: {
|
|
35
|
+
actionId: string;
|
|
36
|
+
proposal: Record<string, unknown>;
|
|
37
|
+
}): Promise<unknown>;
|
|
38
|
+
status(): Promise<Record<string, unknown>>;
|
|
39
|
+
close?(): void | Promise<void>;
|
|
40
|
+
};
|
|
33
41
|
}
|
|
34
42
|
export interface CustomerOwnedCollectorGateway {
|
|
35
43
|
baseUrl: string;
|
|
@@ -50,6 +58,7 @@ export interface CollectorGatewayStatus {
|
|
|
50
58
|
lastAckSequence?: number;
|
|
51
59
|
lastRemoteSuccessAt?: string;
|
|
52
60
|
lastRemoteError?: string;
|
|
61
|
+
actionWorker?: Record<string, unknown>;
|
|
53
62
|
}
|
|
54
63
|
export declare function startCustomerOwnedCollectorGateway(options: CustomerOwnedCollectorGatewayOptions): Promise<CustomerOwnedCollectorGateway>;
|
|
55
64
|
//# sourceMappingURL=collector-gateway.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collector-gateway.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/collector-gateway.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,qBAAqB,EAGrB,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,wBAAwB;IACvC,iBAAiB,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjJ,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,yBAAyB,EAAE,EAAE,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAClH,SAAS,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,oBAAoB,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAChK,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC7F,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3G,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC9D,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACjI;AAED,MAAM,WAAW,oCAAoC;IACnD,MAAM,EAAE,wBAAwB,CAAC;IACjC,OAAO,EAAE,qBAAqB,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,YAAY,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"collector-gateway.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/collector-gateway.ts"],"names":[],"mappings":"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"}
|
|
@@ -84,6 +84,7 @@ export async function startCustomerOwnedCollectorGateway(options) {
|
|
|
84
84
|
lastAckSequence,
|
|
85
85
|
lastRemoteSuccessAt,
|
|
86
86
|
lastRemoteError,
|
|
87
|
+
...(options.actionWorker ? { actionWorker: await options.actionWorker.status() } : {}),
|
|
87
88
|
};
|
|
88
89
|
};
|
|
89
90
|
const server = createServer(async (request, response) => {
|
|
@@ -96,7 +97,11 @@ export async function startCustomerOwnedCollectorGateway(options) {
|
|
|
96
97
|
return json(response, 200, await flush());
|
|
97
98
|
if (request.method === "POST" && url.pathname === "/v1/actions") {
|
|
98
99
|
const body = await readJson(request, options.maxBodyBytes ?? 1_048_576);
|
|
99
|
-
|
|
100
|
+
const proposal = object(body.proposal);
|
|
101
|
+
const action = await options.client.proposeAction(proposal, identifier(body.idempotencyKey, "idempotencyKey"));
|
|
102
|
+
if (options.actionWorker)
|
|
103
|
+
await options.actionWorker.track({ actionId: identifier(action.id, "actionId"), proposal });
|
|
104
|
+
return json(response, 202, action);
|
|
100
105
|
}
|
|
101
106
|
const actionRoute = url.pathname.match(/^\/v1\/actions\/([A-Za-z0-9._:-]+)$/);
|
|
102
107
|
if (request.method === "GET" && actionRoute) {
|
|
@@ -187,6 +192,7 @@ export async function startCustomerOwnedCollectorGateway(options) {
|
|
|
187
192
|
close: async () => {
|
|
188
193
|
clearInterval(flushTimer);
|
|
189
194
|
clearInterval(heartbeatTimer);
|
|
195
|
+
await options.actionWorker?.close?.();
|
|
190
196
|
await flush();
|
|
191
197
|
await close(server);
|
|
192
198
|
},
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export type DurableActionPhase = "TRACKED" | "WAITING_APPROVAL" | "DENIED" | "EXPIRED" | "BLOCKED" | "GRANT_ISSUED" | "EXECUTION_STARTED" | "UNKNOWN_RESULT" | "EXECUTED" | "PROBED" | "COMPLETED";
|
|
2
|
+
export interface DurableWorkerProposal {
|
|
3
|
+
externalId: string;
|
|
4
|
+
actionType: string;
|
|
5
|
+
targetSystem: string;
|
|
6
|
+
expectedState?: Record<string, unknown>;
|
|
7
|
+
executionIntent: {
|
|
8
|
+
adapterId: string;
|
|
9
|
+
adapterVersionConstraint: string;
|
|
10
|
+
allowedOrigins: string[];
|
|
11
|
+
allowedOperation: string;
|
|
12
|
+
allowedResource: string;
|
|
13
|
+
approvedParameters: Record<string, unknown>;
|
|
14
|
+
outcomePredicate: Record<string, unknown>;
|
|
15
|
+
agentBuildId: string;
|
|
16
|
+
agentBuildDigest: string;
|
|
17
|
+
};
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface DurableWorkerAction {
|
|
21
|
+
id: string;
|
|
22
|
+
externalId: string;
|
|
23
|
+
status: "PENDING_APPROVAL" | "APPROVED" | "ALLOWED" | "REJECTED" | "DENIED" | "APPROVAL_EXPIRED" | "GRANT_EXPIRED" | "VERIFIED" | string;
|
|
24
|
+
actionType: string;
|
|
25
|
+
targetSystem: string;
|
|
26
|
+
expectedState?: Record<string, unknown>;
|
|
27
|
+
approvalExpiresAt?: string;
|
|
28
|
+
assuranceContext?: {
|
|
29
|
+
executionIntent?: {
|
|
30
|
+
adapterId: string;
|
|
31
|
+
adapterVersionConstraint: string;
|
|
32
|
+
allowedOrigins: string[];
|
|
33
|
+
allowedOperation: string;
|
|
34
|
+
allowedResource: string;
|
|
35
|
+
approvedParametersDigest: string;
|
|
36
|
+
outcomePredicateDigest: string;
|
|
37
|
+
agentBuildId: string;
|
|
38
|
+
agentBuildDigest: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
export interface DurableWorkerGrant {
|
|
44
|
+
id: string;
|
|
45
|
+
status: string;
|
|
46
|
+
grant: {
|
|
47
|
+
payload?: Record<string, unknown>;
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
};
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
export interface DurableWorkerExecutionResult {
|
|
53
|
+
executionSessionId: string;
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
export interface DurableWorkerHostedReservation {
|
|
57
|
+
executionSessionId: string;
|
|
58
|
+
claim: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
export interface DurableWorkerObservation {
|
|
61
|
+
observedState: Record<string, unknown>;
|
|
62
|
+
observationMethod: "TARGET_API" | "TARGET_UI" | "TARGET_AUDIT_LOG" | "WEBHOOK" | "DATABASE_QUERY" | "THIRD_PARTY_CONFIRMATION";
|
|
63
|
+
observationSource: string;
|
|
64
|
+
evidenceReferences?: string[];
|
|
65
|
+
confidence?: number;
|
|
66
|
+
}
|
|
67
|
+
export interface DurableActionCheckpoint {
|
|
68
|
+
schemaVersion: "witnora.durable_action_checkpoint.v0.1";
|
|
69
|
+
actionId: string;
|
|
70
|
+
phase: DurableActionPhase;
|
|
71
|
+
proposal: DurableWorkerProposal;
|
|
72
|
+
proposalSha256: string;
|
|
73
|
+
grant?: DurableWorkerGrant;
|
|
74
|
+
execution?: DurableWorkerExecutionResult;
|
|
75
|
+
observation?: DurableWorkerObservation;
|
|
76
|
+
receiptId?: string;
|
|
77
|
+
executionClaimId?: string;
|
|
78
|
+
hostedReservation?: DurableWorkerHostedReservation;
|
|
79
|
+
limitation?: string;
|
|
80
|
+
updatedAt: string;
|
|
81
|
+
}
|
|
82
|
+
export interface ActionCheckpointStore {
|
|
83
|
+
load(actionId: string): Promise<DurableActionCheckpoint | undefined>;
|
|
84
|
+
save(checkpoint: DurableActionCheckpoint): Promise<void>;
|
|
85
|
+
list(): Promise<DurableActionCheckpoint[]>;
|
|
86
|
+
claimExecution(actionId: string, ownerId: string, claimedAt: string): Promise<{
|
|
87
|
+
acquired: boolean;
|
|
88
|
+
claimId: string;
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
export declare class FileActionCheckpointStore implements ActionCheckpointStore {
|
|
92
|
+
private readonly directory;
|
|
93
|
+
constructor(directory: string);
|
|
94
|
+
load(actionId: string): Promise<DurableActionCheckpoint | undefined>;
|
|
95
|
+
save(checkpoint: DurableActionCheckpoint): Promise<void>;
|
|
96
|
+
list(): Promise<DurableActionCheckpoint[]>;
|
|
97
|
+
claimExecution(actionId: string, ownerId: string, claimedAt: string): Promise<{
|
|
98
|
+
acquired: boolean;
|
|
99
|
+
claimId: string;
|
|
100
|
+
}>;
|
|
101
|
+
private path;
|
|
102
|
+
private claimPath;
|
|
103
|
+
}
|
|
104
|
+
export interface DurableApprovedActionWorkerOptions {
|
|
105
|
+
config: {
|
|
106
|
+
adapterId: string;
|
|
107
|
+
adapterVersion: string;
|
|
108
|
+
probeId: string;
|
|
109
|
+
probeCredentialHandle: string;
|
|
110
|
+
runtimeIdentityId: string;
|
|
111
|
+
grantTtlSeconds?: number;
|
|
112
|
+
pollIntervalMs?: number;
|
|
113
|
+
};
|
|
114
|
+
store: ActionCheckpointStore;
|
|
115
|
+
hosted: {
|
|
116
|
+
getAction(actionId: string): Promise<DurableWorkerAction>;
|
|
117
|
+
issueExecutionGrant(actionId: string, input: Record<string, unknown>, idempotencyKey: string): Promise<DurableWorkerGrant>;
|
|
118
|
+
verifyAction(actionId: string, input: Record<string, unknown>, idempotencyKey: string): Promise<{
|
|
119
|
+
status: string;
|
|
120
|
+
verificationSuccess?: boolean;
|
|
121
|
+
}>;
|
|
122
|
+
listActionReceipts(actionId: string): Promise<Array<{
|
|
123
|
+
id: string;
|
|
124
|
+
receipt?: {
|
|
125
|
+
signatureSet?: unknown[];
|
|
126
|
+
};
|
|
127
|
+
}>>;
|
|
128
|
+
claimExecutionGrant(executionGrantId: string, claim: Record<string, unknown>, idempotencyKey: string): Promise<{
|
|
129
|
+
acquired: boolean;
|
|
130
|
+
}>;
|
|
131
|
+
};
|
|
132
|
+
runtime: {
|
|
133
|
+
reconcileReadOnly: true;
|
|
134
|
+
prepareClaim(input: {
|
|
135
|
+
action: DurableWorkerAction;
|
|
136
|
+
proposal: DurableWorkerProposal;
|
|
137
|
+
grant: DurableWorkerGrant;
|
|
138
|
+
}): Promise<DurableWorkerHostedReservation>;
|
|
139
|
+
execute(input: {
|
|
140
|
+
action: DurableWorkerAction;
|
|
141
|
+
proposal: DurableWorkerProposal;
|
|
142
|
+
grant: DurableWorkerGrant;
|
|
143
|
+
hostedReservation: DurableWorkerHostedReservation;
|
|
144
|
+
}): Promise<DurableWorkerExecutionResult>;
|
|
145
|
+
reconcile(input: {
|
|
146
|
+
action: DurableWorkerAction;
|
|
147
|
+
proposal: DurableWorkerProposal;
|
|
148
|
+
grant: DurableWorkerGrant;
|
|
149
|
+
}): Promise<DurableWorkerExecutionResult | undefined>;
|
|
150
|
+
};
|
|
151
|
+
probe: {
|
|
152
|
+
id: string;
|
|
153
|
+
credentialHandle: string;
|
|
154
|
+
readOnly: true;
|
|
155
|
+
observe(input: {
|
|
156
|
+
action: DurableWorkerAction;
|
|
157
|
+
proposal: DurableWorkerProposal;
|
|
158
|
+
grant: DurableWorkerGrant;
|
|
159
|
+
execution: DurableWorkerExecutionResult;
|
|
160
|
+
}): Promise<DurableWorkerObservation>;
|
|
161
|
+
};
|
|
162
|
+
now?: () => Date;
|
|
163
|
+
}
|
|
164
|
+
export interface DurableActionWorkerStatus {
|
|
165
|
+
schemaVersion: "witnora.durable_action_worker_status.v0.1";
|
|
166
|
+
ready: boolean;
|
|
167
|
+
trackedActionCount: number;
|
|
168
|
+
pendingActionCount: number;
|
|
169
|
+
unknownResultCount: number;
|
|
170
|
+
lastHeartbeatAt: string;
|
|
171
|
+
lastError?: string;
|
|
172
|
+
}
|
|
173
|
+
export declare class DurableApprovedActionWorker {
|
|
174
|
+
private readonly options;
|
|
175
|
+
private readonly now;
|
|
176
|
+
private readonly chains;
|
|
177
|
+
private timer?;
|
|
178
|
+
private lastHeartbeatAt;
|
|
179
|
+
private lastError?;
|
|
180
|
+
private readonly workerId;
|
|
181
|
+
constructor(options: DurableApprovedActionWorkerOptions);
|
|
182
|
+
track(input: {
|
|
183
|
+
actionId: string;
|
|
184
|
+
proposal: DurableWorkerProposal;
|
|
185
|
+
}): Promise<DurableActionCheckpoint>;
|
|
186
|
+
tick(actionId: string): Promise<DurableActionCheckpoint>;
|
|
187
|
+
start(): void;
|
|
188
|
+
stop(): void;
|
|
189
|
+
status(): Promise<DurableActionWorkerStatus>;
|
|
190
|
+
private tickOnce;
|
|
191
|
+
private persist;
|
|
192
|
+
}
|
|
193
|
+
export declare function runtimeAdapterVersionSatisfies(version: string, constraint: string): boolean;
|
|
194
|
+
//# sourceMappingURL=durable-action-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"durable-action-worker.d.ts","sourceRoot":"","sources":["../../../../agentcert-sdk/src/durable-action-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { canonicalJson } from "./canonical.js";
|
|
5
|
+
export class FileActionCheckpointStore {
|
|
6
|
+
directory;
|
|
7
|
+
constructor(directory) { this.directory = resolve(directory); }
|
|
8
|
+
async load(actionId) {
|
|
9
|
+
try {
|
|
10
|
+
return parseCheckpoint(await readFile(this.path(actionId), "utf8"));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (isMissing(error))
|
|
14
|
+
return undefined;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async save(checkpoint) {
|
|
19
|
+
const path = this.path(checkpoint.actionId);
|
|
20
|
+
await mkdir(dirname(path), { recursive: true });
|
|
21
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
22
|
+
const handle = await open(temporary, "w", 0o600);
|
|
23
|
+
try {
|
|
24
|
+
await handle.writeFile(`${JSON.stringify(checkpoint, null, 2)}\n`, "utf8");
|
|
25
|
+
await handle.sync();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await handle.close();
|
|
29
|
+
}
|
|
30
|
+
await rename(temporary, path);
|
|
31
|
+
}
|
|
32
|
+
async list() {
|
|
33
|
+
let names;
|
|
34
|
+
try {
|
|
35
|
+
names = await readdir(this.directory);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (isMissing(error))
|
|
39
|
+
return [];
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const values = await Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => parseCheckpoint(await readFile(join(this.directory, name), "utf8"))));
|
|
43
|
+
return values.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
|
|
44
|
+
}
|
|
45
|
+
async claimExecution(actionId, ownerId, claimedAt) {
|
|
46
|
+
const path = this.claimPath(actionId);
|
|
47
|
+
await mkdir(dirname(path), { recursive: true });
|
|
48
|
+
const claimId = randomUUID();
|
|
49
|
+
try {
|
|
50
|
+
const handle = await open(path, "wx", 0o600);
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(`${JSON.stringify({ schemaVersion: "witnora.execution_claim.v0.1", actionId, claimId, ownerId, claimedAt })}\n`, "utf8");
|
|
53
|
+
await handle.sync();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
return { acquired: true, claimId };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (!isAlreadyExists(error))
|
|
62
|
+
throw error;
|
|
63
|
+
const existing = JSON.parse(await readFile(path, "utf8"));
|
|
64
|
+
if (typeof existing.claimId !== "string" || !existing.claimId)
|
|
65
|
+
throw new Error("Existing execution claim is invalid; refusing to dispatch.");
|
|
66
|
+
return { acquired: false, claimId: existing.claimId };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
path(actionId) {
|
|
70
|
+
if (!/^[A-Za-z0-9._:-]+$/.test(actionId))
|
|
71
|
+
throw new Error("actionId contains unsupported characters.");
|
|
72
|
+
return join(this.directory, `${actionId}.json`);
|
|
73
|
+
}
|
|
74
|
+
claimPath(actionId) {
|
|
75
|
+
if (!/^[A-Za-z0-9._:-]+$/.test(actionId))
|
|
76
|
+
throw new Error("actionId contains unsupported characters.");
|
|
77
|
+
return join(this.directory, `${actionId}.execution-claim`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class DurableApprovedActionWorker {
|
|
81
|
+
options;
|
|
82
|
+
now;
|
|
83
|
+
chains = new Map();
|
|
84
|
+
timer;
|
|
85
|
+
lastHeartbeatAt;
|
|
86
|
+
lastError;
|
|
87
|
+
workerId = randomUUID();
|
|
88
|
+
constructor(options) {
|
|
89
|
+
this.options = options;
|
|
90
|
+
this.now = options.now ?? (() => new Date());
|
|
91
|
+
if (!options.config.adapterId || !validVersion(options.config.adapterVersion))
|
|
92
|
+
throw new Error("An exact adapter id and semantic version are required.");
|
|
93
|
+
if (!options.config.probeId || !options.config.probeCredentialHandle || options.probe.id !== options.config.probeId
|
|
94
|
+
|| options.probe.credentialHandle !== options.config.probeCredentialHandle || options.probe.readOnly !== true)
|
|
95
|
+
throw new Error("An exact separate read-only probe credential is required.");
|
|
96
|
+
if (options.config.adapterId === options.config.probeId)
|
|
97
|
+
throw new Error("The adapter and outcome probe must be separate.");
|
|
98
|
+
if (options.runtime.reconcileReadOnly !== true)
|
|
99
|
+
throw new Error("Unknown writes require an explicitly read-only reconciliation path.");
|
|
100
|
+
this.lastHeartbeatAt = this.now().toISOString();
|
|
101
|
+
}
|
|
102
|
+
async track(input) {
|
|
103
|
+
assertProposal(input.proposal);
|
|
104
|
+
const existing = await this.options.store.load(input.actionId);
|
|
105
|
+
const digest = sha256(input.proposal);
|
|
106
|
+
if (existing) {
|
|
107
|
+
if (existing.proposalSha256 !== digest)
|
|
108
|
+
throw new Error("Action id was already tracked with a different proposal.");
|
|
109
|
+
return existing;
|
|
110
|
+
}
|
|
111
|
+
const checkpoint = {
|
|
112
|
+
schemaVersion: "witnora.durable_action_checkpoint.v0.1", actionId: input.actionId, phase: "TRACKED",
|
|
113
|
+
proposal: structuredClone(input.proposal), proposalSha256: digest, updatedAt: this.now().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
await this.options.store.save(checkpoint);
|
|
116
|
+
return checkpoint;
|
|
117
|
+
}
|
|
118
|
+
tick(actionId) {
|
|
119
|
+
const previous = this.chains.get(actionId) ?? Promise.resolve(undefined);
|
|
120
|
+
const next = previous.catch(() => undefined).then(() => this.tickOnce(actionId));
|
|
121
|
+
this.chains.set(actionId, next);
|
|
122
|
+
return next.finally(() => { if (this.chains.get(actionId) === next)
|
|
123
|
+
this.chains.delete(actionId); });
|
|
124
|
+
}
|
|
125
|
+
start() {
|
|
126
|
+
if (this.timer)
|
|
127
|
+
return;
|
|
128
|
+
const poll = async () => {
|
|
129
|
+
this.lastHeartbeatAt = this.now().toISOString();
|
|
130
|
+
const checkpoints = await this.options.store.list();
|
|
131
|
+
let cycleError;
|
|
132
|
+
await Promise.all(checkpoints.filter((item) => !terminal(item.phase)).map((item) => this.tick(item.actionId).catch((error) => { cycleError = message(error); })));
|
|
133
|
+
this.lastError = cycleError;
|
|
134
|
+
};
|
|
135
|
+
void poll().catch((error) => { this.lastError = message(error); });
|
|
136
|
+
this.timer = setInterval(() => void poll().catch((error) => { this.lastError = message(error); }), this.options.config.pollIntervalMs ?? 2_000);
|
|
137
|
+
this.timer.unref();
|
|
138
|
+
}
|
|
139
|
+
stop() { if (this.timer)
|
|
140
|
+
clearInterval(this.timer); this.timer = undefined; }
|
|
141
|
+
async status() {
|
|
142
|
+
const checkpoints = await this.options.store.list();
|
|
143
|
+
return {
|
|
144
|
+
schemaVersion: "witnora.durable_action_worker_status.v0.1",
|
|
145
|
+
ready: Boolean(this.timer) && !this.lastError,
|
|
146
|
+
trackedActionCount: checkpoints.length,
|
|
147
|
+
pendingActionCount: checkpoints.filter((item) => !terminal(item.phase)).length,
|
|
148
|
+
unknownResultCount: checkpoints.filter((item) => item.phase === "UNKNOWN_RESULT").length,
|
|
149
|
+
lastHeartbeatAt: this.lastHeartbeatAt,
|
|
150
|
+
...(this.lastError ? { lastError: this.lastError } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async tickOnce(actionId) {
|
|
154
|
+
let checkpoint = await this.options.store.load(actionId);
|
|
155
|
+
if (!checkpoint)
|
|
156
|
+
throw new Error(`Action ${actionId} is not tracked.`);
|
|
157
|
+
if (terminal(checkpoint.phase))
|
|
158
|
+
return checkpoint;
|
|
159
|
+
const action = await this.options.hosted.getAction(actionId);
|
|
160
|
+
if (!exactActionMatches(action, actionId, checkpoint.proposal, this.options.config.adapterId, this.options.config.adapterVersion)) {
|
|
161
|
+
return this.persist(checkpoint, "BLOCKED", { limitation: "Hosted action no longer matches the exact locally configured action, adapter, or build binding." });
|
|
162
|
+
}
|
|
163
|
+
if ((checkpoint.phase === "EXECUTION_STARTED" || checkpoint.phase === "UNKNOWN_RESULT") && checkpoint.grant) {
|
|
164
|
+
if (!exactGrantMatches(checkpoint.grant, actionId, checkpoint.proposal, this.options.config.runtimeIdentityId)) {
|
|
165
|
+
return this.persist(checkpoint, "BLOCKED", { limitation: "Hosted grant does not match the exact approved local action binding." });
|
|
166
|
+
}
|
|
167
|
+
const reconciled = await this.options.runtime.reconcile({ action, proposal: checkpoint.proposal, grant: checkpoint.grant });
|
|
168
|
+
if (!reconciled)
|
|
169
|
+
return this.persist(checkpoint, "UNKNOWN_RESULT", { limitation: "Write result is unknown. The worker will only use read-only reconciliation and will never redispatch this grant." });
|
|
170
|
+
checkpoint = await this.persist(checkpoint, "EXECUTED", { execution: reconciled, limitation: undefined });
|
|
171
|
+
}
|
|
172
|
+
if (!checkpoint.execution) {
|
|
173
|
+
if (action.status === "REJECTED" || action.status === "DENIED")
|
|
174
|
+
return this.persist(checkpoint, "DENIED");
|
|
175
|
+
if (action.status === "APPROVAL_EXPIRED" || action.status === "GRANT_EXPIRED" || (action.status === "APPROVED" && expired(action.approvalExpiresAt, this.now())))
|
|
176
|
+
return this.persist(checkpoint, "EXPIRED");
|
|
177
|
+
if (action.status === "PENDING_APPROVAL")
|
|
178
|
+
return this.persist(checkpoint, "WAITING_APPROVAL");
|
|
179
|
+
if (action.status !== "APPROVED" && action.status !== "ALLOWED") {
|
|
180
|
+
return this.persist(checkpoint, "BLOCKED", { limitation: `Action status ${action.status} is not executable.` });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!checkpoint.grant && !checkpoint.execution) {
|
|
184
|
+
const intent = checkpoint.proposal.executionIntent;
|
|
185
|
+
const grant = await this.options.hosted.issueExecutionGrant(actionId, {
|
|
186
|
+
runtimeIdentityId: this.options.config.runtimeIdentityId,
|
|
187
|
+
adapterId: intent.adapterId,
|
|
188
|
+
adapterVersionConstraint: intent.adapterVersionConstraint,
|
|
189
|
+
allowedOrigins: intent.allowedOrigins,
|
|
190
|
+
allowedOperation: intent.allowedOperation,
|
|
191
|
+
allowedResource: intent.allowedResource,
|
|
192
|
+
approvedParameters: intent.approvedParameters,
|
|
193
|
+
outcomePredicate: intent.outcomePredicate,
|
|
194
|
+
agentBuildId: intent.agentBuildId,
|
|
195
|
+
agentBuildDigest: intent.agentBuildDigest,
|
|
196
|
+
ttlSeconds: this.options.config.grantTtlSeconds ?? 120,
|
|
197
|
+
}, `durable-worker:grant:${actionId}`);
|
|
198
|
+
checkpoint = await this.persist(checkpoint, "GRANT_ISSUED", { grant });
|
|
199
|
+
}
|
|
200
|
+
const grant = checkpoint.grant;
|
|
201
|
+
if (!grant)
|
|
202
|
+
return this.persist(checkpoint, "BLOCKED", { limitation: "A durable execution result exists without its bound Hosted grant." });
|
|
203
|
+
const grantPayload = grant.grant.payload ?? {};
|
|
204
|
+
if (!exactGrantMatches(grant, actionId, checkpoint.proposal, this.options.config.runtimeIdentityId)) {
|
|
205
|
+
return this.persist(checkpoint, "BLOCKED", { limitation: "Hosted grant does not match the exact approved local action binding." });
|
|
206
|
+
}
|
|
207
|
+
if (!checkpoint.execution) {
|
|
208
|
+
if (grant.status !== "ISSUED" || expired(stringValue(grantPayload.expiresAt), this.now()))
|
|
209
|
+
return this.persist(checkpoint, "EXPIRED");
|
|
210
|
+
const claim = await this.options.store.claimExecution(actionId, this.workerId, this.now().toISOString());
|
|
211
|
+
if (!claim.acquired) {
|
|
212
|
+
checkpoint = await this.persist(checkpoint, "UNKNOWN_RESULT", {
|
|
213
|
+
executionClaimId: claim.claimId,
|
|
214
|
+
limitation: "Another Gateway process owns the durable execution claim. This worker will only reconcile and will never dispatch.",
|
|
215
|
+
});
|
|
216
|
+
const reconciled = await this.options.runtime.reconcile({ action, proposal: checkpoint.proposal, grant });
|
|
217
|
+
if (!reconciled)
|
|
218
|
+
return checkpoint;
|
|
219
|
+
checkpoint = await this.persist(checkpoint, "EXECUTED", { execution: reconciled, limitation: undefined });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
checkpoint = await this.persist(checkpoint, "EXECUTION_STARTED", { executionClaimId: claim.claimId });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!checkpoint.execution && checkpoint.phase === "EXECUTION_STARTED") {
|
|
226
|
+
if (!checkpoint.hostedReservation) {
|
|
227
|
+
const reservation = await this.options.runtime.prepareClaim({ action, proposal: checkpoint.proposal, grant });
|
|
228
|
+
if (!exactHostedReservation(reservation, actionId, grant.id))
|
|
229
|
+
return this.persist(checkpoint, "BLOCKED", { limitation: "Runtime claim is not bound to the exact action, grant, and execution session." });
|
|
230
|
+
const claimed = await this.options.hosted.claimExecutionGrant(grant.id, reservation.claim, `durable-worker:claim:${grant.id}`);
|
|
231
|
+
if (!claimed.acquired) {
|
|
232
|
+
checkpoint = await this.persist(checkpoint, "UNKNOWN_RESULT", {
|
|
233
|
+
limitation: "Hosted already has an execution reservation for this grant. This worker will only reconcile and will never dispatch.",
|
|
234
|
+
});
|
|
235
|
+
const reconciled = await this.options.runtime.reconcile({ action, proposal: checkpoint.proposal, grant });
|
|
236
|
+
if (!reconciled)
|
|
237
|
+
return checkpoint;
|
|
238
|
+
checkpoint = await this.persist(checkpoint, "EXECUTED", { execution: reconciled, limitation: undefined });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
checkpoint = await this.persist(checkpoint, "EXECUTION_STARTED", { hostedReservation: reservation });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!checkpoint.execution && checkpoint.phase === "EXECUTION_STARTED" && checkpoint.hostedReservation) {
|
|
246
|
+
try {
|
|
247
|
+
const execution = await this.options.runtime.execute({ action, proposal: checkpoint.proposal, grant, hostedReservation: checkpoint.hostedReservation });
|
|
248
|
+
checkpoint = await this.persist(checkpoint, "EXECUTED", { execution });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
checkpoint = await this.persist(checkpoint, "UNKNOWN_RESULT", { limitation: "Write result is unknown. The worker will only use read-only reconciliation and will never redispatch this grant." });
|
|
252
|
+
const reconciled = await this.options.runtime.reconcile({ action, proposal: checkpoint.proposal, grant });
|
|
253
|
+
if (!reconciled)
|
|
254
|
+
return checkpoint;
|
|
255
|
+
checkpoint = await this.persist(checkpoint, "EXECUTED", { execution: reconciled, limitation: undefined });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!checkpoint.observation) {
|
|
259
|
+
const observation = await this.options.probe.observe({ action, proposal: checkpoint.proposal, grant, execution: checkpoint.execution });
|
|
260
|
+
if (!observation.observationSource || observation.observationSource !== this.options.config.probeId)
|
|
261
|
+
throw new Error("Outcome observation did not come from the exact configured probe.");
|
|
262
|
+
checkpoint = await this.persist(checkpoint, "PROBED", { observation });
|
|
263
|
+
}
|
|
264
|
+
const verified = await this.options.hosted.verifyAction(actionId, {
|
|
265
|
+
...checkpoint.observation,
|
|
266
|
+
executionGrantId: grant.id,
|
|
267
|
+
executionSessionId: checkpoint.execution.executionSessionId,
|
|
268
|
+
}, `durable-worker:verify:${actionId}:${checkpoint.execution.executionSessionId}`);
|
|
269
|
+
if (verified.status !== "VERIFIED" || verified.verificationSuccess !== true)
|
|
270
|
+
throw new Error("Hosted outcome verification did not establish the expected result.");
|
|
271
|
+
const receipts = await this.options.hosted.listActionReceipts(actionId);
|
|
272
|
+
const signed = receipts.find((item) => Array.isArray(item.receipt?.signatureSet) && item.receipt.signatureSet.length > 0);
|
|
273
|
+
if (!signed)
|
|
274
|
+
throw new Error("Hosted did not automatically issue a signed action receipt.");
|
|
275
|
+
return this.persist(checkpoint, "COMPLETED", { receiptId: signed.id });
|
|
276
|
+
}
|
|
277
|
+
async persist(checkpoint, phase, patch = {}) {
|
|
278
|
+
const next = { ...checkpoint, ...patch, phase, updatedAt: this.now().toISOString() };
|
|
279
|
+
if (patch.limitation === undefined && "limitation" in patch)
|
|
280
|
+
delete next.limitation;
|
|
281
|
+
await this.options.store.save(next);
|
|
282
|
+
return next;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function exactActionMatches(action, actionId, proposal, adapterId, adapterVersion) {
|
|
286
|
+
const approved = action.assuranceContext?.executionIntent;
|
|
287
|
+
const proposed = proposal.executionIntent;
|
|
288
|
+
return action.id === actionId && action.externalId === proposal.externalId && action.actionType === proposal.actionType
|
|
289
|
+
&& action.targetSystem === proposal.targetSystem && sha256(action.expectedState ?? {}) === sha256(proposal.expectedState ?? {})
|
|
290
|
+
&& proposed.adapterId === adapterId && runtimeAdapterVersionSatisfies(adapterVersion, proposed.adapterVersionConstraint)
|
|
291
|
+
&& approved?.adapterId === proposed.adapterId && approved.adapterVersionConstraint === proposed.adapterVersionConstraint
|
|
292
|
+
&& canonicalJson(approved.allowedOrigins) === canonicalJson(proposed.allowedOrigins)
|
|
293
|
+
&& approved.allowedOperation === proposed.allowedOperation && approved.allowedResource === proposed.allowedResource
|
|
294
|
+
&& approved.approvedParametersDigest === sha256(proposed.approvedParameters)
|
|
295
|
+
&& approved.outcomePredicateDigest === sha256(proposed.outcomePredicate)
|
|
296
|
+
&& approved.agentBuildId === proposed.agentBuildId && approved.agentBuildDigest === proposed.agentBuildDigest;
|
|
297
|
+
}
|
|
298
|
+
function exactGrantMatches(grant, actionId, proposal, runtimeIdentityId) {
|
|
299
|
+
const payload = grant.grant.payload ?? {};
|
|
300
|
+
const intent = proposal.executionIntent;
|
|
301
|
+
return payload.executionGrantId === grant.id && payload.actionId === actionId && payload.adapterId === intent.adapterId
|
|
302
|
+
&& payload.adapterVersionConstraint === intent.adapterVersionConstraint && payload.expectedRuntimeIdentityId === runtimeIdentityId
|
|
303
|
+
&& canonicalJson(payload.allowedOrigins) === canonicalJson(intent.allowedOrigins)
|
|
304
|
+
&& payload.allowedOperation === intent.allowedOperation && payload.allowedResource === intent.allowedResource
|
|
305
|
+
&& payload.agentBuildId === intent.agentBuildId && payload.agentBuildDigest === intent.agentBuildDigest
|
|
306
|
+
&& payload.parametersDigest === sha256(intent.approvedParameters) && payload.outcomePredicateDigest === sha256(intent.outcomePredicate);
|
|
307
|
+
}
|
|
308
|
+
function exactHostedReservation(reservation, actionId, executionGrantId) {
|
|
309
|
+
if (!reservation || typeof reservation !== "object" || typeof reservation.executionSessionId !== "string"
|
|
310
|
+
|| !reservation.executionSessionId || !reservation.claim || typeof reservation.claim !== "object" || Array.isArray(reservation.claim))
|
|
311
|
+
return false;
|
|
312
|
+
const payload = reservation.claim.payload;
|
|
313
|
+
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload)
|
|
314
|
+
&& payload.actionId === actionId
|
|
315
|
+
&& payload.executionGrantId === executionGrantId
|
|
316
|
+
&& payload.executionSessionId === reservation.executionSessionId);
|
|
317
|
+
}
|
|
318
|
+
function parseCheckpoint(raw) {
|
|
319
|
+
const value = JSON.parse(raw);
|
|
320
|
+
if (value.schemaVersion !== "witnora.durable_action_checkpoint.v0.1" || !value.actionId || !value.phase || !value.proposal || !value.proposalSha256 || !value.updatedAt) {
|
|
321
|
+
throw new Error("Durable action checkpoint is invalid.");
|
|
322
|
+
}
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
function assertProposal(value) {
|
|
326
|
+
const intent = value?.executionIntent;
|
|
327
|
+
if (!value?.externalId || !value.actionType || !value.targetSystem || !intent?.adapterId || !intent.adapterVersionConstraint
|
|
328
|
+
|| !Array.isArray(intent.allowedOrigins) || !intent.allowedOrigins.length || !intent.allowedOperation || !intent.allowedResource
|
|
329
|
+
|| !intent.approvedParameters || typeof intent.approvedParameters !== "object" || !intent.outcomePredicate || typeof intent.outcomePredicate !== "object"
|
|
330
|
+
|| !intent.agentBuildId || !/^[a-f0-9]{64}$/.test(intent.agentBuildDigest)) {
|
|
331
|
+
throw new Error("Tracked action is missing an exact execution intent.");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function sha256(value) { return createHash("sha256").update(canonicalJson(value)).digest("hex"); }
|
|
335
|
+
function expired(value, now) { return !value || !Number.isFinite(Date.parse(value)) || Date.parse(value) <= now.getTime(); }
|
|
336
|
+
function stringValue(value) { return typeof value === "string" ? value : undefined; }
|
|
337
|
+
function validVersion(value) { return /^v?\d+\.\d+\.\d+$/.test(value); }
|
|
338
|
+
export function runtimeAdapterVersionSatisfies(version, constraint) {
|
|
339
|
+
const parsed = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
340
|
+
const exact = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(constraint);
|
|
341
|
+
if (!parsed)
|
|
342
|
+
return false;
|
|
343
|
+
if (exact)
|
|
344
|
+
return parsed.slice(1).join(".") === exact.slice(1).join(".");
|
|
345
|
+
const compatible = /^\^v?(\d+)\.(\d+)\.(\d+)$/.exec(constraint);
|
|
346
|
+
if (!compatible)
|
|
347
|
+
return false;
|
|
348
|
+
const [major, minor, patch] = parsed.slice(1).map(Number);
|
|
349
|
+
const [requiredMajor, requiredMinor, requiredPatch] = compatible.slice(1).map(Number);
|
|
350
|
+
if (requiredMajor > 0)
|
|
351
|
+
return major === requiredMajor && (minor > requiredMinor || (minor === requiredMinor && patch >= requiredPatch));
|
|
352
|
+
if (requiredMinor > 0)
|
|
353
|
+
return major === 0 && minor === requiredMinor && patch >= requiredPatch;
|
|
354
|
+
return major === 0 && minor === 0 && patch === requiredPatch;
|
|
355
|
+
}
|
|
356
|
+
function terminal(phase) { return phase === "DENIED" || phase === "EXPIRED" || phase === "BLOCKED" || phase === "COMPLETED"; }
|
|
357
|
+
function isMissing(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); }
|
|
358
|
+
function isAlreadyExists(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST"); }
|
|
359
|
+
function message(error) { return error instanceof Error ? error.message : String(error); }
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export async function inspectIsolatedOutcomeProbe(config) {
|
|
3
|
+
const result = await invokeProbeChild({ mode: "health", config });
|
|
4
|
+
if (result.ready !== true)
|
|
5
|
+
throw new Error("Independent outcome-probe child did not become ready.");
|
|
6
|
+
}
|
|
7
|
+
export async function observeInIsolatedOutcomeProbe(config, input) {
|
|
8
|
+
const result = await invokeProbeChild({ mode: "observe", config, input });
|
|
9
|
+
if (!result.observation || typeof result.observation !== "object" || Array.isArray(result.observation))
|
|
10
|
+
throw new Error("Independent outcome-probe child returned no bounded observation.");
|
|
11
|
+
return result.observation;
|
|
12
|
+
}
|
|
13
|
+
async function invokeProbeChild(request) {
|
|
14
|
+
const serializedRequest = `${JSON.stringify(request)}\n`;
|
|
15
|
+
if (Buffer.byteLength(serializedRequest) > 262_144)
|
|
16
|
+
throw new Error("Independent outcome-probe input exceeded its bounded input limit.");
|
|
17
|
+
const child = spawn(process.execPath, ["--input-type=module", "--eval", PROBE_CHILD_SOURCE], {
|
|
18
|
+
env: {}, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
const stdout = [];
|
|
21
|
+
const stderr = [];
|
|
22
|
+
let stdoutBytes = 0;
|
|
23
|
+
let stderrBytes = 0;
|
|
24
|
+
child.stdout.on("data", (chunk) => {
|
|
25
|
+
stdoutBytes += chunk.length;
|
|
26
|
+
if (stdoutBytes <= 65_536)
|
|
27
|
+
stdout.push(chunk);
|
|
28
|
+
else
|
|
29
|
+
child.kill();
|
|
30
|
+
});
|
|
31
|
+
child.stderr.on("data", (chunk) => {
|
|
32
|
+
stderrBytes += chunk.length;
|
|
33
|
+
if (stderrBytes <= 8_192)
|
|
34
|
+
stderr.push(chunk);
|
|
35
|
+
});
|
|
36
|
+
const timeout = setTimeout(() => child.kill(), 10_000);
|
|
37
|
+
child.stdin.end(serializedRequest);
|
|
38
|
+
const exitCode = await new Promise((resolveExit, reject) => {
|
|
39
|
+
child.once("error", reject);
|
|
40
|
+
child.once("close", resolveExit);
|
|
41
|
+
}).finally(() => clearTimeout(timeout));
|
|
42
|
+
if (stdoutBytes > 65_536)
|
|
43
|
+
throw new Error("Independent outcome-probe child exceeded its bounded output limit.");
|
|
44
|
+
const output = Buffer.concat(stdout).toString("utf8");
|
|
45
|
+
let value;
|
|
46
|
+
try {
|
|
47
|
+
value = JSON.parse(output);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(`Independent outcome-probe child failed (${exitCode ?? "unknown"}): ${Buffer.concat(stderr).toString("utf8").slice(0, 500) || "invalid response"}`);
|
|
51
|
+
}
|
|
52
|
+
if (exitCode !== 0 || value.ok !== true)
|
|
53
|
+
throw new Error(`Independent outcome-probe child failed: ${String(value.error ?? "unknown error")}`);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
const PROBE_CHILD_SOURCE = String.raw `
|
|
57
|
+
import { createHash } from "node:crypto";
|
|
58
|
+
import { readFile } from "node:fs/promises";
|
|
59
|
+
import { pathToFileURL } from "node:url";
|
|
60
|
+
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
for await (const chunk of process.stdin) {
|
|
64
|
+
bytes += chunk.length;
|
|
65
|
+
if (bytes > 262144) throw new Error("probe_input_too_large");
|
|
66
|
+
chunks.push(chunk);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
71
|
+
const config = request.config ?? {};
|
|
72
|
+
const moduleBytes = await readFile(config.modulePath);
|
|
73
|
+
const digest = createHash("sha256").update(moduleBytes).digest("hex");
|
|
74
|
+
if (digest !== config.moduleSha256) throw new Error("probe_module_digest_mismatch");
|
|
75
|
+
const loaded = await import(pathToFileURL(config.modulePath).href + "?sha256=" + digest);
|
|
76
|
+
if (typeof loaded.createWitnoraOutcomeProbe !== "function") throw new Error("probe_factory_missing");
|
|
77
|
+
const context = Object.freeze({
|
|
78
|
+
probeId: config.probeId,
|
|
79
|
+
projectId: config.projectId,
|
|
80
|
+
targetReadCredential: Object.freeze({ handle: config.credentialHandle, access: "READ_ONLY" }),
|
|
81
|
+
});
|
|
82
|
+
const probe = await loaded.createWitnoraOutcomeProbe(context);
|
|
83
|
+
if (probe?.id !== config.probeId || probe?.credentialHandle !== config.credentialHandle || probe?.readOnly !== true || typeof probe?.observe !== "function") throw new Error("probe_contract_mismatch");
|
|
84
|
+
if (request.mode === "health") {
|
|
85
|
+
respond({ ok: true, ready: true });
|
|
86
|
+
} else if (request.mode === "observe") {
|
|
87
|
+
const raw = await probe.observe(request.input ?? {});
|
|
88
|
+
const observedState = record(raw?.observedState, "observedState");
|
|
89
|
+
rejectSecrets(observedState);
|
|
90
|
+
const allowedMethods = new Set(["TARGET_API", "TARGET_UI", "TARGET_AUDIT_LOG", "WEBHOOK", "DATABASE_QUERY", "THIRD_PARTY_CONFIRMATION"]);
|
|
91
|
+
if (!allowedMethods.has(raw?.observationMethod) || raw?.observationSource !== config.probeId) throw new Error("probe_observation_binding_invalid");
|
|
92
|
+
const references = raw.evidenceReferences === undefined ? undefined : strings(raw.evidenceReferences, 20);
|
|
93
|
+
const confidence = raw.confidence === undefined ? undefined : Number(raw.confidence);
|
|
94
|
+
if (confidence !== undefined && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)) throw new Error("probe_confidence_invalid");
|
|
95
|
+
respond({ ok: true, observation: {
|
|
96
|
+
observedState,
|
|
97
|
+
observationMethod: raw.observationMethod,
|
|
98
|
+
observationSource: raw.observationSource,
|
|
99
|
+
...(references ? { evidenceReferences: references } : {}),
|
|
100
|
+
...(confidence === undefined ? {} : { confidence }),
|
|
101
|
+
} });
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error("probe_mode_invalid");
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
respond({ ok: false, error: error instanceof Error ? error.message.slice(0, 200) : "probe_failed" }, 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function record(value, name) {
|
|
110
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(name + "_invalid");
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
function strings(value, maximum) {
|
|
114
|
+
if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length > 512)) throw new Error("probe_evidence_references_invalid");
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
function rejectSecrets(value, depth = 0) {
|
|
118
|
+
if (depth > 8) throw new Error("probe_observation_too_deep");
|
|
119
|
+
for (const [key, child] of Object.entries(value)) {
|
|
120
|
+
if (/(authorization|cookie|password|secret|token|credential|api.?key|private.?key)/i.test(key)) throw new Error("probe_observation_contains_secret_field");
|
|
121
|
+
if (typeof child === "string" && child.length > 4096) throw new Error("probe_observation_string_too_large");
|
|
122
|
+
if (child && typeof child === "object") rejectSecrets(child, depth + 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function respond(value, code = 0) {
|
|
126
|
+
const output = JSON.stringify(value);
|
|
127
|
+
if (Buffer.byteLength(output) > 65536) {
|
|
128
|
+
process.stdout.write(JSON.stringify({ ok: false, error: "probe_output_too_large" }));
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write(output);
|
|
133
|
+
process.exitCode = code;
|
|
134
|
+
}
|
|
135
|
+
`;
|