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 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 and
17
- asks for two decisions: **Confirm plan** and **Authorize install**. Then open a
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
- The browser approval creates one restricted project credential, then the CLI
25
- saves it outside the repository, detects capability groups, and installs only
26
- missing Gateway, outcome-probe, CI, policy, review, configuration, and boundary
27
- files. It verifies each component and reports progress to Hosted. A failed
28
- attempt removes only files created by that attempt. The onboarding receipt is
29
- synthetic: it cannot create a run, evidence object, release decision, or
30
- `CURRENT` assurance.
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 durable customer-owned Gateway beside the
49
- Agent automatically. Start it, then run the Agent's normal sandbox workflow:
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 run
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**. They are not part of the default customer
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") {
@@ -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, and records an isolated synthetic self-test receipt. The self-test
26
- does not create assurance evidence or establish CURRENT status.
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 runs a customer-owned, metadata-only collector beside the Agent.
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 doctor output
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 doctor\n");
82
- output(" 2. Run: npx witnora@latest gateway run\n");
83
- output(" 3. Import .witnora/gateway/client.mjs at one sandbox workflow boundary.\n");
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 it with `witnora gateway run`." });
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." : "Start the Gateway, then send one sandbox run through its local event API.",
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}\`.\n\n## Start the Gateway\n\n\`\`\`bash\nnpx witnora@latest gateway doctor\nnpx witnora@latest gateway run\n\`\`\`\n\nKeep \`gateway run\` open. 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\` or \`data/\`.\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`;
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
- output("\nExisting complete customer-owned Gateway found; verifying and reusing it.\n");
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("The self-test remains isolated. Start the generated Gateway and run the agent normally; the first source-signed, server-reconciled Gateway run completes onboarding.\n");
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`],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "witnora",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Independent assurance for covered agent action paths across models and frameworks.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",