witnora 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/dist/cli.js +50 -2
- package/dist/command-help.js +34 -5
- package/dist/control-plane.js +2 -0
- package/dist/gateway.js +237 -8
- package/dist/onboard.js +21 -3
- package/dist/release-evaluation.js +309 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,21 +13,22 @@ outcome. It writes portable reports and accumulates a local failure corpus.
|
|
|
13
13
|
|
|
14
14
|
## 5-minute hosted path
|
|
15
15
|
|
|
16
|
-
Create a Hosted project. The Setup Wizard pre-fills a metadata-only plan
|
|
17
|
-
|
|
18
|
-
terminal in the agent repository and run the one command shown by Witnora:
|
|
16
|
+
Create a Hosted project. The Setup Wizard pre-fills a metadata-only plan. Open
|
|
17
|
+
a terminal in the Agent repository and run the one command shown by Witnora:
|
|
19
18
|
|
|
20
19
|
```bash
|
|
21
20
|
npx witnora@latest onboard --project your-project-id
|
|
22
21
|
```
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
One browser **Approve setup** action authorizes the bounded plan and one
|
|
24
|
+
reversible installation. The CLI saves a restricted credential outside the
|
|
25
|
+
repository, detects capability groups, installs only missing Gateway,
|
|
26
|
+
outcome-probe, CI, policy, review, configuration, and boundary files, and
|
|
27
|
+
starts the customer-owned Gateway in the background. It health-checks each
|
|
28
|
+
component and reports progress to Hosted. A failed attempt stops only the
|
|
29
|
+
Gateway process it started and removes only files created by that attempt. The
|
|
30
|
+
onboarding receipt is synthetic: it cannot create a run, evidence object,
|
|
31
|
+
release decision, or `CURRENT` assurance.
|
|
31
32
|
|
|
32
33
|
Onboarding also performs the default private capability discovery locally. It
|
|
33
34
|
does not read source-file contents or upload source code, prompt text,
|
|
@@ -45,11 +46,15 @@ explicit GitHub authorization and is never enabled implicitly.
|
|
|
45
46
|
|
|
46
47
|
### Customer-owned Gateway
|
|
47
48
|
|
|
48
|
-
The default Setup Wizard installs a
|
|
49
|
-
Agent automatically.
|
|
49
|
+
The default Setup Wizard installs and starts a customer-owned Gateway beside
|
|
50
|
+
the Agent automatically. Run the Agent's normal sandbox workflow. Routine
|
|
51
|
+
Gateway operations are available without keeping another terminal open:
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
|
-
npx witnora@latest gateway
|
|
54
|
+
npx witnora@latest gateway status
|
|
55
|
+
npx witnora@latest gateway logs
|
|
56
|
+
npx witnora@latest gateway restart
|
|
57
|
+
npx witnora@latest gateway stop
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
Initialization writes a reusable `.witnora/gateway/client.mjs`. Import its
|
|
@@ -69,8 +74,8 @@ read-only probe checks the target state.
|
|
|
69
74
|
|
|
70
75
|
Enterprises that require self-hosted or manual component control can use the
|
|
71
76
|
individual `gateway init`, `gateway doctor`, policy, probe, CI, and review
|
|
72
|
-
commands under Hosted **Advanced**.
|
|
73
|
-
journey.
|
|
77
|
+
commands under Hosted **Advanced**. `gateway run` remains available there for
|
|
78
|
+
foreground debugging. They are not part of the default customer journey.
|
|
74
79
|
|
|
75
80
|
After the connection self-test, Hosted shows one template-specific next step.
|
|
76
81
|
Place the generated Gateway client at one meaningful sandbox boundary and run
|
package/dist/cli.js
CHANGED
|
@@ -37,8 +37,9 @@ import { importGenericEval, renderGenericEvalReport } from "./generic-eval.js";
|
|
|
37
37
|
import { runDesignPartnerCommand } from "./design-partner-v02.js";
|
|
38
38
|
import { runOnboard } from "./onboard.js";
|
|
39
39
|
import { inspectRepository } from "./onboard.js";
|
|
40
|
+
import { renderReleaseEvaluation, runReleaseEvaluation } from "./release-evaluation.js";
|
|
40
41
|
import { runPrivateCapabilityDiscovery } from "./private-discovery.js";
|
|
41
|
-
import { doctorCustomerGateway, initializeCustomerGateway, renderGatewayDoctor, runCustomerGateway } from "./gateway.js";
|
|
42
|
+
import { doctorCustomerGateway, initializeCustomerGateway, readManagedCustomerGatewayLogs, renderGatewayDoctor, renderManagedGatewayStatus, restartManagedCustomerGateway, runCustomerGateway, startManagedCustomerGateway, statusManagedCustomerGateway, stopManagedCustomerGateway, } from "./gateway.js";
|
|
42
43
|
import { verifyEvidencePacketV02 } from "./evidence-v02.js";
|
|
43
44
|
process.on("uncaughtException", reportFatalError);
|
|
44
45
|
process.on("unhandledRejection", reportFatalError);
|
|
@@ -158,6 +159,29 @@ else if (command === "onboard") {
|
|
|
158
159
|
openBrowser: readBoolFlag("--no-browser") ? async () => { } : undefined,
|
|
159
160
|
});
|
|
160
161
|
}
|
|
162
|
+
else if (command === "release") {
|
|
163
|
+
const action = process.argv[3] ?? "help";
|
|
164
|
+
if (action !== "evaluate")
|
|
165
|
+
throw new Error("Use witnora release evaluate --case <assurance-case-id>.");
|
|
166
|
+
const assuranceCaseId = readFlag("--case");
|
|
167
|
+
if (!assuranceCaseId)
|
|
168
|
+
throw new Error("--case <assurance-case-id> is required.");
|
|
169
|
+
const connection = await resolveConnection({
|
|
170
|
+
name: readFlag("--connection"),
|
|
171
|
+
server: readFlag("--server"),
|
|
172
|
+
projectId: readFlag("--project"),
|
|
173
|
+
apiKey: readFlag("--api-key"),
|
|
174
|
+
});
|
|
175
|
+
const result = await runReleaseEvaluation({
|
|
176
|
+
connection,
|
|
177
|
+
assuranceCaseId,
|
|
178
|
+
repository: readFlag("--repo") ?? process.cwd(),
|
|
179
|
+
outDir: readFlag("--out"),
|
|
180
|
+
});
|
|
181
|
+
process.stdout.write(renderReleaseEvaluation(result));
|
|
182
|
+
if (!result.passed)
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
}
|
|
161
185
|
else if (command === "gateway") {
|
|
162
186
|
const action = process.argv[3] ?? "help";
|
|
163
187
|
if (action === "init") {
|
|
@@ -180,11 +204,35 @@ else if (command === "gateway") {
|
|
|
180
204
|
if (result.overall !== "READY_TO_RECORD")
|
|
181
205
|
process.exitCode = 1;
|
|
182
206
|
}
|
|
207
|
+
else if (action === "start") {
|
|
208
|
+
const result = await startManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
209
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
210
|
+
}
|
|
211
|
+
else if (action === "status") {
|
|
212
|
+
const result = await statusManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
213
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
214
|
+
if (!result.healthy)
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
else if (action === "logs") {
|
|
218
|
+
const lines = Number(readFlag("--lines") ?? 100);
|
|
219
|
+
if (!Number.isSafeInteger(lines) || lines < 1 || lines > 10_000)
|
|
220
|
+
throw new Error("--lines must be an integer between 1 and 10000.");
|
|
221
|
+
process.stdout.write(`${await readManagedCustomerGatewayLogs({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir"), lines })}\n`);
|
|
222
|
+
}
|
|
223
|
+
else if (action === "restart") {
|
|
224
|
+
const result = await restartManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
225
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
226
|
+
}
|
|
227
|
+
else if (action === "stop") {
|
|
228
|
+
const result = await stopManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
229
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
230
|
+
}
|
|
183
231
|
else if (action === "run") {
|
|
184
232
|
await runCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
185
233
|
}
|
|
186
234
|
else {
|
|
187
|
-
throw new Error("Use witnora gateway init|doctor|run.");
|
|
235
|
+
throw new Error("Use witnora gateway init|doctor|start|status|logs|restart|stop|run.");
|
|
188
236
|
}
|
|
189
237
|
}
|
|
190
238
|
else if (command === "discover") {
|
package/dist/command-help.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
export function renderCommandHelp(command) {
|
|
2
2
|
if (command === "sandbox" || command === "browser-adapter")
|
|
3
3
|
return undefined;
|
|
4
|
+
if (command === "release")
|
|
5
|
+
return `Usage:
|
|
6
|
+
witnora release evaluate --case <assurance-case-id>
|
|
7
|
+
|
|
8
|
+
Runs one allowlisted release-evaluation script already defined by this repository,
|
|
9
|
+
writes a Witnora evidence bundle, and attaches it to the selected self-service
|
|
10
|
+
release assurance case. Hosted configuration cannot supply executable commands.
|
|
11
|
+
|
|
12
|
+
Script selection order:
|
|
13
|
+
witnora:release, evals:run, test:ci, test
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--case <id> Self-service release assurance case (required)
|
|
17
|
+
--connection <name> Saved Hosted connection
|
|
18
|
+
--repo <directory> Agent repository (default: current directory)
|
|
19
|
+
--out <directory> Evidence output directory
|
|
20
|
+
--server <url> Hosted Witnora base URL
|
|
21
|
+
--project <id> Hosted project ID
|
|
22
|
+
--api-key <key> Project API key (prefer the saved connection or secret manager)
|
|
23
|
+
--help, -h Show this help
|
|
24
|
+
`;
|
|
4
25
|
if (command === "discover")
|
|
5
26
|
return `Usage:
|
|
6
27
|
witnora discover [--connection <name>] [--repo <directory>]
|
|
@@ -22,8 +43,9 @@ Options:
|
|
|
22
43
|
witnora onboard --project <project-id> --template <browser|coding|mcp|workflow|data>
|
|
23
44
|
|
|
24
45
|
Opens one browser authorization, saves a restricted project credential, detects the repository,
|
|
25
|
-
writes missing starter files,
|
|
26
|
-
does not create assurance evidence or establish
|
|
46
|
+
writes missing starter files, starts a customer-owned Gateway in the background, and records an
|
|
47
|
+
isolated synthetic self-test receipt. The self-test does not create assurance evidence or establish
|
|
48
|
+
CURRENT status.
|
|
27
49
|
|
|
28
50
|
Options:
|
|
29
51
|
--server <url> Hosted server (default: https://witnora.com)
|
|
@@ -37,12 +59,18 @@ Options:
|
|
|
37
59
|
return `Usage:
|
|
38
60
|
witnora gateway init --project <project-id>
|
|
39
61
|
witnora gateway doctor
|
|
62
|
+
witnora gateway start
|
|
63
|
+
witnora gateway status
|
|
64
|
+
witnora gateway logs [--lines 100]
|
|
65
|
+
witnora gateway restart
|
|
66
|
+
witnora gateway stop
|
|
40
67
|
witnora gateway run
|
|
41
68
|
|
|
42
|
-
Initializes and
|
|
69
|
+
Initializes and manages a customer-owned, metadata-only collector beside the Agent.
|
|
43
70
|
The browser approval issues a collector-scoped credential; no API key is copied into
|
|
44
71
|
the repository or exposed to the Agent. The local queue and source signing key remain
|
|
45
|
-
under customer control.
|
|
72
|
+
under customer control. Onboard uses background start by default. The run command is
|
|
73
|
+
the foreground debugging path.
|
|
46
74
|
|
|
47
75
|
The reference Gateway establishes RECORDED evidence only. ENFORCED requires target
|
|
48
76
|
write credentials behind a controlled execution adapter. OUTCOME VERIFIED requires a
|
|
@@ -56,7 +84,8 @@ Options:
|
|
|
56
84
|
--name <name> Saved collector connection name
|
|
57
85
|
--no-browser Print the approval URL without opening it
|
|
58
86
|
--force Replace an existing reviewed local setup
|
|
59
|
-
--json JSON
|
|
87
|
+
--json JSON status output
|
|
88
|
+
--lines <count> Number of recent log lines (default: 100)
|
|
60
89
|
`;
|
|
61
90
|
if (command === "design-partner")
|
|
62
91
|
return `Usage:
|
package/dist/control-plane.js
CHANGED
|
@@ -100,6 +100,8 @@ export async function pushEvidenceToControlPlane(options) {
|
|
|
100
100
|
schemaVersion: bundle.schemaVersion,
|
|
101
101
|
runId: run.id,
|
|
102
102
|
});
|
|
103
|
+
if (options.releaseAssuranceCaseId)
|
|
104
|
+
query.set("assuranceCaseId", options.releaseAssuranceCaseId);
|
|
103
105
|
const evidence = await requestJson(request, `${projectUrl}/evidence?${query}`, {
|
|
104
106
|
method: "POST",
|
|
105
107
|
headers: { ...headers, "content-type": "application/json" },
|
package/dist/gateway.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { closeSync, openSync } from "node:fs";
|
|
2
4
|
import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
4
7
|
import { loadConnection } from "./credentials.js";
|
|
5
8
|
import { authorizeProjectConnection } from "./device-authorization.js";
|
|
6
9
|
const CONFIG_SCHEMA = "witnora.customer_gateway_setup.v0.1";
|
|
7
10
|
const SECRETS_SCHEMA = "witnora.customer_gateway_local_secrets.v0.1";
|
|
11
|
+
const RUNTIME_SCHEMA = "witnora.managed_gateway_runtime.v0.1";
|
|
8
12
|
export async function initializeCustomerGateway(options) {
|
|
9
13
|
const repository = resolve(options.repository ?? process.cwd());
|
|
10
14
|
const outDir = resolve(repository, options.outDir ?? ".witnora/gateway");
|
|
@@ -59,7 +63,7 @@ export async function initializeCustomerGateway(options) {
|
|
|
59
63
|
for (const [path, content, mode] of [
|
|
60
64
|
[configPath, `${JSON.stringify(config, null, 2)}\n`, 0o644],
|
|
61
65
|
[secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, 0o600],
|
|
62
|
-
[gitignorePath, "secrets.json\ndata/\n", 0o644],
|
|
66
|
+
[gitignorePath, "secrets.json\ndata/\nruntime/\n", 0o644],
|
|
63
67
|
[clientPath, gatewayClient(config), 0o644],
|
|
64
68
|
[readmePath, gatewayReadme(config), 0o644],
|
|
65
69
|
]) {
|
|
@@ -78,10 +82,9 @@ export async function initializeCustomerGateway(options) {
|
|
|
78
82
|
output("\nCustomer-owned Gateway initialized.\n");
|
|
79
83
|
output(`Configuration: ${configPath}\nLocal secret: ${secretsPath} (never commit or upload)\n`);
|
|
80
84
|
output("Next:\n");
|
|
81
|
-
output(" 1. Run: npx witnora@latest gateway
|
|
82
|
-
output(" 2.
|
|
83
|
-
output(" 3.
|
|
84
|
-
output(" 4. Run that workflow normally; start, event, and complete are recorded locally.\n");
|
|
85
|
+
output(" 1. Run: npx witnora@latest gateway start\n");
|
|
86
|
+
output(" 2. Import .witnora/gateway/client.mjs at one sandbox workflow boundary.\n");
|
|
87
|
+
output(" 3. Run that workflow normally; start, event, and complete are recorded locally.\n");
|
|
85
88
|
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");
|
|
86
89
|
return { configPath, secretsPath, config, generatedFiles };
|
|
87
90
|
}
|
|
@@ -136,7 +139,7 @@ export async function doctorCustomerGateway(options) {
|
|
|
136
139
|
checks.push({ id: "process", status: "PASS", message: `Gateway is listening at http://${config.host}:${config.port}.` });
|
|
137
140
|
}
|
|
138
141
|
catch {
|
|
139
|
-
checks.push({ id: "process", status: "WARN", message: "Gateway is not running yet. Start
|
|
142
|
+
checks.push({ id: "process", status: "WARN", message: "Gateway is not running yet. Start managed mode with `witnora gateway start`." });
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
145
|
if (config && secrets) {
|
|
@@ -149,7 +152,7 @@ export async function doctorCustomerGateway(options) {
|
|
|
149
152
|
overall: failed ? "SETUP_INCOMPLETE" : "READY_TO_RECORD",
|
|
150
153
|
checks,
|
|
151
154
|
evidenceCeiling: "recorded",
|
|
152
|
-
nextAction: failed ? "Run `witnora gateway init --project <project-id>` again after resolving failed checks." : "
|
|
155
|
+
nextAction: failed ? "Run `witnora gateway init --project <project-id>` again after resolving failed checks." : "Ensure the managed Gateway is healthy, then send one sandbox run through its local event API.",
|
|
153
156
|
};
|
|
154
157
|
}
|
|
155
158
|
export async function runCustomerGateway(options) {
|
|
@@ -181,6 +184,187 @@ export async function runCustomerGateway(options) {
|
|
|
181
184
|
process.once(signal, () => void gateway.close().finally(() => process.exit(0)));
|
|
182
185
|
}
|
|
183
186
|
}
|
|
187
|
+
export async function startManagedCustomerGateway(options = {}) {
|
|
188
|
+
const repository = resolve(options.repository ?? process.cwd());
|
|
189
|
+
const directory = resolve(repository, options.dir ?? ".witnora/gateway");
|
|
190
|
+
const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
|
|
191
|
+
const current = await statusManagedCustomerGateway(options);
|
|
192
|
+
if (current.state === "RUNNING_MANAGED" || current.state === "RUNNING_EXTERNAL") {
|
|
193
|
+
return { ...current, started: false };
|
|
194
|
+
}
|
|
195
|
+
if (current.state === "CONFLICT")
|
|
196
|
+
throw new Error(current.detail);
|
|
197
|
+
if (current.state === "STALE")
|
|
198
|
+
await rm(runtimePath(directory), { force: true });
|
|
199
|
+
const cliEntry = resolve(options.cliEntry ?? fileURLToPath(new URL("./cli.js", import.meta.url)));
|
|
200
|
+
if (!await exists(cliEntry))
|
|
201
|
+
throw new Error(`Managed Gateway CLI entry was not found at ${cliEntry}. Reinstall Witnora and run onboard again.`);
|
|
202
|
+
const runtimeDirectory = join(directory, "runtime");
|
|
203
|
+
const logPath = join(runtimeDirectory, "gateway.log");
|
|
204
|
+
await mkdir(runtimeDirectory, { recursive: true });
|
|
205
|
+
const log = openSync(logPath, "a");
|
|
206
|
+
let launchError;
|
|
207
|
+
let child;
|
|
208
|
+
try {
|
|
209
|
+
const args = [cliEntry, "gateway", "run", "--repo", repository];
|
|
210
|
+
if (options.dir)
|
|
211
|
+
args.push("--dir", options.dir);
|
|
212
|
+
child = spawn(process.execPath, args, {
|
|
213
|
+
cwd: repository,
|
|
214
|
+
detached: true,
|
|
215
|
+
windowsHide: true,
|
|
216
|
+
stdio: ["ignore", log, log],
|
|
217
|
+
env: {
|
|
218
|
+
...process.env,
|
|
219
|
+
...(options.configHome ? { WITNORA_CONFIG_HOME: options.configHome } : {}),
|
|
220
|
+
WITNORA_GATEWAY_MANAGED: "1",
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
child.once("error", (error) => { launchError = error; });
|
|
224
|
+
child.unref();
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(log);
|
|
228
|
+
}
|
|
229
|
+
if (!child.pid)
|
|
230
|
+
throw new Error("Witnora could not obtain a process ID for the managed Gateway.");
|
|
231
|
+
const runtime = {
|
|
232
|
+
schemaVersion: RUNTIME_SCHEMA,
|
|
233
|
+
pid: child.pid,
|
|
234
|
+
projectId: config.projectId,
|
|
235
|
+
server: config.server,
|
|
236
|
+
collectorId: config.collectorId,
|
|
237
|
+
host: config.host,
|
|
238
|
+
port: config.port,
|
|
239
|
+
startedAt: new Date().toISOString(),
|
|
240
|
+
cliEntry,
|
|
241
|
+
logPath,
|
|
242
|
+
};
|
|
243
|
+
await writeFile(runtimePath(directory), `${JSON.stringify(runtime, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
244
|
+
await chmod(runtimePath(directory), 0o600).catch(() => undefined);
|
|
245
|
+
const sleep = options.sleep ?? wait;
|
|
246
|
+
const deadline = Date.now() + (options.timeoutMs ?? 12_000);
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
if (launchError)
|
|
249
|
+
break;
|
|
250
|
+
const status = await statusManagedCustomerGateway(options);
|
|
251
|
+
if (status.state === "RUNNING_MANAGED") {
|
|
252
|
+
options.output?.(`Managed customer-owned Gateway started in the background at ${status.baseUrl}.\nLog: ${logPath}\n`);
|
|
253
|
+
return { ...status, started: true };
|
|
254
|
+
}
|
|
255
|
+
if (status.state === "CONFLICT") {
|
|
256
|
+
await stopManagedCustomerGateway({ ...options, timeoutMs: 2_000 }).catch(() => undefined);
|
|
257
|
+
throw new Error(status.detail);
|
|
258
|
+
}
|
|
259
|
+
await sleep(120);
|
|
260
|
+
}
|
|
261
|
+
await stopManagedCustomerGateway({ ...options, timeoutMs: 2_000 }).catch(() => undefined);
|
|
262
|
+
const tail = await readManagedCustomerGatewayLogs({ repository, dir: options.dir, lines: 20 }).catch(() => "");
|
|
263
|
+
throw new Error(`Managed Gateway did not become healthy${launchError ? `: ${launchError.message}` : " before the startup deadline"}.${tail ? `\nRecent Gateway log:\n${tail}` : ""}`);
|
|
264
|
+
}
|
|
265
|
+
export async function statusManagedCustomerGateway(options = {}) {
|
|
266
|
+
const repository = resolve(options.repository ?? process.cwd());
|
|
267
|
+
const directory = resolve(repository, options.dir ?? ".witnora/gateway");
|
|
268
|
+
const config = parseConfig(await readFile(join(directory, "gateway.json"), "utf8"));
|
|
269
|
+
const baseUrl = `http://${config.host}:${config.port}`;
|
|
270
|
+
const runtime = await readRuntime(directory);
|
|
271
|
+
const health = await gatewayHealth(baseUrl, options.fetch ?? fetch);
|
|
272
|
+
const base = {
|
|
273
|
+
schemaVersion: "witnora.managed_gateway_status.v0.1",
|
|
274
|
+
baseUrl,
|
|
275
|
+
projectId: config.projectId,
|
|
276
|
+
collectorId: config.collectorId,
|
|
277
|
+
pid: runtime?.pid,
|
|
278
|
+
logPath: runtime?.logPath,
|
|
279
|
+
};
|
|
280
|
+
if (health && health.collectorId !== config.collectorId) {
|
|
281
|
+
return { ...base, state: "CONFLICT", healthy: false, managed: false, detail: `Port ${config.port} is occupied by collector ${health.collectorId}, not ${config.collectorId}.` };
|
|
282
|
+
}
|
|
283
|
+
if (runtime && !runtimeMatches(runtime, config)) {
|
|
284
|
+
return { ...base, state: "CONFLICT", healthy: false, managed: false, detail: "Managed Gateway runtime metadata belongs to a different project or collector." };
|
|
285
|
+
}
|
|
286
|
+
if (health) {
|
|
287
|
+
const managed = Boolean(runtime && pidRunning(runtime.pid));
|
|
288
|
+
return {
|
|
289
|
+
...base,
|
|
290
|
+
state: managed ? "RUNNING_MANAGED" : "RUNNING_EXTERNAL",
|
|
291
|
+
healthy: true,
|
|
292
|
+
managed,
|
|
293
|
+
detail: managed ? "The customer-owned Gateway is healthy and managed by Witnora." : "A compatible Gateway is healthy but was started outside Witnora process management.",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (runtime) {
|
|
297
|
+
const running = pidRunning(runtime.pid);
|
|
298
|
+
return {
|
|
299
|
+
...base,
|
|
300
|
+
state: running ? "STARTING" : "STALE",
|
|
301
|
+
healthy: false,
|
|
302
|
+
managed: running,
|
|
303
|
+
detail: running ? "The managed Gateway process is starting but has not passed its health check." : "Managed Gateway runtime metadata is stale; the recorded process is no longer running.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { ...base, state: "STOPPED", healthy: false, managed: false, detail: "The customer-owned Gateway is not running." };
|
|
307
|
+
}
|
|
308
|
+
export async function stopManagedCustomerGateway(options = {}) {
|
|
309
|
+
const repository = resolve(options.repository ?? process.cwd());
|
|
310
|
+
const directory = resolve(repository, options.dir ?? ".witnora/gateway");
|
|
311
|
+
const runtime = await readRuntime(directory);
|
|
312
|
+
const before = await statusManagedCustomerGateway(options);
|
|
313
|
+
if (!runtime)
|
|
314
|
+
return { ...before, stopped: false };
|
|
315
|
+
if (before.state === "CONFLICT")
|
|
316
|
+
throw new Error(before.detail);
|
|
317
|
+
if (pidRunning(runtime.pid)) {
|
|
318
|
+
try {
|
|
319
|
+
process.kill(runtime.pid, "SIGTERM");
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (!isMissingProcess(error))
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
const sleep = options.sleep ?? wait;
|
|
326
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5_000);
|
|
327
|
+
while (Date.now() < deadline && pidRunning(runtime.pid))
|
|
328
|
+
await sleep(100);
|
|
329
|
+
if (pidRunning(runtime.pid)) {
|
|
330
|
+
try {
|
|
331
|
+
process.kill(runtime.pid, "SIGKILL");
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (!isMissingProcess(error))
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await rm(runtimePath(directory), { force: true });
|
|
340
|
+
const after = await statusManagedCustomerGateway(options);
|
|
341
|
+
return { ...after, stopped: !after.healthy };
|
|
342
|
+
}
|
|
343
|
+
export async function restartManagedCustomerGateway(options = {}) {
|
|
344
|
+
const status = await statusManagedCustomerGateway(options);
|
|
345
|
+
if (status.state === "RUNNING_EXTERNAL")
|
|
346
|
+
throw new Error("The Gateway is running outside Witnora process management. Stop that foreground process before requesting a managed restart.");
|
|
347
|
+
await stopManagedCustomerGateway(options);
|
|
348
|
+
return startManagedCustomerGateway(options);
|
|
349
|
+
}
|
|
350
|
+
export async function readManagedCustomerGatewayLogs(options = {}) {
|
|
351
|
+
const repository = resolve(options.repository ?? process.cwd());
|
|
352
|
+
const directory = resolve(repository, options.dir ?? ".witnora/gateway");
|
|
353
|
+
const path = join(directory, "runtime", "gateway.log");
|
|
354
|
+
const content = await readFile(path, "utf8");
|
|
355
|
+
return content.split(/\r?\n/).slice(-Math.max(1, options.lines ?? 100)).join("\n").trim();
|
|
356
|
+
}
|
|
357
|
+
export function renderManagedGatewayStatus(result) {
|
|
358
|
+
return [
|
|
359
|
+
`Customer-owned Gateway: ${result.state}`,
|
|
360
|
+
`Health: ${result.healthy ? "HEALTHY" : "UNAVAILABLE"}`,
|
|
361
|
+
`Endpoint: ${result.baseUrl}`,
|
|
362
|
+
...(result.pid ? [`Process: ${result.pid}${result.managed ? " (managed)" : ""}`] : []),
|
|
363
|
+
...(result.logPath ? [`Log: ${result.logPath}`] : []),
|
|
364
|
+
result.detail,
|
|
365
|
+
"",
|
|
366
|
+
].join("\n");
|
|
367
|
+
}
|
|
184
368
|
export function renderGatewayDoctor(result) {
|
|
185
369
|
return [
|
|
186
370
|
`Customer-owned Gateway: ${result.overall}`,
|
|
@@ -218,7 +402,7 @@ function gatewayPort(value, fallback) {
|
|
|
218
402
|
return parsed;
|
|
219
403
|
}
|
|
220
404
|
function gatewayReadme(config) {
|
|
221
|
-
return `# Witnora customer-owned Gateway\n\nThis directory configures a metadata-only Gateway for project \`${config.projectId}
|
|
405
|
+
return `# Witnora customer-owned Gateway\n\nThis directory configures a metadata-only Gateway for project \`${config.projectId}\`. The Setup Autopilot starts it in the background after browser authorization.\n\n## Operations\n\n\`\`\`bash\nnpx witnora@latest gateway status\nnpx witnora@latest gateway logs\nnpx witnora@latest gateway restart\nnpx witnora@latest gateway stop\n\`\`\`\n\n\`gateway run\` remains available as a foreground debugging command. In the Agent repository, import the generated client at one meaningful sandbox workflow boundary:\n\n\`\`\`js\nimport { randomUUID } from "node:crypto";\nimport { witnoraGateway } from "./.witnora/gateway/client.mjs";\n\nconst runId = randomUUID();\nawait witnoraGateway.start(runId, { workflow: "sandbox-workflow" });\ntry {\n // Run the existing customer workflow here. Do not add raw inputs or outputs.\n await witnoraGateway.event(runId, "workflow.step.completed", { step: "meaningful-boundary" });\n await witnoraGateway.complete(runId, { status: "completed" });\n} catch (error) {\n await witnoraGateway.event(runId, "workflow.failed", { errorType: error?.name ?? "Error" });\n await witnoraGateway.complete(runId, { status: "failed" });\n throw error;\n}\n\`\`\`\n\nThe generated client reads only the local ignored Gateway token and sends metadata to \`http://${config.host}:${config.port}\`. The Hosted API key and source-signing key stay in the Gateway process. Do not commit \`secrets.json\`, \`data/\`, or \`runtime/\`.\n\nThis reference process creates source-signed, durable **RECORDED** evidence. It does not claim complete mediation. **ENFORCED** requires the target write credential to be removed from the Agent and placed behind a controlled execution adapter. **OUTCOME VERIFIED** requires a separate read-only credential and independent probe.\n`;
|
|
222
406
|
}
|
|
223
407
|
function gatewayClient(config) {
|
|
224
408
|
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`;
|
|
@@ -248,6 +432,51 @@ function gatewayPaths(directory) {
|
|
|
248
432
|
join(directory, "README.md"),
|
|
249
433
|
];
|
|
250
434
|
}
|
|
435
|
+
async function gatewayHealth(baseUrl, requestFetch) {
|
|
436
|
+
try {
|
|
437
|
+
const response = await requestFetch(`${baseUrl}/healthz`, { signal: AbortSignal.timeout(800) });
|
|
438
|
+
if (!response.ok)
|
|
439
|
+
return undefined;
|
|
440
|
+
const value = await response.json();
|
|
441
|
+
return typeof value.collectorId === "string" && value.collectorId ? { collectorId: value.collectorId } : undefined;
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async function readRuntime(directory) {
|
|
448
|
+
try {
|
|
449
|
+
const value = JSON.parse(await readFile(runtimePath(directory), "utf8"));
|
|
450
|
+
if (value.schemaVersion !== RUNTIME_SCHEMA || !Number.isSafeInteger(value.pid) || Number(value.pid) < 1 || !value.projectId || !value.collectorId || !value.host || !Number.isSafeInteger(value.port) || !value.cliEntry || !value.logPath)
|
|
451
|
+
return undefined;
|
|
452
|
+
return value;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function runtimeMatches(runtime, config) {
|
|
459
|
+
return runtime.projectId === config.projectId && runtime.server === config.server && runtime.collectorId === config.collectorId
|
|
460
|
+
&& runtime.host === config.host && runtime.port === config.port;
|
|
461
|
+
}
|
|
462
|
+
function runtimePath(directory) {
|
|
463
|
+
return join(directory, "runtime", "gateway-runtime.json");
|
|
464
|
+
}
|
|
465
|
+
function pidRunning(pid) {
|
|
466
|
+
try {
|
|
467
|
+
process.kill(pid, 0);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
return !isMissingProcess(error);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function isMissingProcess(error) {
|
|
475
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ESRCH");
|
|
476
|
+
}
|
|
477
|
+
function wait(milliseconds) {
|
|
478
|
+
return new Promise((resolveWait) => setTimeout(resolveWait, milliseconds));
|
|
479
|
+
}
|
|
251
480
|
function safeSlug(value) {
|
|
252
481
|
return (value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 48);
|
|
253
482
|
}
|
package/dist/onboard.js
CHANGED
|
@@ -7,7 +7,7 @@ import { verifyControlPlaneConnection } from "./control-plane.js";
|
|
|
7
7
|
import { inferPrivateCapabilities, runPrivateCapabilityDiscovery } from "./private-discovery.js";
|
|
8
8
|
import { parseAgentTemplate, starterAdapter, starterProfile, starterTripwireConfig } from "./onboarding-templates.js";
|
|
9
9
|
import { writeTryEvidence } from "./try.js";
|
|
10
|
-
import { doctorCustomerGateway, initializeCustomerGateway, inspectCustomerGatewayFiles } from "./gateway.js";
|
|
10
|
+
import { doctorCustomerGateway, initializeCustomerGateway, inspectCustomerGatewayFiles, startManagedCustomerGateway, stopManagedCustomerGateway, } from "./gateway.js";
|
|
11
11
|
export async function runOnboard(options) {
|
|
12
12
|
const requestFetch = options.fetch ?? fetch;
|
|
13
13
|
const output = options.output ?? ((message) => process.stdout.write(message));
|
|
@@ -38,7 +38,12 @@ export async function runOnboard(options) {
|
|
|
38
38
|
}
|
|
39
39
|
const attemptId = `install-${Date.now()}-${randomSuffix()}`;
|
|
40
40
|
const generatedFiles = [];
|
|
41
|
+
const gatewayLifecycle = options.gatewayLifecycle ?? {
|
|
42
|
+
start: startManagedCustomerGateway,
|
|
43
|
+
stop: stopManagedCustomerGateway,
|
|
44
|
+
};
|
|
41
45
|
let gatewayMigration;
|
|
46
|
+
let managedGateway;
|
|
42
47
|
await reportInstall(requestFetch, server, token.projectId, token.apiKey, setupPlan.id, { status: "installing", attemptId });
|
|
43
48
|
try {
|
|
44
49
|
generatedFiles.push(...await generateRepositoryConfig(repositoryPath, repository.template, repository.name));
|
|
@@ -103,6 +108,14 @@ export async function runOnboard(options) {
|
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
generatedFiles.push(...await generateAutopilotFiles(repositoryPath, repository.name));
|
|
111
|
+
managedGateway = await gatewayLifecycle.start({
|
|
112
|
+
repository: repositoryPath,
|
|
113
|
+
configHome: options.configHome,
|
|
114
|
+
fetch: requestFetch,
|
|
115
|
+
output,
|
|
116
|
+
});
|
|
117
|
+
if (!managedGateway.healthy)
|
|
118
|
+
throw new Error(managedGateway.detail);
|
|
106
119
|
const doctor = await doctorCustomerGateway({ repository: repositoryPath, configHome: options.configHome, fetch: requestFetch });
|
|
107
120
|
if (doctor.overall !== "READY_TO_RECORD")
|
|
108
121
|
throw new Error(doctor.checks.filter((check) => check.status === "FAIL").map((check) => check.message).join(" "));
|
|
@@ -114,13 +127,18 @@ export async function runOnboard(options) {
|
|
|
114
127
|
output(`Credentials: ${credentialsPath}\nSelf-test receipt: ${receiptPath}\n`);
|
|
115
128
|
output(`Private discovery: ${discovery.capabilityCount} capability group(s); ${discovery.unknownCapabilityCount} pending confirmation.\n`);
|
|
116
129
|
output("Installed: customer-owned Gateway, default-deny policy, independent-probe contract, review contract, and PR/release/nightly CI.\n");
|
|
117
|
-
output(
|
|
130
|
+
output(`Gateway: ${managedGateway.state} at ${managedGateway.baseUrl}${managedGateway.pid ? ` (process ${managedGateway.pid})` : ""}.\n`);
|
|
131
|
+
output("The self-test remains isolated. The Gateway is ready in the background; run the agent normally. The first source-signed, server-reconciled Gateway run completes onboarding.\n");
|
|
118
132
|
return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
|
|
119
133
|
repositoryKind: repository.kind, generatedFiles, receiptPath, discovery, setupPlanId: setupPlan.id,
|
|
120
|
-
gatewayDirectory: join(repositoryPath, ".witnora", "gateway"), gatewayArchiveDirectory: gatewayMigration?.archiveDirectory
|
|
134
|
+
gatewayDirectory: join(repositoryPath, ".witnora", "gateway"), gatewayArchiveDirectory: gatewayMigration?.archiveDirectory,
|
|
135
|
+
gateway: managedGateway };
|
|
121
136
|
}
|
|
122
137
|
catch (error) {
|
|
123
138
|
const diagnosis = error instanceof Error ? error.message : String(error);
|
|
139
|
+
if (managedGateway?.started) {
|
|
140
|
+
await gatewayLifecycle.stop({ repository: repositoryPath, configHome: options.configHome, fetch: requestFetch }).catch(() => undefined);
|
|
141
|
+
}
|
|
124
142
|
await rollbackGeneratedFiles(generatedFiles);
|
|
125
143
|
if (gatewayMigration)
|
|
126
144
|
await restoreGateway(gatewayMigration);
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, relative, resolve } from "node:path";
|
|
5
|
+
import { withArtifactManifest } from "./artifact-manifest.js";
|
|
6
|
+
import { buildEvidenceBundle } from "./bundle.js";
|
|
7
|
+
import { collectCompanionArtifacts } from "./companion-artifacts.js";
|
|
8
|
+
import { pushEvidenceToControlPlane } from "./control-plane.js";
|
|
9
|
+
const RELEASE_SCRIPT_CANDIDATES = ["witnora:release", "evals:run", "test:ci", "test"];
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
11
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
12
|
+
export async function runReleaseEvaluation(options) {
|
|
13
|
+
const repository = resolve(options.repository ?? process.cwd());
|
|
14
|
+
const assuranceCase = await loadReleaseAssuranceCase(options.connection, options.assuranceCaseId, options.fetch ?? fetch);
|
|
15
|
+
assertEvaluatingSelfServiceCase(assuranceCase);
|
|
16
|
+
const script = await detectReleaseScript(repository);
|
|
17
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
18
|
+
const executedAt = (options.now ?? (() => new Date()))();
|
|
19
|
+
const execution = await (options.runner ?? runNpmScript)({ repository, script, timeoutMs });
|
|
20
|
+
const outputDirectory = resolve(options.outDir ?? join(repository, ".witnora", "release", assuranceCase.id));
|
|
21
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
22
|
+
const logPath = join(outputDirectory, "evaluation.log");
|
|
23
|
+
const evaluationArtifactPath = join(outputDirectory, "evaluation-output.json");
|
|
24
|
+
const evidencePath = join(outputDirectory, "agentcert-evidence.json");
|
|
25
|
+
await writeFile(logPath, renderEvaluationLog(script, execution), "utf8");
|
|
26
|
+
await writeFile(evaluationArtifactPath, `${JSON.stringify(renderEvaluationArtifact(script, execution), null, 2)}\n`, "utf8");
|
|
27
|
+
const result = releaseEvaluationResult({
|
|
28
|
+
assuranceCase,
|
|
29
|
+
script,
|
|
30
|
+
execution,
|
|
31
|
+
timestamp: executedAt.toISOString(),
|
|
32
|
+
runId: (options.randomId ?? randomUUID)(),
|
|
33
|
+
artifactPath: relative(repository, evaluationArtifactPath).replaceAll("\\", "/"),
|
|
34
|
+
});
|
|
35
|
+
let bundle = buildEvidenceBundle([result], assuranceCase.subject.name, assuranceCase.subject.kind);
|
|
36
|
+
bundle.runId = `witnora_release_${result.runId}`;
|
|
37
|
+
bundle.generatedAt = executedAt.toISOString();
|
|
38
|
+
const companions = await collectCompanionArtifacts(bundle, repository);
|
|
39
|
+
bundle = withArtifactManifest(bundle, companions.artifacts);
|
|
40
|
+
const evidenceBytes = new TextEncoder().encode(`${JSON.stringify(bundle, null, 2)}\n`);
|
|
41
|
+
await writeFile(evidencePath, evidenceBytes);
|
|
42
|
+
const hosted = await (options.push ?? pushEvidenceToControlPlane)({
|
|
43
|
+
baseUrl: options.connection.server,
|
|
44
|
+
projectId: options.connection.projectId,
|
|
45
|
+
apiKey: options.connection.apiKey,
|
|
46
|
+
bundle,
|
|
47
|
+
evidenceBytes,
|
|
48
|
+
fileName: "agentcert-evidence.json",
|
|
49
|
+
companionArtifacts: companions.artifacts,
|
|
50
|
+
skippedCompanionArtifacts: companions.skipped,
|
|
51
|
+
releaseAssuranceCaseId: assuranceCase.id,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
assuranceCaseId: assuranceCase.id,
|
|
55
|
+
script,
|
|
56
|
+
passed: execution.exitCode === 0 && !execution.timedOut,
|
|
57
|
+
exitCode: execution.exitCode,
|
|
58
|
+
timedOut: execution.timedOut,
|
|
59
|
+
evidencePath,
|
|
60
|
+
logPath,
|
|
61
|
+
hostedRunId: hosted.runId,
|
|
62
|
+
hostedEvidenceId: hosted.evidenceId,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function renderReleaseEvaluation(outcome) {
|
|
66
|
+
const status = outcome.passed ? "PASSED" : "FAILED";
|
|
67
|
+
return [
|
|
68
|
+
`Release evaluation: ${status}`,
|
|
69
|
+
`Local script: npm run ${outcome.script}`,
|
|
70
|
+
`Evidence: ${outcome.evidencePath}`,
|
|
71
|
+
`Log: ${outcome.logPath}`,
|
|
72
|
+
`Hosted run: ${outcome.hostedRunId}`,
|
|
73
|
+
`Attached to release assurance case: ${outcome.assuranceCaseId}`,
|
|
74
|
+
outcome.passed
|
|
75
|
+
? "Next: return to Release Assurance. Witnora will enable customer review after the attached evidence is verified."
|
|
76
|
+
: "Next: inspect the log, fix the failing evaluation, and run the same command again. The failed evidence remains part of the audit trail.",
|
|
77
|
+
"",
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
async function loadReleaseAssuranceCase(connection, caseId, request) {
|
|
81
|
+
const response = await request(`${connection.server.replace(/\/$/, "")}/v1/projects/${encodeURIComponent(connection.projectId)}/assurance-cases/${encodeURIComponent(caseId)}`, { headers: { authorization: `Bearer ${connection.apiKey}` } });
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const body = await response.text();
|
|
84
|
+
let message = body || `HTTP ${response.status}`;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(body);
|
|
87
|
+
message = parsed.error ?? parsed.message ?? message;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Preserve the plain response body.
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Could not load release assurance case ${caseId}: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
const payload = await response.json();
|
|
95
|
+
if (!payload.assuranceCase)
|
|
96
|
+
throw new Error(`Release assurance case ${caseId} was not returned by Hosted.`);
|
|
97
|
+
return payload.assuranceCase;
|
|
98
|
+
}
|
|
99
|
+
function assertEvaluatingSelfServiceCase(assuranceCase) {
|
|
100
|
+
if (assuranceCase.engagement) {
|
|
101
|
+
throw new Error("This case is managed by an independent review engagement. Use the reviewer evidence workflow instead.");
|
|
102
|
+
}
|
|
103
|
+
if (assuranceCase.status !== "evaluating") {
|
|
104
|
+
throw new Error(`Release assurance case ${assuranceCase.id} is ${assuranceCase.status}, not evaluating.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function detectReleaseScript(repository) {
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = await readFile(join(repository, "package.json"), "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code === "ENOENT") {
|
|
114
|
+
throw new Error("No package.json was found. Add a local `witnora:release` npm script that runs the repository's existing release evaluation.");
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
let packageJson;
|
|
119
|
+
try {
|
|
120
|
+
packageJson = JSON.parse(raw);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new Error("package.json is not valid JSON.");
|
|
124
|
+
}
|
|
125
|
+
const script = RELEASE_SCRIPT_CANDIDATES.find((candidate) => typeof packageJson.scripts?.[candidate] === "string");
|
|
126
|
+
if (!script) {
|
|
127
|
+
throw new Error("No supported release evaluation script was found. Add `witnora:release`, or expose an existing `evals:run`, `test:ci`, or `test` npm script.");
|
|
128
|
+
}
|
|
129
|
+
return script;
|
|
130
|
+
}
|
|
131
|
+
async function runNpmScript(input) {
|
|
132
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
133
|
+
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", input.script], {
|
|
134
|
+
cwd: input.repository,
|
|
135
|
+
detached: process.platform !== "win32",
|
|
136
|
+
shell: false,
|
|
137
|
+
windowsHide: true,
|
|
138
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
139
|
+
});
|
|
140
|
+
let stdout = "";
|
|
141
|
+
let stderr = "";
|
|
142
|
+
let timedOut = false;
|
|
143
|
+
let settled = false;
|
|
144
|
+
let forceTimer;
|
|
145
|
+
const finish = (result) => {
|
|
146
|
+
if (settled)
|
|
147
|
+
return;
|
|
148
|
+
settled = true;
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
if (forceTimer)
|
|
151
|
+
clearTimeout(forceTimer);
|
|
152
|
+
resolveRun(result);
|
|
153
|
+
};
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
timedOut = true;
|
|
156
|
+
terminateProcessTree(child.pid);
|
|
157
|
+
forceTimer = setTimeout(() => {
|
|
158
|
+
terminateProcessTree(child.pid, true);
|
|
159
|
+
finish({ exitCode: 1, stdout, stderr, timedOut: true });
|
|
160
|
+
}, 5_000);
|
|
161
|
+
}, input.timeoutMs);
|
|
162
|
+
child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
|
|
163
|
+
child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
|
|
164
|
+
child.once("error", (error) => {
|
|
165
|
+
if (settled)
|
|
166
|
+
return;
|
|
167
|
+
settled = true;
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
if (forceTimer)
|
|
170
|
+
clearTimeout(forceTimer);
|
|
171
|
+
rejectRun(error);
|
|
172
|
+
});
|
|
173
|
+
child.once("close", (code) => {
|
|
174
|
+
finish({ exitCode: code ?? 1, stdout, stderr, timedOut });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function terminateProcessTree(pid, force = false) {
|
|
179
|
+
if (!pid)
|
|
180
|
+
return;
|
|
181
|
+
if (process.platform === "win32") {
|
|
182
|
+
const taskkill = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
|
183
|
+
shell: false,
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
stdio: "ignore",
|
|
186
|
+
});
|
|
187
|
+
taskkill.on("error", () => undefined);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// The process may have exited between the timeout and termination attempt.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function appendBounded(current, chunk) {
|
|
198
|
+
if (Buffer.byteLength(current) >= MAX_OUTPUT_BYTES)
|
|
199
|
+
return current;
|
|
200
|
+
const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(current);
|
|
201
|
+
return current + chunk.subarray(0, remaining).toString("utf8");
|
|
202
|
+
}
|
|
203
|
+
function normalizeTimeout(value) {
|
|
204
|
+
const timeout = value ?? DEFAULT_TIMEOUT_MS;
|
|
205
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1_000 || timeout > 30 * 60 * 1000) {
|
|
206
|
+
throw new Error("Release evaluation timeout must be between 1 second and 30 minutes.");
|
|
207
|
+
}
|
|
208
|
+
return timeout;
|
|
209
|
+
}
|
|
210
|
+
function renderEvaluationLog(script, result) {
|
|
211
|
+
return [
|
|
212
|
+
`command=npm run ${script}`,
|
|
213
|
+
`exitCode=${result.exitCode}`,
|
|
214
|
+
`timedOut=${result.timedOut}`,
|
|
215
|
+
"",
|
|
216
|
+
"[stdout]",
|
|
217
|
+
result.stdout,
|
|
218
|
+
"",
|
|
219
|
+
"[stderr]",
|
|
220
|
+
result.stderr,
|
|
221
|
+
"",
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
function renderEvaluationArtifact(script, result) {
|
|
225
|
+
const stdout = redactEvaluationOutput(result.stdout);
|
|
226
|
+
const stderr = redactEvaluationOutput(result.stderr);
|
|
227
|
+
return {
|
|
228
|
+
schemaVersion: "witnora.release_evaluation_output.v0.1",
|
|
229
|
+
command: { executable: "npm", arguments: ["run", script] },
|
|
230
|
+
exitCode: result.exitCode,
|
|
231
|
+
timedOut: result.timedOut,
|
|
232
|
+
stdout: outputDescriptor(result.stdout, stdout.value),
|
|
233
|
+
stderr: outputDescriptor(result.stderr, stderr.value),
|
|
234
|
+
redaction: {
|
|
235
|
+
policyVersion: "witnora.release_output_redaction.v0.1",
|
|
236
|
+
replacements: stdout.replacements + stderr.replacements,
|
|
237
|
+
rawOutputRetainedLocally: true,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function outputDescriptor(raw, redacted) {
|
|
242
|
+
return {
|
|
243
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
244
|
+
byteLength: Buffer.byteLength(raw),
|
|
245
|
+
excerpt: redacted,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function redactEvaluationOutput(value) {
|
|
249
|
+
const patterns = [
|
|
250
|
+
/\bsk-(?:proj-|live_|test_)?[A-Za-z0-9_-]{16,}\b/g,
|
|
251
|
+
/\brk_(?:live|test)_[A-Za-z0-9]{12,}\b/g,
|
|
252
|
+
/\bnpm_[A-Za-z0-9]{16,}\b/g,
|
|
253
|
+
/\bac_(?:live|test)_[A-Za-z0-9_-]{12,}\b/g,
|
|
254
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi,
|
|
255
|
+
];
|
|
256
|
+
let redacted = value;
|
|
257
|
+
let replacements = 0;
|
|
258
|
+
for (const pattern of patterns) {
|
|
259
|
+
redacted = redacted.replace(pattern, () => {
|
|
260
|
+
replacements += 1;
|
|
261
|
+
return "[REDACTED_SECRET]";
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return { value: redacted, replacements };
|
|
265
|
+
}
|
|
266
|
+
function releaseEvaluationResult(input) {
|
|
267
|
+
const passed = input.execution.exitCode === 0 && !input.execution.timedOut;
|
|
268
|
+
return {
|
|
269
|
+
schemaVersion: "1",
|
|
270
|
+
product: "agentcert-cli",
|
|
271
|
+
runId: input.runId,
|
|
272
|
+
timestamp: input.timestamp,
|
|
273
|
+
phase: "pre-release",
|
|
274
|
+
score: passed ? 100 : 0,
|
|
275
|
+
passed,
|
|
276
|
+
summary: passed
|
|
277
|
+
? `The repository's existing ${input.script} evaluation completed successfully.`
|
|
278
|
+
: `The repository's existing ${input.script} evaluation failed${input.execution.timedOut ? " after timing out" : ""}.`,
|
|
279
|
+
evidenceStrength: {
|
|
280
|
+
schemaVersion: "agentcert.evidence_strength.v0.1",
|
|
281
|
+
level: "reported",
|
|
282
|
+
claims: ["A customer-owned repository evaluation produced a captured process result."],
|
|
283
|
+
limitations: ["This local command result is not independent outcome verification or an independent Witnora review."],
|
|
284
|
+
trustVector: {
|
|
285
|
+
schemaVersion: "witnora.evidence_trust_vector.v0.1",
|
|
286
|
+
capture: "self_reported",
|
|
287
|
+
mediation: "none",
|
|
288
|
+
outcome: "unverified",
|
|
289
|
+
completeness: "partial",
|
|
290
|
+
attestation: "unsigned",
|
|
291
|
+
review: "none",
|
|
292
|
+
limitations: ["The repository controls the evaluated script and its reported outcome."],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
artifacts: { evaluationOutput: input.artifactPath },
|
|
296
|
+
evidence: [{
|
|
297
|
+
id: `release-evaluation-${input.runId}`,
|
|
298
|
+
kind: "release_evaluation",
|
|
299
|
+
severity: passed ? "info" : "high",
|
|
300
|
+
message: passed
|
|
301
|
+
? `npm run ${input.script} completed with exit code 0.`
|
|
302
|
+
: `npm run ${input.script} completed with exit code ${input.execution.exitCode}${input.execution.timedOut ? " after timing out" : ""}.`,
|
|
303
|
+
source: "witnora-cli",
|
|
304
|
+
artifactPath: input.artifactPath,
|
|
305
|
+
suggestedFix: passed ? undefined : "Inspect the complete local evaluation log and the uploaded redacted summary, correct the failure, and rerun this release evaluation.",
|
|
306
|
+
metadata: { assuranceCaseId: input.assuranceCase.id, script: input.script, timedOut: input.execution.timedOut },
|
|
307
|
+
}],
|
|
308
|
+
};
|
|
309
|
+
}
|