witnora 0.20.2 → 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -3
- package/dist/gateway-service.js +38 -8
- package/dist/gateway.js +106 -19
- package/dist/onboard.js +64 -7
- package/dist/vendor/onegent-runtime/failure-runtime-watch.js +2 -2
- package/package.json +1 -1
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,
|
|
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") });
|
package/dist/gateway-service.js
CHANGED
|
@@ -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
|
-
|
|
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";
|
|
@@ -56,7 +56,7 @@ export async function initializeCustomerGateway(options) {
|
|
|
56
56
|
projectId: authorization.projectId,
|
|
57
57
|
server: authorization.server,
|
|
58
58
|
connectionName: authorization.connectionName,
|
|
59
|
-
collectorId:
|
|
59
|
+
collectorId: collectorIdForRepository(repository, options.projectId),
|
|
60
60
|
host: process.env.WITNORA_GATEWAY_HOST?.trim() || "127.0.0.1",
|
|
61
61
|
port: gatewayPort(process.env.WITNORA_GATEWAY_PORT, 8787),
|
|
62
62
|
storageDirectory: "data",
|
|
@@ -223,8 +223,18 @@ export async function ensureCustomerGatewayPortAvailable(options) {
|
|
|
223
223
|
const httpActionRaw = await readFile(httpActionPath, "utf8").catch((error) => error.code === "ENOENT" ? undefined : Promise.reject(error));
|
|
224
224
|
const httpAction = httpActionRaw ? parseCustomerHttpActionConfig(httpActionRaw) : undefined;
|
|
225
225
|
const httpActionGuideRaw = httpAction ? await readFile(httpActionGuidePath, "utf8") : undefined;
|
|
226
|
-
const
|
|
227
|
-
|
|
226
|
+
const requestFetch = options.fetch ?? fetch;
|
|
227
|
+
let health = await localCollector(current.host, current.port, requestFetch);
|
|
228
|
+
if (!health && await canListen(current.host, current.port)) {
|
|
229
|
+
// A supervised Gateway can leave a short window between process exit and
|
|
230
|
+
// recovery. Recheck before claiming the port so another repository's
|
|
231
|
+
// supervisor cannot reclaim it between this probe and service startup.
|
|
232
|
+
await (options.sleep ?? wait)(600);
|
|
233
|
+
health = await localCollector(current.host, current.port, requestFetch);
|
|
234
|
+
if (!health && await canListen(current.host, current.port))
|
|
235
|
+
return { changed: false, port: current.port };
|
|
236
|
+
}
|
|
237
|
+
if (health === current.collectorId) {
|
|
228
238
|
return { changed: false, port: current.port };
|
|
229
239
|
}
|
|
230
240
|
if (clientRaw !== gatewayClient(current) || readmeRaw !== gatewayReadme(current)
|
|
@@ -649,6 +659,11 @@ async function configManagedWorkflowHarnessImport() {
|
|
|
649
659
|
function configBusinessTaskEvaluatorImport() {
|
|
650
660
|
return import(new URL("./vendor/onegent-runtime/business-task-evaluator.js", import.meta.url).href);
|
|
651
661
|
}
|
|
662
|
+
export function workflowHarnessTickDidWork(result) {
|
|
663
|
+
return (result.evaluationsCompleted ?? 0) > 0
|
|
664
|
+
|| (result.evaluationsFailed ?? 0) > 0
|
|
665
|
+
|| (result.runtimeWatchObservationsUploaded ?? 0) > 0;
|
|
666
|
+
}
|
|
652
667
|
export async function createConfiguredWorkflowHarness(input) {
|
|
653
668
|
const requestFetch = input.fetch ?? fetch;
|
|
654
669
|
let managedEvaluator;
|
|
@@ -717,6 +732,8 @@ export async function createConfiguredWorkflowHarness(input) {
|
|
|
717
732
|
&& input.config.realPathActivations[0].actionPathIds.length === 1
|
|
718
733
|
? input.config.realPathActivations[0]
|
|
719
734
|
: undefined;
|
|
735
|
+
const basePollIntervalMs = input.config.pollIntervalMs ?? 5_000;
|
|
736
|
+
const maxIdlePollIntervalMs = Math.max(basePollIntervalMs, 15 * 60_000);
|
|
720
737
|
const runtimeWatch = runtimeWatchConfiguration
|
|
721
738
|
&& input.managed.ManagedFailureRuntimeWatch
|
|
722
739
|
&& input.managed.FileFailureRuntimeWatchCheckpointStore
|
|
@@ -730,7 +747,7 @@ export async function createConfiguredWorkflowHarness(input) {
|
|
|
730
747
|
fetch: requestFetch,
|
|
731
748
|
}),
|
|
732
749
|
checkpoints: new input.managed.FileFailureRuntimeWatchCheckpointStore(join(input.directory, "data", "failure-runtime-watch")),
|
|
733
|
-
intervalMs:
|
|
750
|
+
intervalMs: maxIdlePollIntervalMs,
|
|
734
751
|
...(activation ? { bindings: [{ taskContractId: activation.taskContractId, actionPathId: activation.actionPathIds[0], environment: "sandbox" }] } : {}),
|
|
735
752
|
readOutcome: async ({ resourceId, actions, receipts }) => input.managed.readLocalSandboxRuntimeWatchOutcome({
|
|
736
753
|
path: join(input.directory, "data", "runtime-sandbox", "fixture", "audit.jsonl"),
|
|
@@ -744,6 +761,7 @@ export async function createConfiguredWorkflowHarness(input) {
|
|
|
744
761
|
: undefined;
|
|
745
762
|
let timer;
|
|
746
763
|
let closing = false;
|
|
764
|
+
let idleTicks = 0;
|
|
747
765
|
let lastTickAt;
|
|
748
766
|
let lastError;
|
|
749
767
|
let lastProviderHealthAt = 0;
|
|
@@ -778,14 +796,33 @@ export async function createConfiguredWorkflowHarness(input) {
|
|
|
778
796
|
start() {
|
|
779
797
|
if (timer || closing)
|
|
780
798
|
return;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
799
|
+
const schedule = (delayMs) => {
|
|
800
|
+
if (closing)
|
|
801
|
+
return;
|
|
802
|
+
timer = setTimeout(() => void run(), delayMs);
|
|
803
|
+
timer.unref?.();
|
|
804
|
+
};
|
|
805
|
+
const run = async () => {
|
|
806
|
+
let didWork = false;
|
|
807
|
+
try {
|
|
808
|
+
const result = await tick();
|
|
809
|
+
didWork = workflowHarnessTickDidWork(result);
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
process.stderr.write(`Managed Workflow Harness tick failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
813
|
+
}
|
|
814
|
+
if (closing)
|
|
815
|
+
return;
|
|
816
|
+
idleTicks = didWork ? 0 : idleTicks + 1;
|
|
817
|
+
const multiplier = 2 ** Math.max(0, idleTicks - 1);
|
|
818
|
+
schedule(Math.min(maxIdlePollIntervalMs, basePollIntervalMs * multiplier));
|
|
819
|
+
};
|
|
820
|
+
schedule(0);
|
|
784
821
|
},
|
|
785
822
|
async close() {
|
|
786
823
|
closing = true;
|
|
787
824
|
if (timer)
|
|
788
|
-
|
|
825
|
+
clearTimeout(timer);
|
|
789
826
|
timer = undefined;
|
|
790
827
|
await managedEvaluator?.close();
|
|
791
828
|
},
|
|
@@ -1014,9 +1051,7 @@ async function startManagedLocalEvaluator(directory, config, evaluatorKit) {
|
|
|
1014
1051
|
if (!repositoryRelative || repositoryRelative.startsWith("..") || resolve(repository, repositoryRelative) !== modulePath) {
|
|
1015
1052
|
throw new Error("Managed evaluator module escaped the Agent repository.");
|
|
1016
1053
|
}
|
|
1017
|
-
|
|
1018
|
-
const realRelative = relative(realRepository, realModulePath);
|
|
1019
|
-
if (!realRelative || realRelative.startsWith("..") || resolve(realRepository, realRelative) !== realModulePath) {
|
|
1054
|
+
if (!await isRealPathContained(repository, modulePath)) {
|
|
1020
1055
|
throw new Error("Managed evaluator module symlink escaped the Agent repository.");
|
|
1021
1056
|
}
|
|
1022
1057
|
const source = await readFile(modulePath);
|
|
@@ -1319,12 +1354,15 @@ export async function statusManagedCustomerGateway(options = {}) {
|
|
|
1319
1354
|
pid: runtime?.pid,
|
|
1320
1355
|
logPath: runtime?.logPath,
|
|
1321
1356
|
};
|
|
1322
|
-
if (health && health.collectorId !== config.collectorId) {
|
|
1323
|
-
return { ...base, state: "CONFLICT", healthy: false, managed: false, detail: `Port ${config.port} is occupied by collector ${health.collectorId}, not ${config.collectorId}.` };
|
|
1324
|
-
}
|
|
1325
1357
|
if (runtime && !runtimeMatches(runtime, config)) {
|
|
1358
|
+
if (!pidRunning(runtime.pid)) {
|
|
1359
|
+
return { ...base, state: "STALE", healthy: false, managed: false, detail: "Managed Gateway runtime metadata belongs to an older generated identity and its recorded process is no longer running." };
|
|
1360
|
+
}
|
|
1326
1361
|
return { ...base, state: "CONFLICT", healthy: false, managed: false, detail: "Managed Gateway runtime metadata belongs to a different project or collector." };
|
|
1327
1362
|
}
|
|
1363
|
+
if (health && health.collectorId !== config.collectorId) {
|
|
1364
|
+
return { ...base, state: "CONFLICT", healthy: false, managed: false, detail: `Port ${config.port} is occupied by collector ${health.collectorId}, not ${config.collectorId}.` };
|
|
1365
|
+
}
|
|
1328
1366
|
if (health) {
|
|
1329
1367
|
const managed = Boolean(runtime && pidRunning(runtime.pid));
|
|
1330
1368
|
return {
|
|
@@ -1392,20 +1430,47 @@ export async function restartManagedCustomerGateway(options = {}) {
|
|
|
1392
1430
|
export async function superviseManagedCustomerGateway(options = {}) {
|
|
1393
1431
|
const inspect = options.inspect ?? (() => statusManagedCustomerGateway({ repository: options.repository, dir: options.dir }));
|
|
1394
1432
|
const start = options.start ?? (() => startManagedCustomerGateway({ repository: options.repository, dir: options.dir, configHome: options.configHome }));
|
|
1433
|
+
const stop = options.stop ?? (() => stopManagedCustomerGateway({ repository: options.repository, dir: options.dir, configHome: options.configHome }));
|
|
1395
1434
|
const sleep = options.sleep ?? wait;
|
|
1396
1435
|
const maxCycles = options.maxCycles ?? Number.POSITIVE_INFINITY;
|
|
1436
|
+
if (Boolean(options.serviceLeasePath) !== Boolean(options.serviceGeneration)) {
|
|
1437
|
+
throw new Error("A service-owned Gateway supervisor requires both its lease path and generation.");
|
|
1438
|
+
}
|
|
1439
|
+
const readServiceLease = options.readServiceLease ?? ((path) => readFile(path, "utf8"));
|
|
1440
|
+
const leaseRevoked = async () => {
|
|
1441
|
+
if (!options.serviceLeasePath || !options.serviceGeneration)
|
|
1442
|
+
return false;
|
|
1443
|
+
try {
|
|
1444
|
+
return (await readServiceLease(options.serviceLeasePath)).trim() !== options.serviceGeneration;
|
|
1445
|
+
}
|
|
1446
|
+
catch {
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
const stopForRevokedLease = async () => {
|
|
1451
|
+
if (!await leaseRevoked())
|
|
1452
|
+
return false;
|
|
1453
|
+
await stop();
|
|
1454
|
+
return true;
|
|
1455
|
+
};
|
|
1397
1456
|
let recoveries = 0;
|
|
1398
1457
|
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
|
|
1458
|
+
if (await stopForRevokedLease())
|
|
1459
|
+
return;
|
|
1399
1460
|
const status = await inspect();
|
|
1461
|
+
if (await stopForRevokedLease())
|
|
1462
|
+
return;
|
|
1400
1463
|
if (status.state === "CONFLICT")
|
|
1401
1464
|
throw new Error(status.detail);
|
|
1402
1465
|
if (status.state === "STOPPED" || status.state === "STALE") {
|
|
1403
1466
|
await start();
|
|
1404
1467
|
recoveries += 1;
|
|
1405
1468
|
options.output?.(`Recovered customer-owned Gateway (${recoveries}).\n`);
|
|
1469
|
+
if (await stopForRevokedLease())
|
|
1470
|
+
return;
|
|
1406
1471
|
}
|
|
1407
1472
|
if (cycle + 1 < maxCycles)
|
|
1408
|
-
await sleep(options.intervalMs ?? 5_000);
|
|
1473
|
+
await sleep(options.intervalMs ?? (options.serviceLeasePath ? 250 : 5_000));
|
|
1409
1474
|
}
|
|
1410
1475
|
}
|
|
1411
1476
|
export async function readManagedCustomerGatewayLogs(options = {}) {
|
|
@@ -1744,9 +1809,7 @@ export async function activateManagedWorkflowHarness(options) {
|
|
|
1744
1809
|
if (!moduleRelativeToRepository || moduleRelativeToRepository.startsWith("..") || resolve(repository, moduleRelativeToRepository) !== absoluteModulePath) {
|
|
1745
1810
|
throw new Error("The customer Assurance Harness module must remain inside the Agent repository.");
|
|
1746
1811
|
}
|
|
1747
|
-
|
|
1748
|
-
const realRelative = relative(realRepository, realModulePath);
|
|
1749
|
-
if (realRelative.startsWith("..") || resolve(realRepository, realRelative) !== realModulePath) {
|
|
1812
|
+
if (!await isRealPathContained(repository, absoluteModulePath)) {
|
|
1750
1813
|
throw new Error("The customer Assurance Harness module symlink must remain inside the Agent repository.");
|
|
1751
1814
|
}
|
|
1752
1815
|
const source = await readFile(absoluteModulePath, "utf8").catch((error) => {
|
|
@@ -1795,6 +1858,25 @@ export async function activateManagedWorkflowHarness(options) {
|
|
|
1795
1858
|
}
|
|
1796
1859
|
return { state: "READY_TO_START", path, modulePath: evaluatorModulePath, config, created, changed };
|
|
1797
1860
|
}
|
|
1861
|
+
export async function isRealPathContained(repository, candidate, resolveRealPath = realpath) {
|
|
1862
|
+
const canonicalExistingPath = async (path) => {
|
|
1863
|
+
const resolvedPath = await resolveRealPath(path);
|
|
1864
|
+
const resolvedParent = await resolveRealPath(dirname(resolvedPath));
|
|
1865
|
+
return join(resolvedParent, basename(resolvedPath));
|
|
1866
|
+
};
|
|
1867
|
+
const realRepository = await canonicalExistingPath(repository);
|
|
1868
|
+
let realCandidate;
|
|
1869
|
+
try {
|
|
1870
|
+
realCandidate = await canonicalExistingPath(candidate);
|
|
1871
|
+
}
|
|
1872
|
+
catch (error) {
|
|
1873
|
+
if (error.code !== "ENOENT")
|
|
1874
|
+
throw error;
|
|
1875
|
+
realCandidate = join(await canonicalExistingPath(dirname(candidate)), basename(candidate));
|
|
1876
|
+
}
|
|
1877
|
+
const realRelative = relative(realRepository, realCandidate);
|
|
1878
|
+
return realRelative === "" || (realRelative !== ".." && !realRelative.startsWith(`..${sep}`) && !isAbsolute(realRelative));
|
|
1879
|
+
}
|
|
1798
1880
|
function isExactGeneratedHarnessUpgrade(current, next, previousGeneratedModuleSha256) {
|
|
1799
1881
|
if (!previousGeneratedModuleSha256 || current.evaluatorModuleSha256 !== previousGeneratedModuleSha256 || current.evaluatorContractSha256 !== previousGeneratedModuleSha256)
|
|
1800
1882
|
return false;
|
|
@@ -2139,6 +2221,11 @@ function wait(milliseconds) {
|
|
|
2139
2221
|
function safeSlug(value) {
|
|
2140
2222
|
return (value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 48);
|
|
2141
2223
|
}
|
|
2224
|
+
function collectorIdForRepository(repository, projectId) {
|
|
2225
|
+
const readable = safeSlug(basename(repository)).slice(0, 36);
|
|
2226
|
+
const identity = createHash("sha256").update(`${resolve(repository)}\n${projectId}`).digest("hex").slice(0, 10);
|
|
2227
|
+
return `${readable}-${identity}-collector`;
|
|
2228
|
+
}
|
|
2142
2229
|
function message(error) {
|
|
2143
2230
|
return error instanceof Error ? error.message : String(error);
|
|
2144
2231
|
}
|
package/dist/onboard.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { DEFAULT_WITNORA_SERVER, saveConnection } from "./credentials.js";
|
|
5
|
+
import { credentialsPath, DEFAULT_WITNORA_SERVER, loadConnection, saveConnection } from "./credentials.js";
|
|
6
6
|
import { authorizeProjectConnection } from "./device-authorization.js";
|
|
7
7
|
import { verifyControlPlaneConnection } from "./control-plane.js";
|
|
8
8
|
import { inferPrivateCapabilities, runPrivateCapabilityDiscovery } from "./private-discovery.js";
|
|
@@ -26,7 +26,15 @@ export async function runOnboard(options) {
|
|
|
26
26
|
framework: frameworkForTemplate(repository.template),
|
|
27
27
|
};
|
|
28
28
|
const connectionName = options.name ?? repository.slug;
|
|
29
|
-
const
|
|
29
|
+
const reusableConnection = await loadMatchingOnboardConnection({
|
|
30
|
+
repository: repositoryPath,
|
|
31
|
+
projectId: options.projectId,
|
|
32
|
+
server,
|
|
33
|
+
connectionName,
|
|
34
|
+
configHome: options.configHome,
|
|
35
|
+
fetch: requestFetch,
|
|
36
|
+
});
|
|
37
|
+
const token = reusableConnection ?? await authorizeProjectConnection({
|
|
30
38
|
projectId: options.projectId,
|
|
31
39
|
connectionName,
|
|
32
40
|
credentialProfile: "autopilot",
|
|
@@ -38,6 +46,8 @@ export async function runOnboard(options) {
|
|
|
38
46
|
output,
|
|
39
47
|
configHome: options.configHome,
|
|
40
48
|
});
|
|
49
|
+
if (reusableConnection)
|
|
50
|
+
output("Reusing the previously approved project-scoped setup connection; no new browser approval is required.\n");
|
|
41
51
|
const credentialsPath = token.credentialsPath;
|
|
42
52
|
await verifyControlPlaneConnection({ baseUrl: server, projectId: token.projectId, apiKey: token.apiKey, fetch: requestFetch });
|
|
43
53
|
const planSnapshot = await jsonRequest(requestFetch, `${server}/v1/projects/${encodeURIComponent(token.projectId)}/setup-plans`, {
|
|
@@ -211,7 +221,7 @@ export async function runOnboard(options) {
|
|
|
211
221
|
if (!(error instanceof RuntimeSetupNotReadyError))
|
|
212
222
|
throw error;
|
|
213
223
|
runtimeLimitation = `Local sandbox Runtime references were found but did not establish readiness: ${error.message}`;
|
|
214
|
-
output(
|
|
224
|
+
output(`\nExisting customer-owned Gateway remains RECORDED_ONLY because its Runtime references did not pass readiness checks: ${error.message}\n`);
|
|
215
225
|
}
|
|
216
226
|
}
|
|
217
227
|
else {
|
|
@@ -336,7 +346,7 @@ export async function runOnboard(options) {
|
|
|
336
346
|
output(`Action transport: ${actionTransportTest.transport.toUpperCase()} is waiting for one exact sandbox Task/action path. ${actionTransportTest.limitation}\n`);
|
|
337
347
|
}
|
|
338
348
|
output(actionTransportTest?.state === "WAITING_FOR_ACTION_PATH"
|
|
339
|
-
? `Base Gateway connected, but ${actionTransportTest.transport.toUpperCase()} Action is not verified yet.
|
|
349
|
+
? `Base Gateway connected, but ${actionTransportTest.transport.toUpperCase()} Action is not verified yet. Run the Agent's normal sandbox workflow from this repository; there is no Run button in Overview. Once Overview receives the activity, confirm one sandbox Business Task and choose its exact discovered Agent action, then rerun this same command.\n`
|
|
340
350
|
: "Connected. Nora is monitoring this Agent. Run it normally whenever it is ready; the first source-signed activity will appear automatically without blocking setup.\n");
|
|
341
351
|
return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
|
|
342
352
|
repositoryKind: repository.kind, generatedFiles, receiptPath, discovery, setupPlanId: setupPlan.id,
|
|
@@ -368,6 +378,35 @@ export async function runOnboard(options) {
|
|
|
368
378
|
throw new Error(`Witnora Setup Autopilot rolled back this install attempt: ${diagnosis}`);
|
|
369
379
|
}
|
|
370
380
|
}
|
|
381
|
+
const AUTOPILOT_CONNECTION_SCOPES = [
|
|
382
|
+
"runs:read", "runs:write", "events:write", "evidence:write", "collector:manage",
|
|
383
|
+
"actions:read", "actions:propose", "actions:execute",
|
|
384
|
+
];
|
|
385
|
+
async function loadMatchingOnboardConnection(options) {
|
|
386
|
+
const gateway = await inspectCustomerGatewayFiles({ repository: options.repository });
|
|
387
|
+
try {
|
|
388
|
+
let candidateName = options.connectionName;
|
|
389
|
+
if (gateway.status === "complete") {
|
|
390
|
+
const config = JSON.parse(await readFile(join(gateway.directory, "gateway.json"), "utf8"));
|
|
391
|
+
if (config.projectId !== options.projectId || config.server !== options.server || typeof config.connectionName !== "string")
|
|
392
|
+
return undefined;
|
|
393
|
+
candidateName = config.connectionName;
|
|
394
|
+
}
|
|
395
|
+
const stored = await loadConnection(candidateName, { configHome: options.configHome });
|
|
396
|
+
if (!stored || stored.projectId !== options.projectId || stored.server !== options.server)
|
|
397
|
+
return undefined;
|
|
398
|
+
await verifyControlPlaneConnection({ baseUrl: stored.server, projectId: stored.projectId, apiKey: stored.apiKey, fetch: options.fetch });
|
|
399
|
+
return {
|
|
400
|
+
...stored,
|
|
401
|
+
connectionName: candidateName,
|
|
402
|
+
credentialsPath: credentialsPath({ configHome: options.configHome }),
|
|
403
|
+
scopes: [...AUTOPILOT_CONNECTION_SCOPES],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
371
410
|
async function runActionTransportTest(options) {
|
|
372
411
|
if (!options.httpActionReady)
|
|
373
412
|
return {
|
|
@@ -378,13 +417,13 @@ async function runActionTransportTest(options) {
|
|
|
378
417
|
const binding = await loadCustomerMcpActionBinding({ repository: options.repository });
|
|
379
418
|
const checkedAt = new Date().toISOString();
|
|
380
419
|
const passed = options.transport === "http"
|
|
381
|
-
? {
|
|
420
|
+
? await verifyHttpActionConnection(binding, options.fetch).then((endpoint) => ({
|
|
382
421
|
state: "PASSED",
|
|
383
422
|
transport: "http",
|
|
384
423
|
checkedAt,
|
|
385
424
|
actionId: binding.action.id,
|
|
386
|
-
endpoint
|
|
387
|
-
}
|
|
425
|
+
endpoint,
|
|
426
|
+
}))
|
|
388
427
|
: await import("./mcp.js").then(async ({ testWitnoraMcpConnection }) => {
|
|
389
428
|
const result = await testWitnoraMcpConnection({ repository: options.repository, fetch: options.fetch });
|
|
390
429
|
return {
|
|
@@ -420,6 +459,24 @@ async function runActionTransportTest(options) {
|
|
|
420
459
|
options.generatedFiles.push(receiptPath);
|
|
421
460
|
return { ...passed, receiptPath };
|
|
422
461
|
}
|
|
462
|
+
async function verifyHttpActionConnection(binding, requestFetch) {
|
|
463
|
+
const endpoint = `${binding.baseUrl}${binding.action.path}`;
|
|
464
|
+
const response = await requestFetch(endpoint, {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: {
|
|
467
|
+
authorization: `Bearer ${binding.actionToken}`,
|
|
468
|
+
"content-type": "application/json",
|
|
469
|
+
"idempotency-key": `witnora-transport-test-${binding.action.id}`,
|
|
470
|
+
},
|
|
471
|
+
body: "{}",
|
|
472
|
+
signal: AbortSignal.timeout(5_000),
|
|
473
|
+
});
|
|
474
|
+
const body = await response.json().catch(() => ({}));
|
|
475
|
+
if (response.status !== 400 || body.error !== "resourceId is required and must use URL-safe identifier characters.") {
|
|
476
|
+
throw new Error(`HTTP Action connection test failed closed (${response.status}). The exact customer-owned endpoint, Action binding, or action-scoped token is unavailable.`);
|
|
477
|
+
}
|
|
478
|
+
return endpoint;
|
|
479
|
+
}
|
|
423
480
|
async function waitForInstalledGatewayService(options) {
|
|
424
481
|
const pause = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
425
482
|
const deadline = Date.now() + (options.timeoutMs ?? 12_000);
|
|
@@ -87,8 +87,8 @@ export class ManagedFailureRuntimeWatch {
|
|
|
87
87
|
this.#now = options.now ?? (() => new Date());
|
|
88
88
|
this.#readOutcome = options.readOutcome;
|
|
89
89
|
this.#bindings = options.bindings ? new Set(options.bindings.map(bindingKey)) : undefined;
|
|
90
|
-
if (!Number.isSafeInteger(this.#intervalMs) || this.#intervalMs < 1_000 || this.#intervalMs > 60_000) {
|
|
91
|
-
throw new Error("Failure Runtime Watch intervalMs must be between 1000 and
|
|
90
|
+
if (!Number.isSafeInteger(this.#intervalMs) || this.#intervalMs < 1_000 || this.#intervalMs > 15 * 60_000) {
|
|
91
|
+
throw new Error("Failure Runtime Watch intervalMs must be between 1000 and 900000.");
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
tick() {
|