witnora 0.20.2 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -41,7 +41,7 @@ import { inspectRepository } from "./onboard.js";
41
41
  import { renderReleaseEvaluation, runReleaseEvaluation } from "./release-evaluation.js";
42
42
  import { runPrivateCapabilityDiscovery } from "./private-discovery.js";
43
43
  import { configureManagedWorkflowHarness, doctorCustomerGateway, initializeCustomerGateway, isGatewayDoctorReady, readManagedCustomerGatewayLogs, renderGatewayDoctor, renderManagedGatewayStatus, restartManagedCustomerGateway, runCustomerGateway, startManagedCustomerGateway, statusManagedCustomerGateway, stopManagedCustomerGateway, superviseManagedCustomerGateway, } from "./gateway.js";
44
- import { installCurrentGatewayService, restartCurrentGatewayService, uninstallCurrentGatewayService } from "./gateway-service.js";
44
+ import { installCurrentGatewayService, uninstallCurrentGatewayService } from "./gateway-service.js";
45
45
  import { verifyEvidencePacketV02 } from "./evidence-v02.js";
46
46
  process.on("uncaughtException", reportFatalError);
47
47
  process.on("unhandledRejection", reportFatalError);
@@ -238,7 +238,6 @@ else if (command === "gateway") {
238
238
  const repository = readFlag("--repo") ?? process.cwd();
239
239
  const dir = readFlag("--dir");
240
240
  const service = await installCurrentGatewayService({ repository, gatewayDirectory: dir, cliEntry: fileURLToPath(import.meta.url) });
241
- await restartCurrentGatewayService({ repository, gatewayDirectory: dir, cliEntry: fileURLToPath(import.meta.url) });
242
241
  const result = await startManagedCustomerGateway({ repository, dir });
243
242
  const doctor = await doctorCustomerGateway({ repository, dir });
244
243
  if (!isGatewayDoctorReady(doctor))
@@ -263,7 +262,7 @@ else if (command === "gateway") {
263
262
  throw new Error("Use witnora gateway service install|uninstall.");
264
263
  }
265
264
  else if (action === "supervise") {
266
- await superviseManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir"), configHome: readFlag("--config-home"), output: (message) => process.stdout.write(message) });
265
+ await superviseManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir"), configHome: readFlag("--config-home"), serviceLeasePath: readFlag("--service-lease"), serviceGeneration: readFlag("--service-generation"), output: (message) => process.stdout.write(message) });
267
266
  }
268
267
  else if (action === "stop") {
269
268
  const result = await stopManagedCustomerGateway({ repository: readFlag("--repo") ?? process.cwd(), dir: readFlag("--dir") });
@@ -1,17 +1,22 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
3
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, userInfo } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  const execFileAsync = promisify(execFile);
8
+ const WINDOWS_PARENT_EXIT_GRACE_MS = 500;
8
9
  export async function installGatewayService(input) {
9
10
  const plan = createGatewayServicePlan(input);
10
11
  // A prior task can still be supervising an older generated Gateway. End it
11
12
  // before replacing the definition so onboarding never leaves two owners
12
13
  // racing for the same localhost port.
13
- if (plan.kind === "WINDOWS_TASK")
14
+ if (plan.kind === "WINDOWS_TASK") {
14
15
  await input.run(plan.stop.command, plan.stop.args).catch(() => undefined);
16
+ await (input.removeFile ?? removeFile)(plan.serviceLease.path);
17
+ await (input.sleep ?? wait)(WINDOWS_PARENT_EXIT_GRACE_MS);
18
+ await input.writeDefinition(plan.serviceLease.path, `${plan.serviceLease.generation}\n`);
19
+ }
15
20
  if (plan.launcher)
16
21
  await input.writeDefinition(plan.launcher.path, plan.launcher.definition);
17
22
  await input.writeDefinition(plan.definitionPath, plan.definition);
@@ -23,31 +28,42 @@ export async function installGatewayService(input) {
23
28
  return { installed: true, plan };
24
29
  }
25
30
  export async function installCurrentGatewayService(options) {
26
- const planInput = currentPlanInput(options);
31
+ const planInput = currentPlanInput(options, randomUUID());
27
32
  return installGatewayService({ ...planInput,
28
33
  writeDefinition: async (path, value) => {
29
34
  await mkdir(dirname(path), { recursive: true });
30
- const contents = planInput.platform === "win32"
35
+ const contents = planInput.platform === "win32" && !path.endsWith(".lease")
31
36
  ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(value, "utf16le")])
32
37
  : Buffer.from(value, "utf8");
33
38
  await writeFile(path, contents, { mode: 0o600 });
34
39
  },
35
40
  run: options.run ?? runCommand,
41
+ sleep: options.sleep,
42
+ removeFile: options.removeFile,
36
43
  });
37
44
  }
38
45
  export async function uninstallCurrentGatewayService(options) {
39
46
  const plan = createGatewayServicePlan(currentPlanInput(options));
40
47
  const run = options.run ?? runCommand;
48
+ if (plan.kind === "WINDOWS_TASK") {
49
+ await run(plan.stop.command, plan.stop.args).catch(() => undefined);
50
+ await (options.removeFile ?? removeFile)(plan.serviceLease.path);
51
+ await (options.sleep ?? wait)(WINDOWS_PARENT_EXIT_GRACE_MS);
52
+ }
41
53
  await run(plan.uninstall.command, plan.uninstall.args);
42
54
  if (plan.kind === "SYSTEMD_USER")
43
55
  await run("systemctl", ["--user", "daemon-reload"]);
44
56
  await rm(plan.definitionPath, { force: true });
45
57
  if (plan.launcher)
46
58
  await rm(plan.launcher.path, { force: true });
59
+ if (plan.serviceLease)
60
+ await rm(plan.serviceLease.path, { force: true });
47
61
  return { uninstalled: true, plan };
48
62
  }
49
63
  export async function restartCurrentGatewayService(options) {
50
64
  const plan = createGatewayServicePlan(currentPlanInput(options));
65
+ if (plan.kind === "WINDOWS_TASK")
66
+ return (await installCurrentGatewayService(options)).plan;
51
67
  const run = options.run ?? runCommand;
52
68
  await run(plan.stop.command, plan.stop.args).catch(() => undefined);
53
69
  await run(plan.start.command, plan.start.args);
@@ -56,6 +72,10 @@ export async function restartCurrentGatewayService(options) {
56
72
  export async function stopCurrentGatewayService(options) {
57
73
  const plan = createGatewayServicePlan(currentPlanInput(options));
58
74
  await (options.run ?? runCommand)(plan.stop.command, plan.stop.args).catch(() => undefined);
75
+ if (plan.kind === "WINDOWS_TASK") {
76
+ await (options.removeFile ?? removeFile)(plan.serviceLease.path);
77
+ await (options.sleep ?? wait)(WINDOWS_PARENT_EXIT_GRACE_MS);
78
+ }
59
79
  return plan;
60
80
  }
61
81
  export function createGatewayServicePlan(input) {
@@ -64,8 +84,12 @@ export function createGatewayServicePlan(input) {
64
84
  const superviseArgs = [input.cliEntry, "gateway", "supervise", "--repo", input.repository, "--dir", input.gatewayDirectory];
65
85
  if (input.configHome)
66
86
  superviseArgs.push("--config-home", input.configHome);
67
- if (input.platform === "win32")
68
- return windowsPlan(input, id, superviseArgs);
87
+ if (input.platform === "win32") {
88
+ const leasePath = `${input.serviceHome}\\${id}.lease`;
89
+ const generation = input.serviceGeneration ?? "manual-service-generation";
90
+ superviseArgs.push("--service-lease", leasePath, "--service-generation", generation);
91
+ return { ...windowsPlan(input, id, superviseArgs), serviceLease: { path: leasePath, generation } };
92
+ }
69
93
  if (input.platform === "darwin")
70
94
  return launchdPlan(input, id, superviseArgs);
71
95
  if (input.platform === "linux")
@@ -96,7 +120,7 @@ function windowsPlan(input, id, args) {
96
120
  stop: { command: "schtasks.exe", args: ["/End", "/TN", id] },
97
121
  uninstall: { command: "schtasks.exe", args: ["/Delete", "/TN", id, "/F"] } };
98
122
  }
99
- function currentPlanInput(options) {
123
+ function currentPlanInput(options, serviceGeneration) {
100
124
  const platform = options.platform ?? process.platform;
101
125
  if (platform !== "win32" && platform !== "linux" && platform !== "darwin")
102
126
  throw new Error(`Gateway service installation is not supported on ${platform}.`);
@@ -104,9 +128,15 @@ function currentPlanInput(options) {
104
128
  const env = options.env ?? process.env;
105
129
  const serviceHome = platform === "win32" ? join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Witnora", "services")
106
130
  : platform === "darwin" ? join(homedir(), "Library", "LaunchAgents") : join(homedir(), ".config", "systemd", "user");
107
- return { platform, repository, gatewayDirectory: resolve(repository, options.gatewayDirectory ?? ".witnora/gateway"), cliEntry: resolve(options.cliEntry), nodeExecutable: options.nodeExecutable ?? process.execPath, serviceHome, userId: platform === "win32" ? `${env.USERDOMAIN ? `${env.USERDOMAIN}\\` : ""}${env.USERNAME ?? userInfo().username}` : userInfo().username, configHome: options.configHome ?? env.WITNORA_CONFIG_HOME };
131
+ return { platform, repository, gatewayDirectory: resolve(repository, options.gatewayDirectory ?? ".witnora/gateway"), cliEntry: resolve(options.cliEntry), nodeExecutable: options.nodeExecutable ?? process.execPath, serviceHome, userId: platform === "win32" ? `${env.USERDOMAIN ? `${env.USERDOMAIN}\\` : ""}${env.USERNAME ?? userInfo().username}` : userInfo().username, configHome: options.configHome ?? env.WITNORA_CONFIG_HOME, serviceGeneration };
108
132
  }
109
133
  async function runCommand(command, args) { await execFileAsync(command, args, { windowsHide: true }); }
134
+ function wait(milliseconds) {
135
+ return new Promise((resolveWait) => setTimeout(resolveWait, milliseconds));
136
+ }
137
+ async function removeFile(path) {
138
+ await rm(path, { force: true });
139
+ }
110
140
  function systemdPlan(input, id, args) {
111
141
  const unit = `${id}.service`;
112
142
  const definitionPath = `${input.serviceHome}/${unit}`;
package/dist/gateway.js CHANGED
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { closeSync, openSync } from "node:fs";
4
4
  import { access, chmod, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { createServer as createNetServer } from "node:net";
6
- import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { loadConnection } from "./credentials.js";
9
9
  import { authorizeProjectConnection } from "./device-authorization.js";
@@ -1014,9 +1014,7 @@ async function startManagedLocalEvaluator(directory, config, evaluatorKit) {
1014
1014
  if (!repositoryRelative || repositoryRelative.startsWith("..") || resolve(repository, repositoryRelative) !== modulePath) {
1015
1015
  throw new Error("Managed evaluator module escaped the Agent repository.");
1016
1016
  }
1017
- const [realRepository, realModulePath] = await Promise.all([realpath(repository), realpath(modulePath)]);
1018
- const realRelative = relative(realRepository, realModulePath);
1019
- if (!realRelative || realRelative.startsWith("..") || resolve(realRepository, realRelative) !== realModulePath) {
1017
+ if (!await isRealPathContained(repository, modulePath)) {
1020
1018
  throw new Error("Managed evaluator module symlink escaped the Agent repository.");
1021
1019
  }
1022
1020
  const source = await readFile(modulePath);
@@ -1392,20 +1390,47 @@ export async function restartManagedCustomerGateway(options = {}) {
1392
1390
  export async function superviseManagedCustomerGateway(options = {}) {
1393
1391
  const inspect = options.inspect ?? (() => statusManagedCustomerGateway({ repository: options.repository, dir: options.dir }));
1394
1392
  const start = options.start ?? (() => startManagedCustomerGateway({ repository: options.repository, dir: options.dir, configHome: options.configHome }));
1393
+ const stop = options.stop ?? (() => stopManagedCustomerGateway({ repository: options.repository, dir: options.dir, configHome: options.configHome }));
1395
1394
  const sleep = options.sleep ?? wait;
1396
1395
  const maxCycles = options.maxCycles ?? Number.POSITIVE_INFINITY;
1396
+ if (Boolean(options.serviceLeasePath) !== Boolean(options.serviceGeneration)) {
1397
+ throw new Error("A service-owned Gateway supervisor requires both its lease path and generation.");
1398
+ }
1399
+ const readServiceLease = options.readServiceLease ?? ((path) => readFile(path, "utf8"));
1400
+ const leaseRevoked = async () => {
1401
+ if (!options.serviceLeasePath || !options.serviceGeneration)
1402
+ return false;
1403
+ try {
1404
+ return (await readServiceLease(options.serviceLeasePath)).trim() !== options.serviceGeneration;
1405
+ }
1406
+ catch {
1407
+ return true;
1408
+ }
1409
+ };
1410
+ const stopForRevokedLease = async () => {
1411
+ if (!await leaseRevoked())
1412
+ return false;
1413
+ await stop();
1414
+ return true;
1415
+ };
1397
1416
  let recoveries = 0;
1398
1417
  for (let cycle = 0; cycle < maxCycles; cycle += 1) {
1418
+ if (await stopForRevokedLease())
1419
+ return;
1399
1420
  const status = await inspect();
1421
+ if (await stopForRevokedLease())
1422
+ return;
1400
1423
  if (status.state === "CONFLICT")
1401
1424
  throw new Error(status.detail);
1402
1425
  if (status.state === "STOPPED" || status.state === "STALE") {
1403
1426
  await start();
1404
1427
  recoveries += 1;
1405
1428
  options.output?.(`Recovered customer-owned Gateway (${recoveries}).\n`);
1429
+ if (await stopForRevokedLease())
1430
+ return;
1406
1431
  }
1407
1432
  if (cycle + 1 < maxCycles)
1408
- await sleep(options.intervalMs ?? 5_000);
1433
+ await sleep(options.intervalMs ?? (options.serviceLeasePath ? 250 : 5_000));
1409
1434
  }
1410
1435
  }
1411
1436
  export async function readManagedCustomerGatewayLogs(options = {}) {
@@ -1744,9 +1769,7 @@ export async function activateManagedWorkflowHarness(options) {
1744
1769
  if (!moduleRelativeToRepository || moduleRelativeToRepository.startsWith("..") || resolve(repository, moduleRelativeToRepository) !== absoluteModulePath) {
1745
1770
  throw new Error("The customer Assurance Harness module must remain inside the Agent repository.");
1746
1771
  }
1747
- const [realRepository, realModulePath] = await Promise.all([realpath(repository), realpath(absoluteModulePath).catch(() => absoluteModulePath)]);
1748
- const realRelative = relative(realRepository, realModulePath);
1749
- if (realRelative.startsWith("..") || resolve(realRepository, realRelative) !== realModulePath) {
1772
+ if (!await isRealPathContained(repository, absoluteModulePath)) {
1750
1773
  throw new Error("The customer Assurance Harness module symlink must remain inside the Agent repository.");
1751
1774
  }
1752
1775
  const source = await readFile(absoluteModulePath, "utf8").catch((error) => {
@@ -1795,6 +1818,25 @@ export async function activateManagedWorkflowHarness(options) {
1795
1818
  }
1796
1819
  return { state: "READY_TO_START", path, modulePath: evaluatorModulePath, config, created, changed };
1797
1820
  }
1821
+ export async function isRealPathContained(repository, candidate, resolveRealPath = realpath) {
1822
+ const canonicalExistingPath = async (path) => {
1823
+ const resolvedPath = await resolveRealPath(path);
1824
+ const resolvedParent = await resolveRealPath(dirname(resolvedPath));
1825
+ return join(resolvedParent, basename(resolvedPath));
1826
+ };
1827
+ const realRepository = await canonicalExistingPath(repository);
1828
+ let realCandidate;
1829
+ try {
1830
+ realCandidate = await canonicalExistingPath(candidate);
1831
+ }
1832
+ catch (error) {
1833
+ if (error.code !== "ENOENT")
1834
+ throw error;
1835
+ realCandidate = join(await canonicalExistingPath(dirname(candidate)), basename(candidate));
1836
+ }
1837
+ const realRelative = relative(realRepository, realCandidate);
1838
+ return realRelative === "" || (realRelative !== ".." && !realRelative.startsWith(`..${sep}`) && !isAbsolute(realRelative));
1839
+ }
1798
1840
  function isExactGeneratedHarnessUpgrade(current, next, previousGeneratedModuleSha256) {
1799
1841
  if (!previousGeneratedModuleSha256 || current.evaluatorModuleSha256 !== previousGeneratedModuleSha256 || current.evaluatorContractSha256 !== previousGeneratedModuleSha256)
1800
1842
  return false;
package/dist/onboard.js CHANGED
@@ -378,13 +378,13 @@ async function runActionTransportTest(options) {
378
378
  const binding = await loadCustomerMcpActionBinding({ repository: options.repository });
379
379
  const checkedAt = new Date().toISOString();
380
380
  const passed = options.transport === "http"
381
- ? {
381
+ ? await verifyHttpActionConnection(binding, options.fetch).then((endpoint) => ({
382
382
  state: "PASSED",
383
383
  transport: "http",
384
384
  checkedAt,
385
385
  actionId: binding.action.id,
386
- endpoint: `${binding.baseUrl}${binding.action.path}`,
387
- }
386
+ endpoint,
387
+ }))
388
388
  : await import("./mcp.js").then(async ({ testWitnoraMcpConnection }) => {
389
389
  const result = await testWitnoraMcpConnection({ repository: options.repository, fetch: options.fetch });
390
390
  return {
@@ -420,6 +420,24 @@ async function runActionTransportTest(options) {
420
420
  options.generatedFiles.push(receiptPath);
421
421
  return { ...passed, receiptPath };
422
422
  }
423
+ async function verifyHttpActionConnection(binding, requestFetch) {
424
+ const endpoint = `${binding.baseUrl}${binding.action.path}`;
425
+ const response = await requestFetch(endpoint, {
426
+ method: "POST",
427
+ headers: {
428
+ authorization: `Bearer ${binding.actionToken}`,
429
+ "content-type": "application/json",
430
+ "idempotency-key": `witnora-transport-test-${binding.action.id}`,
431
+ },
432
+ body: "{}",
433
+ signal: AbortSignal.timeout(5_000),
434
+ });
435
+ const body = await response.json().catch(() => ({}));
436
+ if (response.status !== 400 || body.error !== "resourceId is required and must use URL-safe identifier characters.") {
437
+ throw new Error(`HTTP Action connection test failed closed (${response.status}). The exact customer-owned endpoint, Action binding, or action-scoped token is unavailable.`);
438
+ }
439
+ return endpoint;
440
+ }
423
441
  async function waitForInstalledGatewayService(options) {
424
442
  const pause = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
425
443
  const deadline = Date.now() + (options.timeoutMs ?? 12_000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "witnora",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "description": "Independent assurance for covered agent action paths across models and frameworks.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",