witnora 0.12.0 → 0.13.0
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 +26 -2
- package/dist/command-help.js +13 -5
- package/dist/gateway.js +237 -8
- package/dist/onboard.js +92 -6
- 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
|
@@ -38,7 +38,7 @@ import { runDesignPartnerCommand } from "./design-partner-v02.js";
|
|
|
38
38
|
import { runOnboard } from "./onboard.js";
|
|
39
39
|
import { inspectRepository } from "./onboard.js";
|
|
40
40
|
import { runPrivateCapabilityDiscovery } from "./private-discovery.js";
|
|
41
|
-
import { doctorCustomerGateway, initializeCustomerGateway, renderGatewayDoctor, runCustomerGateway } from "./gateway.js";
|
|
41
|
+
import { doctorCustomerGateway, initializeCustomerGateway, readManagedCustomerGatewayLogs, renderGatewayDoctor, renderManagedGatewayStatus, restartManagedCustomerGateway, runCustomerGateway, startManagedCustomerGateway, statusManagedCustomerGateway, stopManagedCustomerGateway, } from "./gateway.js";
|
|
42
42
|
import { verifyEvidencePacketV02 } from "./evidence-v02.js";
|
|
43
43
|
process.on("uncaughtException", reportFatalError);
|
|
44
44
|
process.on("unhandledRejection", reportFatalError);
|
|
@@ -180,11 +180,35 @@ else if (command === "gateway") {
|
|
|
180
180
|
if (result.overall !== "READY_TO_RECORD")
|
|
181
181
|
process.exitCode = 1;
|
|
182
182
|
}
|
|
183
|
+
else if (action === "start") {
|
|
184
|
+
const result = await startManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
185
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
186
|
+
}
|
|
187
|
+
else if (action === "status") {
|
|
188
|
+
const result = await statusManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
189
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
190
|
+
if (!result.healthy)
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
}
|
|
193
|
+
else if (action === "logs") {
|
|
194
|
+
const lines = Number(readFlag("--lines") ?? 100);
|
|
195
|
+
if (!Number.isSafeInteger(lines) || lines < 1 || lines > 10_000)
|
|
196
|
+
throw new Error("--lines must be an integer between 1 and 10000.");
|
|
197
|
+
process.stdout.write(`${await readManagedCustomerGatewayLogs({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir"), lines })}\n`);
|
|
198
|
+
}
|
|
199
|
+
else if (action === "restart") {
|
|
200
|
+
const result = await restartManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
201
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
202
|
+
}
|
|
203
|
+
else if (action === "stop") {
|
|
204
|
+
const result = await stopManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
205
|
+
process.stdout.write(readBoolFlag("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderManagedGatewayStatus(result));
|
|
206
|
+
}
|
|
183
207
|
else if (action === "run") {
|
|
184
208
|
await runCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
|
|
185
209
|
}
|
|
186
210
|
else {
|
|
187
|
-
throw new Error("Use witnora gateway init|doctor|run.");
|
|
211
|
+
throw new Error("Use witnora gateway init|doctor|start|status|logs|restart|stop|run.");
|
|
188
212
|
}
|
|
189
213
|
}
|
|
190
214
|
else if (command === "discover") {
|
package/dist/command-help.js
CHANGED
|
@@ -22,8 +22,9 @@ Options:
|
|
|
22
22
|
witnora onboard --project <project-id> --template <browser|coding|mcp|workflow|data>
|
|
23
23
|
|
|
24
24
|
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
|
|
25
|
+
writes missing starter files, starts a customer-owned Gateway in the background, and records an
|
|
26
|
+
isolated synthetic self-test receipt. The self-test does not create assurance evidence or establish
|
|
27
|
+
CURRENT status.
|
|
27
28
|
|
|
28
29
|
Options:
|
|
29
30
|
--server <url> Hosted server (default: https://witnora.com)
|
|
@@ -37,12 +38,18 @@ Options:
|
|
|
37
38
|
return `Usage:
|
|
38
39
|
witnora gateway init --project <project-id>
|
|
39
40
|
witnora gateway doctor
|
|
41
|
+
witnora gateway start
|
|
42
|
+
witnora gateway status
|
|
43
|
+
witnora gateway logs [--lines 100]
|
|
44
|
+
witnora gateway restart
|
|
45
|
+
witnora gateway stop
|
|
40
46
|
witnora gateway run
|
|
41
47
|
|
|
42
|
-
Initializes and
|
|
48
|
+
Initializes and manages a customer-owned, metadata-only collector beside the Agent.
|
|
43
49
|
The browser approval issues a collector-scoped credential; no API key is copied into
|
|
44
50
|
the repository or exposed to the Agent. The local queue and source signing key remain
|
|
45
|
-
under customer control.
|
|
51
|
+
under customer control. Onboard uses background start by default. The run command is
|
|
52
|
+
the foreground debugging path.
|
|
46
53
|
|
|
47
54
|
The reference Gateway establishes RECORDED evidence only. ENFORCED requires target
|
|
48
55
|
write credentials behind a controlled execution adapter. OUTCOME VERIFIED requires a
|
|
@@ -56,7 +63,8 @@ Options:
|
|
|
56
63
|
--name <name> Saved collector connection name
|
|
57
64
|
--no-browser Print the approval URL without opening it
|
|
58
65
|
--force Replace an existing reviewed local setup
|
|
59
|
-
--json JSON
|
|
66
|
+
--json JSON status output
|
|
67
|
+
--lines <count> Number of recent log lines (default: 100)
|
|
60
68
|
`;
|
|
61
69
|
if (command === "design-partner")
|
|
62
70
|
return `Usage:
|
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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { DEFAULT_WITNORA_SERVER } from "./credentials.js";
|
|
4
|
+
import { DEFAULT_WITNORA_SERVER, saveConnection } from "./credentials.js";
|
|
5
5
|
import { authorizeProjectConnection } from "./device-authorization.js";
|
|
6
6
|
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,6 +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
|
+
};
|
|
45
|
+
let gatewayMigration;
|
|
46
|
+
let managedGateway;
|
|
41
47
|
await reportInstall(requestFetch, server, token.projectId, token.apiKey, setupPlan.id, { status: "installing", attemptId });
|
|
42
48
|
try {
|
|
43
49
|
generatedFiles.push(...await generateRepositoryConfig(repositoryPath, repository.template, repository.name));
|
|
@@ -85,9 +91,31 @@ export async function runOnboard(options) {
|
|
|
85
91
|
generatedFiles.push(...gateway.generatedFiles);
|
|
86
92
|
}
|
|
87
93
|
else {
|
|
88
|
-
|
|
94
|
+
const binding = await inspectGatewayBinding(gatewayState.directory, token.projectId, server);
|
|
95
|
+
if (binding.matches) {
|
|
96
|
+
await saveConnection(binding.connectionName, { server, projectId: token.projectId, apiKey: token.apiKey }, { configHome: options.configHome });
|
|
97
|
+
output("\nExisting customer-owned Gateway matches this project; verifying and reusing it.\n");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
await assertGatewayStopped(requestFetch, binding.host, binding.port, binding.projectId, token.projectId);
|
|
101
|
+
gatewayMigration = await archiveGateway(gatewayState.directory, repositoryPath, binding.projectId);
|
|
102
|
+
output(`\nExisting Gateway belongs to project ${binding.projectId}; archived it at ${gatewayMigration.archiveDirectory}.\n`);
|
|
103
|
+
const gateway = await initializeCustomerGateway({
|
|
104
|
+
projectId: token.projectId, server, repository: repositoryPath, authorization: token,
|
|
105
|
+
fetch: requestFetch, configHome: options.configHome, output,
|
|
106
|
+
});
|
|
107
|
+
generatedFiles.push(...gateway.generatedFiles);
|
|
108
|
+
}
|
|
89
109
|
}
|
|
90
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);
|
|
91
119
|
const doctor = await doctorCustomerGateway({ repository: repositoryPath, configHome: options.configHome, fetch: requestFetch });
|
|
92
120
|
if (doctor.overall !== "READY_TO_RECORD")
|
|
93
121
|
throw new Error(doctor.checks.filter((check) => check.status === "FAIL").map((check) => check.message).join(" "));
|
|
@@ -99,20 +127,78 @@ export async function runOnboard(options) {
|
|
|
99
127
|
output(`Credentials: ${credentialsPath}\nSelf-test receipt: ${receiptPath}\n`);
|
|
100
128
|
output(`Private discovery: ${discovery.capabilityCount} capability group(s); ${discovery.unknownCapabilityCount} pending confirmation.\n`);
|
|
101
129
|
output("Installed: customer-owned Gateway, default-deny policy, independent-probe contract, review contract, and PR/release/nightly CI.\n");
|
|
102
|
-
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");
|
|
103
132
|
return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
|
|
104
133
|
repositoryKind: repository.kind, generatedFiles, receiptPath, discovery, setupPlanId: setupPlan.id,
|
|
105
|
-
gatewayDirectory: join(repositoryPath, ".witnora", "gateway")
|
|
134
|
+
gatewayDirectory: join(repositoryPath, ".witnora", "gateway"), gatewayArchiveDirectory: gatewayMigration?.archiveDirectory,
|
|
135
|
+
gateway: managedGateway };
|
|
106
136
|
}
|
|
107
137
|
catch (error) {
|
|
108
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
|
+
}
|
|
109
142
|
await rollbackGeneratedFiles(generatedFiles);
|
|
143
|
+
if (gatewayMigration)
|
|
144
|
+
await restoreGateway(gatewayMigration);
|
|
110
145
|
await reportInstall(requestFetch, server, token.projectId, token.apiKey, setupPlan.id, {
|
|
111
146
|
status: "failed", attemptId, generatedFiles: relativeGeneratedFiles(repositoryPath, generatedFiles), diagnosis, rolledBack: true,
|
|
112
147
|
}).catch(() => undefined);
|
|
113
148
|
throw new Error(`Witnora Setup Autopilot rolled back this install attempt: ${diagnosis}`);
|
|
114
149
|
}
|
|
115
150
|
}
|
|
151
|
+
async function inspectGatewayBinding(directory, projectId, server) {
|
|
152
|
+
let value;
|
|
153
|
+
try {
|
|
154
|
+
value = JSON.parse(await readFile(join(directory, "gateway.json"), "utf8"));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new Error("Existing Gateway configuration is not valid JSON. Preserve it and repair the setup in Advanced mode.");
|
|
158
|
+
}
|
|
159
|
+
const existingProjectId = typeof value.projectId === "string" ? value.projectId.trim() : "";
|
|
160
|
+
const existingServer = typeof value.server === "string" ? normalizeServer(value.server) : "";
|
|
161
|
+
const connectionName = typeof value.connectionName === "string" ? value.connectionName.trim() : "";
|
|
162
|
+
const host = typeof value.host === "string" && value.host.trim() ? value.host.trim() : "127.0.0.1";
|
|
163
|
+
const port = Number(value.port);
|
|
164
|
+
if (!existingProjectId || !existingServer || !connectionName || !Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
165
|
+
throw new Error("Existing Gateway configuration is missing a valid project, server, host, or port. Preserve it and repair the setup in Advanced mode.");
|
|
166
|
+
}
|
|
167
|
+
return { matches: existingProjectId === projectId && existingServer === server, projectId: existingProjectId, server: existingServer, connectionName, host, port };
|
|
168
|
+
}
|
|
169
|
+
async function assertGatewayStopped(requestFetch, host, port, oldProjectId, newProjectId) {
|
|
170
|
+
try {
|
|
171
|
+
const response = await requestFetch(`http://${host}:${port}/healthz`, { signal: AbortSignal.timeout(800) });
|
|
172
|
+
if (!response.ok)
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`The running Gateway belongs to project ${oldProjectId}, not ${newProjectId}. Stop it with Ctrl+C, then run onboard again so Witnora can preserve the old evidence and bind this repository to the new project.`);
|
|
179
|
+
}
|
|
180
|
+
async function archiveGateway(gatewayDirectory, repositoryPath, projectId) {
|
|
181
|
+
const archiveRoot = join(repositoryPath, ".witnora", "gateway-archive");
|
|
182
|
+
const archiveDirectory = join(archiveRoot, `${Date.now()}-${safePathSegment(projectId)}-${randomSuffix()}`);
|
|
183
|
+
await mkdir(archiveRoot, { recursive: true });
|
|
184
|
+
const archiveIgnore = join(archiveRoot, ".gitignore");
|
|
185
|
+
if (!await exists(archiveIgnore))
|
|
186
|
+
await writeFile(archiveIgnore, "*\n!.gitignore\n");
|
|
187
|
+
try {
|
|
188
|
+
await rename(gatewayDirectory, archiveDirectory);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
throw new Error(`Could not archive the Gateway for project ${projectId}. Stop any running Gateway with Ctrl+C and retry. ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
}
|
|
193
|
+
return { gatewayDirectory, archiveDirectory };
|
|
194
|
+
}
|
|
195
|
+
async function restoreGateway(migration) {
|
|
196
|
+
await rm(migration.gatewayDirectory, { recursive: true, force: true });
|
|
197
|
+
await rename(migration.archiveDirectory, migration.gatewayDirectory);
|
|
198
|
+
}
|
|
199
|
+
function safePathSegment(value) {
|
|
200
|
+
return (value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-|-$/g, "") || "unknown-project").slice(0, 80);
|
|
201
|
+
}
|
|
116
202
|
async function generateAutopilotFiles(repositoryPath, subject) {
|
|
117
203
|
const files = new Map([
|
|
118
204
|
[".witnora/setup/policy.json", `${JSON.stringify({ schemaVersion: "witnora.setup_policy.v0.1", subject, unknownCapabilities: "pending_confirmation", defaultDecision: "deny", environment: "sandbox" }, null, 2)}\n`],
|