@humwick/server 0.130.3 → 0.130.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/docs/openapi.json +103 -1
- package/package.json +6 -6
- package/public/assets/{MessageBubble-Bq9kr21N.js → MessageBubble-BOkmEvgZ.js} +2 -2
- package/public/assets/{PlanGraphPanel-Dqiroy06.js → PlanGraphPanel-BkaDJp0r.js} +2 -2
- package/public/assets/{TraceView-DYU962-H.js → TraceView-B99Uu2yT.js} +2 -2
- package/public/assets/{VaultDecisionCard-DhPfzioE.js → VaultDecisionCard-DUIfTTFF.js} +2 -2
- package/public/assets/{activity-DzCcQTee.js → activity-ZIX0N1El.js} +2 -2
- package/public/assets/{agent-lineage-SgDt9i3T.js → agent-lineage-kq9TPYFw.js} +2 -2
- package/public/assets/agent-liveness-DSQY1Aa_.js +2 -0
- package/public/assets/{agent-name-CcY3Aijy.js → agent-name-C_7F4DFR.js} +2 -2
- package/public/assets/{agent-profiles-cxtRBn6O.js → agent-profiles-BNOcrGcY.js} +2 -2
- package/public/assets/{agent-project-badge-DGYbIhaI.js → agent-project-badge-B6yAh70a.js} +2 -2
- package/public/assets/{agent-tools-DHbftV0N.js → agent-tools-CcrhmOzF.js} +2 -2
- package/public/assets/{agent-type-icon-CY113sp-.js → agent-type-icon-CXSLrKiq.js} +2 -2
- package/public/assets/{agents-BPzY7IlQ.js → agents-DIZG97_X.js} +2 -2
- package/public/assets/{analytics-DENFPv_a.js → analytics-rasmoPjR.js} +2 -2
- package/public/assets/{artifact-viewer-KRvSc7sl.js → artifact-viewer-tBpTrEPb.js} +2 -2
- package/public/assets/{artifacts-C9gSmL3s.js → artifacts-BNkrynP1.js} +2 -2
- package/public/assets/{artifacts-DT-HkI5O.js → artifacts-Bjq-ZyBA.js} +2 -2
- package/public/assets/{automation-BsmjQhKD.js → automation-Cx422L3L.js} +2 -2
- package/public/assets/{backlog-8NYH_62t.js → backlog-RuFQ8PUu.js} +2 -2
- package/public/assets/{busy-health-D3hwvYb4.js → busy-health-BvNQ9g5x.js} +2 -2
- package/public/assets/{channels-DCJ5WL7O.js → channels-DNbKAS0I.js} +2 -2
- package/public/assets/{chat-DAIl357U.js → chat-Bp3Fx73h.js} +2 -2
- package/public/assets/{code-preview-CzrZfr9U.js → code-preview-KIRZA6cz.js} +2 -2
- package/public/assets/{connectors-xTxtlyou.js → connectors-k7vO4vvc.js} +2 -2
- package/public/assets/{cosmos-BpebFLK2.js → cosmos-BuVoq1aw.js} +2 -2
- package/public/assets/{crate-agent-BHvI5wXC.js → crate-agent-irqlt4NH.js} +2 -2
- package/public/assets/{dependency-graph-DGT0o3wJ.js → dependency-graph-D_rurNBz.js} +2 -2
- package/public/assets/{deploys-hqWOHSHE.js → deploys-yzZqIpxE.js} +2 -2
- package/public/assets/{display-CDeH4I8T.js → display-BemJG1f_.js} +2 -2
- package/public/assets/{files-BTeGVmTg.js → files-DXWcrxOz.js} +2 -2
- package/public/assets/{fleet-DgXtNgCa.js → fleet-TEuOCZXE.js} +2 -2
- package/public/assets/{fleet-metrics-BHEt5yc_.js → fleet-metrics-BGoDAFI-.js} +2 -2
- package/public/assets/{formatted-body-impl-CAe664Z8.js → formatted-body-impl-BB9eq9nN.js} +2 -2
- package/public/assets/{formatted-body-MLtodyPp.js → formatted-body-qy2nTD_1.js} +3 -3
- package/public/assets/{host-providers-dialog-mapF4Fj9.js → host-providers-dialog-BGPHxuwT.js} +2 -2
- package/public/assets/{index-CqqgOI5R.js → index-BC5XZc0z.js} +5 -5
- package/public/assets/{index-MYAIwYTX.css → index-D8Rp9pz2.css} +1 -1
- package/public/assets/{insights-DNyf7IzF.js → insights-C-KQG0h-.js} +2 -2
- package/public/assets/{integrations-DlYOHq7T.js → integrations-ClPRPyKU.js} +2 -2
- package/public/assets/{issue-ref-chip-CeWBNWn7.js → issue-ref-chip-DbH8J9Ak.js} +2 -2
- package/public/assets/{log-viewer-Cib8nqdE.js → log-viewer-PH4zVn24.js} +2 -2
- package/public/assets/{maintenance-DSn1cPm7.js → maintenance-5lV43uTL.js} +2 -2
- package/public/assets/{managed-agents-C3CkM81m.js → managed-agents-BdgU35aX.js} +2 -2
- package/public/assets/{markdown-preview-W4VQQPAv.js → markdown-preview-DNT4OUwQ.js} +3 -3
- package/public/assets/{markdown-preview-impl-CMsTdhjq.js → markdown-preview-impl-CnCcPy0A.js} +2 -2
- package/public/assets/{memory-BV17qtO4.js → memory--GgWH1gH.js} +2 -2
- package/public/assets/{messages-Bm_1AqSA.js → messages-CzFl_97O.js} +2 -2
- package/public/assets/{orchestrators-Dg-vfP09.js → orchestrators-CVvJVkzv.js} +2 -2
- package/public/assets/overview-DsrZvE86.js +2 -0
- package/public/assets/{pairs-8o9nuG3V.js → pairs-D2d0d2py.js} +2 -2
- package/public/assets/{profile-icon-BsRyBMea.js → profile-icon-_SMuSbYF.js} +2 -2
- package/public/assets/{project-profile-instructions-DETKFn_E.js → project-profile-instructions-Apn15y43.js} +2 -2
- package/public/assets/{projects-M_m6AHws.js → projects-D3B6kto5.js} +2 -2
- package/public/assets/{prompt-settings-C_mNCgeL.js → prompt-settings-DnPB6Ez6.js} +2 -2
- package/public/assets/provider-display-bWwiuedV.js +2 -0
- package/public/assets/{registry-DDHHHE5N.js → registry-D790cIhN.js} +2 -2
- package/public/assets/{run-card-Dt4I4jaP.js → run-card-BCro-Odj.js} +2 -2
- package/public/assets/{secrets-BkVwxI5O.js → secrets-CVXvfvbU.js} +2 -2
- package/public/assets/{service-access-settings-Crq-6a-B.js → service-access-settings-D3Xxqb9w.js} +2 -2
- package/public/assets/{settings-CYcavMFu.js → settings-CoUC7Jci.js} +2 -2
- package/public/assets/{shipping-H6gzEeZB.js → shipping-D_Lp9n_J.js} +2 -2
- package/public/assets/{stats-BJVeWkpV.js → stats-CXiNjB9S.js} +2 -2
- package/public/assets/{status-dot-BcHyw1Vw.js → status-dot-C703YIy1.js} +2 -2
- package/public/assets/store-BgDALGIX.js +9 -0
- package/public/assets/{tasks-qJjlQPpx.js → tasks-DO0WpJCx.js} +2 -2
- package/public/assets/{teams-Cd5Sk1uX.js → teams-FGkdi_tL.js} +2 -2
- package/public/assets/{terminal-viewer-CpY5_m7W.js → terminal-viewer-Dh8dSC7e.js} +3 -3
- package/public/assets/{terminal-viewer-impl-DI_2llFR.js → terminal-viewer-impl-B5Zriip3.js} +2 -2
- package/public/assets/{use-agent-terminal-0yJA_buM.js → use-agent-terminal-CRUVDvLC.js} +2 -2
- package/public/assets/{use-decisions-uJXHf34W.js → use-decisions-CoaqRh4l.js} +2 -2
- package/public/assets/{use-project-list-CpWl6eHQ.js → use-project-list-X-BgT53X.js} +2 -2
- package/public/assets/{use-selectors-BdxBW8mL.js → use-selectors-0X_UZkk-.js} +2 -2
- package/public/assets/{user-avatar-3IndfpOc.js → user-avatar-CkLhVBGu.js} +2 -2
- package/public/assets/{versioned-prompt-editor-CIVg7XB1.js → versioned-prompt-editor-BkTi7eim.js} +2 -2
- package/public/assets/{work-queue-B9rnLwHZ.js → work-queue-DDOLEyKo.js} +2 -2
- package/public/assets/{workflow-marker-X8wxyhzC.js → workflow-marker-CzOsB8BM.js} +2 -2
- package/public/assets/{workflows-o5YyMFW7.js → workflows-DJilvmRM.js} +2 -2
- package/public/assets/{workspaces-CzEGoAng.js → workspaces-CHcfbLR3.js} +2 -2
- package/public/cosmos.html +4 -4
- package/public/index.html +29 -29
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/src/adapters/claude-api.ts +1 -1
- package/runner/src/provider-setup.ts +2 -2
- package/src/auth/login-link.ts +35 -0
- package/src/cli/index.ts +7 -1
- package/src/cli/init.ts +53 -0
- package/src/cli/orchestrator.ts +1 -7
- package/src/cli/setup.ts +6 -4
- package/src/db/spawn-delegation.ts +2 -1
- package/src/db/workflow-execution.ts +3 -3
- package/src/dev.ts +4 -13
- package/src/init.ts +299 -0
- package/src/invite-link.ts +16 -9
- package/src/routes/auth.ts +30 -2
- package/src/routes/host-providers.ts +18 -0
- package/src/routes/index.ts +5 -2
- package/src/routes/orchestrator-bootstrap.ts +2 -0
- package/src/routes/secrets-backup.ts +2 -3
- package/src/routes/secrets-import.ts +2 -3
- package/src/routes/secrets-vault.ts +5 -7
- package/src/runtime-install.ts +30 -0
- package/src/server.ts +4 -4
- package/src/services/auth-context.ts +6 -0
- package/src/services/provider-api-key.ts +73 -0
- package/src/setup.ts +47 -8
- package/src/utils.ts +7 -0
- package/public/assets/agent-liveness-Bq-s73jW.js +0 -2
- package/public/assets/overview-DcYCrvlr.js +0 -2
- package/public/assets/provider-display-BEeLXC_D.js +0 -2
- package/public/assets/store-DKPDIZBe.js +0 -9
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Task ownership
|
|
1
|
+
// Task ownership, coordinator routing and spawn admission share execution facts for runs and item lanes.
|
|
2
2
|
export const WORKFLOW_EXECUTIONS_SQL = `(
|
|
3
|
-
SELECT scope, id, taskId, coordinator, status, reason, awaiting FROM kernel_entity__workflow__run
|
|
3
|
+
SELECT scope, id, taskId, coordinator, callerAgentId, status, reason, awaiting FROM kernel_entity__workflow__run
|
|
4
4
|
UNION ALL
|
|
5
|
-
SELECT item.scope, item.id, owner.taskId, owner.coordinator,
|
|
5
|
+
SELECT item.scope, item.id, owner.taskId, owner.coordinator, owner.callerAgentId,
|
|
6
6
|
json_extract(item.lane, '$.status') AS status, json_extract(item.lane, '$.reason') AS reason, json_extract(item.lane, '$.awaiting') AS awaiting
|
|
7
7
|
FROM kernel_entity__workflow__activation item
|
|
8
8
|
JOIN kernel_entity__workflow__run owner ON owner.scope = item.scope AND owner.id = item.runId
|
package/src/dev.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { writeChildEnv } from "@humwick/sdk/env";
|
|
|
2
2
|
import { instanceDirectoryEnv } from "@humwick/sdk/isolated-env";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { shellQuote } from "@humwick/sdk/shell-utils";
|
|
5
|
-
import { existsSync,
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { homedir, hostname as osHostname } from "node:os";
|
|
8
8
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -10,6 +10,7 @@ import { promisify } from "node:util";
|
|
|
10
10
|
import { packPackage, privateWorkspaceDependencies, workspaceDependencyClosure } from "./package-pack";
|
|
11
11
|
import { generateToken, renderEnvFile } from "./setup";
|
|
12
12
|
import { createDaemonPlan, executeDaemonPlan, MANAGED_MARKER } from "./daemon";
|
|
13
|
+
import { npmInstallIntoPrefix } from "./runtime-install";
|
|
13
14
|
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
const DEV_MARKER = "agent-relay-dev-profile";
|
|
@@ -149,13 +150,10 @@ export function createDevInstallPlan(options: {
|
|
|
149
150
|
export async function executeDevInstallPlan(plan: DevInstallPlan, options: { dryRun?: boolean } = {}): Promise<DevInstallResult> {
|
|
150
151
|
if (options.dryRun) return { plan, tarballs: [], output: formatDevInstallPlan(plan) };
|
|
151
152
|
const packed = await executeDevPackPlan(plan.packPlan);
|
|
152
|
-
await
|
|
153
|
-
ensureDevPrefixPackageJson(plan.prefix);
|
|
154
|
-
const command = ["install", "--prefix", plan.prefix, "--no-audit", "--no-fund", "--force", ...packed.tarballs];
|
|
155
|
-
const result = await execFileAsync("npm", command, { cwd: plan.prefix });
|
|
153
|
+
const installed = await npmInstallIntoPrefix(plan.prefix, packed.tarballs);
|
|
156
154
|
const output = [
|
|
157
155
|
packed.output,
|
|
158
|
-
|
|
156
|
+
installed,
|
|
159
157
|
`Installed into ${plan.prefix}`,
|
|
160
158
|
].filter(Boolean).join("\n");
|
|
161
159
|
return { plan, tarballs: packed.tarballs, output };
|
|
@@ -426,13 +424,6 @@ function devEnvFile(rootDir = defaultDevRoot()): string {
|
|
|
426
424
|
return join(rootDir, "env");
|
|
427
425
|
}
|
|
428
426
|
|
|
429
|
-
function ensureDevPrefixPackageJson(prefix: string): void {
|
|
430
|
-
mkdirSync(prefix, { recursive: true });
|
|
431
|
-
const path = join(prefix, "package.json");
|
|
432
|
-
if (existsSync(path)) return;
|
|
433
|
-
writeFileSync(path, JSON.stringify({ private: true, name: "agent-relay-dev-install" }, null, 2) + "\n", "utf8");
|
|
434
|
-
}
|
|
435
|
-
|
|
436
427
|
function devServiceCommands(action: DevServiceAction, options: {
|
|
437
428
|
serverUnit: string;
|
|
438
429
|
orchestratorUnit: string;
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// `humwick init` (#2680): one resumable install of a persistent server. It composes the existing
|
|
2
|
+
// primitives (setup env file, runtime install, daemon planner) and ends with a one-time dashboard
|
|
3
|
+
// login link, so nobody copies the admin token. Every failure names its step and a repair command.
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
7
|
+
import { connect } from "node:net";
|
|
8
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { RELAY_TOKEN_HEADER, errMessage } from "@humwick/sdk";
|
|
12
|
+
import { VERSION } from "./config";
|
|
13
|
+
import { createDaemonPlan, detectDaemonEnvironment, executeDaemonPlan } from "./daemon";
|
|
14
|
+
import { installChannelsHostService } from "./channels-host-service";
|
|
15
|
+
import { executeSetupPlan, resolveSetupPlan, setupPlanToken } from "./setup";
|
|
16
|
+
import { runtimeBinPath, runtimeTarballDir } from "./runtime-prefix";
|
|
17
|
+
import { isServerTarball, npmInstallIntoPrefix } from "./runtime-install";
|
|
18
|
+
import { sanitizeOrchestratorId } from "./utils";
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
const SERVER_PACKAGE = "@humwick/server";
|
|
22
|
+
const ORCHESTRATOR_UNIT = "agent-relay-orchestrator.service";
|
|
23
|
+
|
|
24
|
+
export type InitStep = "preflight" | "setup" | "runtime" | "service" | "health" | "host" | "login";
|
|
25
|
+
|
|
26
|
+
export class InitStepError extends Error {
|
|
27
|
+
constructor(readonly step: InitStep, message: string, readonly repair: string) { super(message); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type InitOptions = {
|
|
31
|
+
tarballDir?: string;
|
|
32
|
+
runtimePrefix?: string;
|
|
33
|
+
envFile?: string;
|
|
34
|
+
host?: string;
|
|
35
|
+
port?: number;
|
|
36
|
+
dbPath?: string;
|
|
37
|
+
healthTimeoutMs?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type InitResult = {
|
|
41
|
+
ok: true;
|
|
42
|
+
version: string;
|
|
43
|
+
dashboardUrl: string;
|
|
44
|
+
loginUrl: string;
|
|
45
|
+
loginExpiresAt?: string;
|
|
46
|
+
service: { unit: string; kind: string; scope: string; file?: string };
|
|
47
|
+
envFile: string;
|
|
48
|
+
dbPath: string;
|
|
49
|
+
runtimePrefix: string;
|
|
50
|
+
runtime: { source: "registry" | "tarballs"; installed: boolean };
|
|
51
|
+
linger: "yes" | "no" | "unknown";
|
|
52
|
+
resumed: boolean;
|
|
53
|
+
/** This machine, joined as the server's first orchestrator host. */
|
|
54
|
+
host: { id: string; unit: string; joined: boolean };
|
|
55
|
+
warnings: string[];
|
|
56
|
+
next: string[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type Log = (line: string) => void;
|
|
60
|
+
|
|
61
|
+
async function run(command: string[], timeoutMs = 15_000): Promise<{ ok: boolean; out: string }> {
|
|
62
|
+
try {
|
|
63
|
+
const { stdout, stderr } = await execFileAsync(command[0]!, command.slice(1), { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
64
|
+
return { ok: true, out: `${stdout}${stderr}`.trim() };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
const err = e as { stdout?: string; stderr?: string; message: string };
|
|
67
|
+
return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() || err.message };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function probeHost(host: string): string {
|
|
72
|
+
return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when something accepts TCP connections on host:port. */
|
|
76
|
+
export function portInUse(host: string, port: number, timeoutMs = 2_000): Promise<boolean> {
|
|
77
|
+
return new Promise((done) => {
|
|
78
|
+
const socket = connect({ host: probeHost(host), port });
|
|
79
|
+
const finish = (inUse: boolean) => { socket.destroy(); done(inUse); };
|
|
80
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
81
|
+
socket.once("connect", () => finish(true));
|
|
82
|
+
socket.once("error", () => finish(false));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function healthy(baseUrl: string): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
return (await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) })).ok;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function unitActive(unit: string): Promise<boolean> {
|
|
95
|
+
return (await run(["systemctl", "--user", "is-active", unit])).out === "active";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function preflight(port: number, host: string, unit: string, log: Log): Promise<{ linger: InitResult["linger"]; resumed: boolean }> {
|
|
99
|
+
if (process.platform === "win32") throw new InitStepError("preflight", "native Windows is not supported", "Install WSL2 (wsl --install), then run init inside the Linux distribution.");
|
|
100
|
+
if (process.platform !== "linux") throw new InitStepError("preflight", `init installs a systemd user service; ${process.platform} is not supported yet`, "Run the server in the foreground: humwick");
|
|
101
|
+
const bun = Bun.which("bun");
|
|
102
|
+
if (!bun) throw new InitStepError("preflight", "bun is not on PATH", "curl -fsSL https://bun.sh/install | bash, then open a new shell");
|
|
103
|
+
if (!Bun.which("npm")) throw new InitStepError("preflight", "npm is not on PATH (the runtime installs with npm)", "Install Node.js 20 or newer with npm, e.g. sudo apt install nodejs npm");
|
|
104
|
+
const node = await run(["node", "--version"]);
|
|
105
|
+
const nodeMajor = Number(/^v(\d+)/.exec(node.out)?.[1] ?? 0);
|
|
106
|
+
if (!node.ok || nodeMajor < 20) throw new InitStepError("preflight", `Node.js 20 or newer is required (found ${node.ok ? node.out : "none"})`, "Install Node.js 20 or newer with npm");
|
|
107
|
+
const manager = await run(["systemctl", "--user", "show-environment"]);
|
|
108
|
+
if (!manager.ok) throw new InitStepError("preflight", `no systemd user manager is reachable: ${manager.out.split("\n")[0]}`, `Log in through a real session (ssh or console), or run: sudo loginctl enable-linger ${userInfo().username}`);
|
|
109
|
+
const lingerOut = await run(["loginctl", "show-user", userInfo().username, "--property=Linger", "--value"]);
|
|
110
|
+
const linger = lingerOut.ok ? (lingerOut.out === "yes" ? "yes" : "no") : "unknown";
|
|
111
|
+
const resumed = await unitActive(unit);
|
|
112
|
+
if (!resumed && await portInUse(host, port)) {
|
|
113
|
+
const other = await healthy(`http://${probeHost(host)}:${port}`) ? "a Humwick server that init does not manage (a foreground trial?)" : "another program";
|
|
114
|
+
throw new InitStepError("preflight", `port ${port} is in use by ${other}`, `Stop it, or pass --port <free port>. To keep a trial server's data, pass --db-path <its agent-relay.db>.`);
|
|
115
|
+
}
|
|
116
|
+
log(`preflight: bun ${Bun.version}, node ${node.out}, systemd user manager, linger ${linger}, port ${port} ${resumed ? "held by this install" : "free"}`);
|
|
117
|
+
return { linger, resumed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fileDigest(path: string): string {
|
|
121
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Copy the tarballs into the runtime's tarball dir; false when the runtime already has these exact files. */
|
|
125
|
+
function stageTarballs(sourceDir: string, prefix: string): { tarballs: string[]; changed: boolean } {
|
|
126
|
+
const names = readdirSync(sourceDir).filter((name) => name.endsWith(".tgz")).sort();
|
|
127
|
+
if (!names.some(isServerTarball)) throw new InitStepError("runtime", `no humwick-server-*.tgz in ${sourceDir}`, "Pack the packages: humwick dev pack --packages sdk,server,runner,orchestrator");
|
|
128
|
+
const target = runtimeTarballDir(prefix);
|
|
129
|
+
const existing = existsSync(target) ? readdirSync(target).filter((name) => name.endsWith(".tgz")).sort() : [];
|
|
130
|
+
const same = existing.join("\n") === names.join("\n")
|
|
131
|
+
&& names.every((name) => fileDigest(join(sourceDir, name)) === fileDigest(join(target, name)));
|
|
132
|
+
if (!same) {
|
|
133
|
+
mkdirSync(target, { recursive: true });
|
|
134
|
+
for (const name of existing) rmSync(join(target, name), { force: true });
|
|
135
|
+
for (const name of names) copyFileSync(join(sourceDir, name), join(target, name));
|
|
136
|
+
}
|
|
137
|
+
return { tarballs: names.map((name) => join(target, name)), changed: !same };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function installedServerVersion(prefix: string): string | undefined {
|
|
141
|
+
try {
|
|
142
|
+
return (JSON.parse(readFileSync(join(prefix, "node_modules", SERVER_PACKAGE, "package.json"), "utf8")) as { version?: string }).version;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function installRuntime(prefix: string, tarballDir: string | undefined, log: Log): Promise<boolean> {
|
|
149
|
+
const binPresent = existsSync(runtimeBinPath("agent-relay", prefix));
|
|
150
|
+
const staged = tarballDir ? stageTarballs(resolve(tarballDir), prefix) : undefined;
|
|
151
|
+
const upToDate = binPresent && installedServerVersion(prefix) === VERSION && (staged ? !staged.changed : true);
|
|
152
|
+
if (upToDate) {
|
|
153
|
+
log(`runtime: ${SERVER_PACKAGE}@${VERSION} already installed in ${prefix}`);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const specs = staged ? staged.tarballs : [`${SERVER_PACKAGE}@${VERSION}`];
|
|
157
|
+
try {
|
|
158
|
+
await npmInstallIntoPrefix(prefix, specs);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
throw new InitStepError("runtime", errMessage(e), `Check network access to the npm registry, then rerun: humwick init${tarballDir ? ` --tarball-dir ${tarballDir}` : ""}`);
|
|
161
|
+
}
|
|
162
|
+
if (!existsSync(runtimeBinPath("agent-relay", prefix))) throw new InitStepError("runtime", `the install finished but ${runtimeBinPath("agent-relay", prefix)} is missing`, "Rerun init; if it repeats, remove the runtime prefix and rerun");
|
|
163
|
+
log(`runtime: installed ${staged ? `${staged.tarballs.length} packed tarballs` : `${SERVER_PACKAGE}@${VERSION}`} into ${prefix}`);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function waitHealthy(baseUrl: string, timeoutMs: number, unit: string): Promise<void> {
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
while (Date.now() < deadline) {
|
|
170
|
+
if (await healthy(baseUrl) && await unitActive(unit)) return;
|
|
171
|
+
await Bun.sleep(1_000);
|
|
172
|
+
}
|
|
173
|
+
throw new InitStepError("health", `${unit} is not healthy at ${baseUrl}/api/health after ${Math.round(timeoutMs / 1000)} s`, `journalctl --user -u ${unit} -n 50 --no-pager`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A call to the local server with the admin token from the env file; it never leaves this machine. */
|
|
177
|
+
async function adminRequest(step: InitStep, baseUrl: string, token: string, method: string, path: string, body?: unknown): Promise<Response> {
|
|
178
|
+
try {
|
|
179
|
+
return await fetch(`${baseUrl}${path}`, {
|
|
180
|
+
method,
|
|
181
|
+
headers: { [RELAY_TOKEN_HEADER]: token, "content-type": "application/json" },
|
|
182
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
183
|
+
signal: AbortSignal.timeout(15_000),
|
|
184
|
+
});
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw new InitStepError(step, `could not reach ${baseUrl}: ${errMessage(e)}`, "Rerun init; it resumes where it stopped.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Join this machine as an orchestrator host (#2680 v1 bar): a host-scoped, single-use bootstrap
|
|
192
|
+
* token from the local server, then the runtime's own `orchestrator install`. Gate slots and the
|
|
193
|
+
* agent cap default to sizes derived from the host's CPUs and RAM.
|
|
194
|
+
*/
|
|
195
|
+
async function joinLocalHost(baseUrl: string, token: string, prefix: string, fromTarballs: boolean, log: Log): Promise<InitResult["host"]> {
|
|
196
|
+
const id = sanitizeOrchestratorId(hostname());
|
|
197
|
+
const repair = "Rerun init, or in the dashboard open Orchestrators → Add";
|
|
198
|
+
const joined = !(await unitActive(ORCHESTRATOR_UNIT));
|
|
199
|
+
if (joined) {
|
|
200
|
+
const baseDir = join(homedir(), "projects");
|
|
201
|
+
const minted = await adminRequest("host", baseUrl, token, "POST", "/api/orchestrators/bootstrap", { id, baseDir, relayUrl: baseUrl });
|
|
202
|
+
if (minted.status !== 201) throw new InitStepError("host", `host bootstrap returned ${minted.status}: ${(await minted.text()).slice(0, 200)}`, repair);
|
|
203
|
+
const { bootstrapToken } = await minted.json() as { bootstrapToken: string };
|
|
204
|
+
const install = await run([runtimeBinPath("humwick", prefix), "orchestrator", "install", "--relay-url", baseUrl, "--bootstrap-token", bootstrapToken,
|
|
205
|
+
"--id", id, "--base-dir", baseDir, "--runtime-prefix", prefix, "--yes", ...(fromTarballs ? ["--skip-install"] : ["--version", VERSION])], 10 * 60_000);
|
|
206
|
+
if (!install.ok) throw new InitStepError("host", `orchestrator install failed:\n${install.out.split("\n").slice(-20).join("\n")}`, repair);
|
|
207
|
+
}
|
|
208
|
+
const deadline = Date.now() + 120_000;
|
|
209
|
+
for (;;) {
|
|
210
|
+
const list = await (await adminRequest("host", baseUrl, token, "GET", "/api/orchestrators")).json().catch(() => []) as Array<{ id: string; status?: string }>;
|
|
211
|
+
if (list.some((h) => h.id === id && h.status === "online")) break;
|
|
212
|
+
if (Date.now() > deadline) throw new InitStepError("host", `host ${id} did not come online within 120 s`, `journalctl --user -u ${ORCHESTRATOR_UNIT} -n 50 --no-pager`);
|
|
213
|
+
await Bun.sleep(1_000);
|
|
214
|
+
}
|
|
215
|
+
log(`host: ${id} ${joined ? "joined" : "already joined"} and online`);
|
|
216
|
+
return { id, unit: ORCHESTRATOR_UNIT, joined };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function mintLoginLink(baseUrl: string, token: string): Promise<{ url: string; expiresAt?: string }> {
|
|
220
|
+
const response = await adminRequest("login", baseUrl, token, "POST", "/api/auth/login-links", {});
|
|
221
|
+
if (response.status !== 201) throw new InitStepError("login", `login link request returned ${response.status}: ${(await response.text()).slice(0, 200)}`, "Rerun init to get a fresh login link");
|
|
222
|
+
const body = await response.json() as { path: string; expiresAt?: number };
|
|
223
|
+
return { url: `${baseUrl}${body.path}`, ...(body.expiresAt ? { expiresAt: new Date(body.expiresAt * 1000).toISOString() } : {}) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Install or repair a persistent server for this user and return how to open its dashboard. */
|
|
227
|
+
export async function runInit(options: InitOptions = {}, log: Log = () => {}): Promise<InitResult> {
|
|
228
|
+
const plan = await resolveSetupPlan({
|
|
229
|
+
...(options.envFile ? { envFile: options.envFile } : {}),
|
|
230
|
+
...(options.host ? { host: options.host } : {}),
|
|
231
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
232
|
+
...(options.dbPath ? { dbPath: options.dbPath } : {}),
|
|
233
|
+
...(options.runtimePrefix ? { runtimePrefix: options.runtimePrefix } : {}),
|
|
234
|
+
});
|
|
235
|
+
const port = Number(plan.values.PORT);
|
|
236
|
+
const host = plan.values.HOST!;
|
|
237
|
+
const daemonEnv = await detectDaemonEnvironment();
|
|
238
|
+
const bunDir = dirname(Bun.which("bun") ?? process.execPath);
|
|
239
|
+
const daemon = createDaemonPlan({ action: "install", envFile: plan.envFile, runtimePrefix: plan.runtimePrefix, port, host, pathPrefix: [bunDir], enable: true, start: true }, daemonEnv);
|
|
240
|
+
const unit = `${daemon.name}.service`;
|
|
241
|
+
|
|
242
|
+
const { linger, resumed } = await preflight(port, host, unit, log);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await executeSetupPlan(plan);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
throw new InitStepError("setup", errMessage(e), `Move the file aside, or rerun with an explicit --env-file`);
|
|
248
|
+
}
|
|
249
|
+
log(`setup: ${plan.reused ? "kept" : "wrote"} ${plan.envFile} (admin token ${plan.reused ? "unchanged" : "generated"})`);
|
|
250
|
+
|
|
251
|
+
const installed = await installRuntime(plan.runtimePrefix, options.tarballDir, log);
|
|
252
|
+
|
|
253
|
+
const previousUnit = daemon.file && existsSync(daemon.file.path) ? readFileSync(daemon.file.path, "utf8") : undefined;
|
|
254
|
+
const restart = resumed && (installed || previousUnit !== daemon.file?.content);
|
|
255
|
+
try {
|
|
256
|
+
await executeDaemonPlan(daemon);
|
|
257
|
+
if (restart) {
|
|
258
|
+
const restarted = await run(["systemctl", "--user", "restart", unit], 60_000);
|
|
259
|
+
if (!restarted.ok) throw new Error(restarted.out);
|
|
260
|
+
}
|
|
261
|
+
// The server supervises a channels-host service; `daemon install` sets it up the same way.
|
|
262
|
+
log(await installChannelsHostService({ ownerServiceName: daemon.name, runtimePrefix: plan.runtimePrefix, legacyEnvFile: plan.envFile, start: true, env: daemonEnv, onlyIfConfigured: false }));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
throw new InitStepError("service", errMessage(e), `systemctl --user status ${unit} agent-relay-channels-host.service`);
|
|
265
|
+
}
|
|
266
|
+
log(`service: ${unit} enabled and ${restart ? "restarted" : resumed ? "running" : "started"}`);
|
|
267
|
+
|
|
268
|
+
const baseUrl = `http://${probeHost(host)}:${port}`;
|
|
269
|
+
await waitHealthy(baseUrl, options.healthTimeoutMs ?? 60_000, unit);
|
|
270
|
+
log(`health: ${baseUrl}/api/health ok`);
|
|
271
|
+
|
|
272
|
+
const token = setupPlanToken(plan);
|
|
273
|
+
if (!token) throw new InitStepError("setup", `${plan.envFile} has no admin token; init needs it to join this host and mint the login link`, `humwick init --env-file <a new path>, or add a token to ${plan.envFile}`);
|
|
274
|
+
const localHost = await joinLocalHost(baseUrl, token, plan.runtimePrefix, Boolean(options.tarballDir), log);
|
|
275
|
+
const login = await mintLoginLink(baseUrl, token);
|
|
276
|
+
const warnings = [...plan.warnings, ...daemon.warnings.filter((w) => !w.startsWith("Runtime binary is not installed"))];
|
|
277
|
+
if (linger !== "yes") warnings.push(`Linger is ${linger}: the service stops when you log out. Keep it running with: sudo loginctl enable-linger ${userInfo().username}`);
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
version: VERSION,
|
|
281
|
+
dashboardUrl: `${baseUrl}/`,
|
|
282
|
+
loginUrl: login.url,
|
|
283
|
+
...(login.expiresAt ? { loginExpiresAt: login.expiresAt } : {}),
|
|
284
|
+
service: { unit, kind: daemon.kind, scope: daemon.scope, ...(daemon.serviceFilePath ? { file: daemon.serviceFilePath } : {}) },
|
|
285
|
+
envFile: plan.envFile,
|
|
286
|
+
dbPath: plan.values.DB_PATH!,
|
|
287
|
+
runtimePrefix: plan.runtimePrefix,
|
|
288
|
+
runtime: { source: options.tarballDir ? "tarballs" : "registry", installed },
|
|
289
|
+
linger,
|
|
290
|
+
resumed,
|
|
291
|
+
host: localHost,
|
|
292
|
+
warnings,
|
|
293
|
+
next: [
|
|
294
|
+
`Open the login link (one use, 15 minutes) on this machine, or from yours through: ssh -L ${port}:127.0.0.1:${port} ${userInfo().username}@<this-host>`,
|
|
295
|
+
`This machine runs agents as host ${localHost.id}. Set up a provider in the dashboard, then spawn your first agent. More machines: Orchestrators → Add.`,
|
|
296
|
+
"Lost the link? Rerun init: it repairs the install and prints a fresh one, without changing the admin token.",
|
|
297
|
+
],
|
|
298
|
+
};
|
|
299
|
+
}
|
package/src/invite-link.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
// #3567 D2 — the invite link `/invite/<code>`
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// #3567 D2 — the invite link `/invite/<code>` opens the dashboard's accept screen; #2680 — the login
|
|
2
|
+
// link `/login/<code>` that `humwick init` prints opens the dashboard signed in. The code moves into
|
|
3
|
+
// the URL fragment, which browsers never send to a server or in a Referer.
|
|
4
|
+
const LINK_PATHS: Array<[RegExp, string]> = [
|
|
5
|
+
[/^\/invite\/([A-Za-z0-9_-]{16,128})$/, "invite"],
|
|
6
|
+
// A login code is a signed token (base64url segments joined by dots).
|
|
7
|
+
[/^\/login\/([A-Za-z0-9_.-]{16,2048})$/, "login"],
|
|
8
|
+
];
|
|
4
9
|
|
|
5
|
-
/** A redirect to the dashboard
|
|
6
|
-
export function
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/** A redirect to the dashboard screen for an invite or login link, else null. */
|
|
11
|
+
export function dashboardLinkRedirect(pathname: string): Response | null {
|
|
12
|
+
for (const [pattern, kind] of LINK_PATHS) {
|
|
13
|
+
const code = pattern.exec(pathname)?.[1];
|
|
14
|
+
// Relative, so a dashboard served under a reverse-proxy prefix keeps its prefix.
|
|
15
|
+
if (code) return new Response(null, { status: 302, headers: { Location: `../#${kind}=${code}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" } });
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
11
18
|
}
|
package/src/routes/auth.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { authorityDenied, recordUserManagement } from "../authority-audit";
|
|
|
3
3
|
// (src/index.ts) so they are reachable unauthenticated — each handler owns its own auth: login
|
|
4
4
|
// verifies credentials, refresh validates the refresh token, logout revokes the presented session.
|
|
5
5
|
// Everything else on the relay still flows through the existing component-token gate.
|
|
6
|
-
import { isRecord, type User } from "@humwick/sdk";
|
|
7
|
-
import { error, json, parseBody, type Handler } from "./_shared";
|
|
6
|
+
import { DEFAULT_USER_ID, isRecord, type User } from "@humwick/sdk";
|
|
7
|
+
import { error, isAdminRequest, json, parseBody, requestUserId, type Handler } from "./_shared";
|
|
8
8
|
import { getAuthProvider, listAuthProviders, mintUserSession, type MintedSession } from "../auth";
|
|
9
9
|
import { getAuthIdentity, getUser, InviteUnavailableError, ValidationError } from "../db";
|
|
10
10
|
import { revokeTokenAsync } from "../token-db-write.ts";
|
|
@@ -14,6 +14,7 @@ import { readSessionCookie, sessionOriginAllowed, withSessionCookie } from "../a
|
|
|
14
14
|
import { getComponentAuth } from "../security";
|
|
15
15
|
import { acceptInvite, userInvitesEnabled } from "../services/user-onboarding";
|
|
16
16
|
import { invitesDisabled } from "./users";
|
|
17
|
+
import { mintLoginLink, redeemLoginLink } from "../auth/login-link";
|
|
17
18
|
|
|
18
19
|
/** Public projection of a user for login/refresh responses — never leaks internal-only columns. */
|
|
19
20
|
function userView(user: User): Record<string, unknown> {
|
|
@@ -120,3 +121,30 @@ export const postAcceptInviteRoute: Handler = async (req) => {
|
|
|
120
121
|
throw e;
|
|
121
122
|
}
|
|
122
123
|
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* POST /api/auth/login-links — admin only (#2680). Body `{ userId? }`, default the seeded admin user.
|
|
127
|
+
* `humwick init` calls this with the admin token so the first login needs no token at all.
|
|
128
|
+
*/
|
|
129
|
+
export const postLoginLinkRoute: Handler = async (req) => {
|
|
130
|
+
if (!isAdminRequest(req)) return (await authorityDenied(req, "only an admin can mint a login link"));
|
|
131
|
+
const parsed = await parseBody<unknown>(req);
|
|
132
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
133
|
+
const userId = isRecord(parsed.body) && typeof parsed.body.userId === "string" ? parsed.body.userId : DEFAULT_USER_ID;
|
|
134
|
+
const user = getUser(userId);
|
|
135
|
+
if (!user || user.status !== "active") return error("user not found or not active", 404);
|
|
136
|
+
const link = await mintLoginLink(user, `auth:login-link:${requestUserId(req)}`);
|
|
137
|
+
recordUserManagement(req, user.id, "mint login link");
|
|
138
|
+
return json(link, 201);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** POST /api/auth/login-links/redeem — public; the single-use link code is the credential. */
|
|
142
|
+
export const postRedeemLoginLinkRoute: Handler = async (req) => {
|
|
143
|
+
if (!sessionOriginAllowed(req, false)) return (await authorityDenied(req, "origin not allowed", 403));
|
|
144
|
+
const parsed = await parseBody<unknown>(req);
|
|
145
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
146
|
+
const code = isRecord(parsed.body) ? parsed.body.code : undefined;
|
|
147
|
+
const user = typeof code === "string" && code ? await redeemLoginLink(code) : null;
|
|
148
|
+
if (!user) return (await authorityDenied(req, "login link is invalid, expired or already used", 401));
|
|
149
|
+
return sessionResponse(req, user, (await mintUserSession(user)));
|
|
150
|
+
};
|
|
@@ -8,6 +8,9 @@ import { error, json, parseBody, type Handler } from "./_shared";
|
|
|
8
8
|
import { getHostProviderSetting } from "../host-provider-store";
|
|
9
9
|
import { isHostSwitchableProvider, isOptInProvider, type HostProviderEnablementSetting } from "../host-provider-enablement";
|
|
10
10
|
import { setHostProviderEnabled, setHostProvidersBulk } from "../services/host-providers";
|
|
11
|
+
import { connectProviderApiKey } from "../services/provider-api-key";
|
|
12
|
+
import { VaultError } from "../secrets-vault/keys";
|
|
13
|
+
import { vaultActorOf } from "../services/auth-context";
|
|
11
14
|
import type { SpawnProvider } from "@humwick/sdk";
|
|
12
15
|
|
|
13
16
|
function hostProviderView(hostId: string, advertised: SpawnProvider[], setting: HostProviderEnablementSetting | null, catalogProviders: SpawnProvider[]) {
|
|
@@ -71,3 +74,18 @@ export const putHostProvidersBulkRoute: Handler = async (req, params) => {
|
|
|
71
74
|
throw e;
|
|
72
75
|
}
|
|
73
76
|
};
|
|
77
|
+
|
|
78
|
+
/** T7689 — first-run provider setup: the operator's key, bound on this host, with a credit window. */
|
|
79
|
+
export const postHostProviderApiKeyRoute: Handler = async (req, params) => {
|
|
80
|
+
const hostId = cleanString(params.id, "host id", { required: true, max: 120 })!;
|
|
81
|
+
const provider = cleanString(params.provider, "provider", { required: true, max: 80 })!;
|
|
82
|
+
const parsed = await parseBody<unknown>(req);
|
|
83
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
84
|
+
try {
|
|
85
|
+
return json(await connectProviderApiKey(vaultActorOf(req), hostId, provider, parsed.body, req));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
88
|
+
if (e instanceof VaultError) return error(e.message, 503);
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
};
|
package/src/routes/index.ts
CHANGED
|
@@ -44,11 +44,11 @@ import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute,
|
|
|
44
44
|
import { postAgentAreaEntered } from "./memory";
|
|
45
45
|
import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
|
|
46
46
|
import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
|
|
47
|
-
import { getHostProvidersRoute, putHostProviderRoute, putHostProvidersBulkRoute } from "./host-providers";
|
|
47
|
+
import { getHostProvidersRoute, postHostProviderApiKeyRoute, putHostProviderRoute, putHostProvidersBulkRoute } from "./host-providers";
|
|
48
48
|
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyDiagnostics, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
49
49
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRemint, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
50
50
|
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
51
|
-
import { getAuthProvidersRoute, postAcceptInviteRoute, postAuthLoginRoute, postAuthLogoutRoute, postAuthRefreshRoute } from "./auth";
|
|
51
|
+
import { getAuthProvidersRoute, postAcceptInviteRoute, postAuthLoginRoute, postAuthLogoutRoute, postAuthRefreshRoute, postLoginLinkRoute, postRedeemLoginLinkRoute } from "./auth";
|
|
52
52
|
import { getActivityEvents, postActivityEvent } from "./activity";
|
|
53
53
|
import { getAnalyticsRoute, getDeliveryQualityRoute, getHealthRoute, getHubMetricsRoute, getLeadTimeRoute, getMaintenanceJobs, getMaintenancePauses, getMaintenanceRunning, getStatsRoute, postMaintenanceJobCancel, postMaintenanceJobPause, postMaintenanceJobResume, postMaintenanceJobRun, postSystemReap } from "./stats";
|
|
54
54
|
import { getApiDocs, getApiSpec } from "./spec";
|
|
@@ -103,6 +103,8 @@ const routes: Route[] = [
|
|
|
103
103
|
route("POST", "/api/auth/refresh", postAuthRefreshRoute),
|
|
104
104
|
route("POST", "/api/auth/logout", postAuthLogoutRoute),
|
|
105
105
|
route("POST", "/api/auth/invites/accept", postAcceptInviteRoute),
|
|
106
|
+
route("POST", "/api/auth/login-links", postLoginLinkRoute),
|
|
107
|
+
route("POST", "/api/auth/login-links/redeem", postRedeemLoginLinkRoute),
|
|
106
108
|
route("POST", "/api/users/invites", postUserInvite),
|
|
107
109
|
route("PUT", "/api/users/me/password", putMyPassword),
|
|
108
110
|
|
|
@@ -228,6 +230,7 @@ const routes: Route[] = [
|
|
|
228
230
|
route("GET", "/api/orchestrators/:id/host-providers", getHostProvidersRoute),
|
|
229
231
|
route("PUT", "/api/orchestrators/:id/host-providers", putHostProvidersBulkRoute),
|
|
230
232
|
route("PUT", "/api/orchestrators/:id/host-providers/:provider", putHostProviderRoute),
|
|
233
|
+
route("POST", "/api/orchestrators/:id/host-providers/:provider/api-key", postHostProviderApiKeyRoute),
|
|
231
234
|
route("GET", "/api/orchestrators/:id/sessions", getOrchestratorSessions),
|
|
232
235
|
route("GET", "/api/orchestrators/:id/version", getOrchestratorVersion),
|
|
233
236
|
route("GET", "/api/orchestrators/:id/logs/:session", getOrchestratorLogs),
|
|
@@ -61,6 +61,8 @@ export const postOrchestratorBootstrap: Handler = async (req) => {
|
|
|
61
61
|
version,
|
|
62
62
|
pathPrefix: pathPrefix ?? [],
|
|
63
63
|
token: bootstrapToken.record,
|
|
64
|
+
// The single-use credential itself, for a caller that runs `orchestrator install` without the shell command (`humwick init`, #2680).
|
|
65
|
+
bootstrapToken: bootstrapToken.token,
|
|
64
66
|
expiresAt: bootstrapToken.record.expiresAt,
|
|
65
67
|
}, 201);
|
|
66
68
|
} catch (e) {
|
|
@@ -2,7 +2,7 @@ import { isRecord } from "@humwick/sdk";
|
|
|
2
2
|
import { ValidationError } from "../db/connection";
|
|
3
3
|
import { readBodyBytes } from "../http-body";
|
|
4
4
|
import { getComponentAuth } from "../security";
|
|
5
|
-
import {
|
|
5
|
+
import { vaultActorOf } from "../services/auth-context";
|
|
6
6
|
import { exportVaultBackup, vaultBackupStatus, writeVaultBackup } from "../secrets-vault/backup";
|
|
7
7
|
import { VaultError } from "../secrets-vault/keys";
|
|
8
8
|
import { restoreVaultBackup } from "../secrets-vault/restore";
|
|
@@ -30,8 +30,7 @@ function operatorRoute(handle: (body: Record<string, unknown>, actor: string) =>
|
|
|
30
30
|
try { body = read.bytes.byteLength ? JSON.parse(new TextDecoder().decode(read.bytes)) : {}; } catch { return error("invalid JSON body", 400); }
|
|
31
31
|
}
|
|
32
32
|
if (!isRecord(body)) return error("body must be an object", 400);
|
|
33
|
-
|
|
34
|
-
try { return await handle(body, ctx.actor.kind === "system" ? "operator" : ctx.actor.id); }
|
|
33
|
+
try { return await handle(body, vaultActorOf(req)); }
|
|
35
34
|
catch (cause) { return backupError(cause); }
|
|
36
35
|
};
|
|
37
36
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ValidationError } from "../db/connection";
|
|
2
2
|
import { isRecord } from "@humwick/sdk";
|
|
3
3
|
import { getComponentAuth } from "../security";
|
|
4
|
-
import {
|
|
4
|
+
import { vaultActorOf } from "../services/auth-context";
|
|
5
5
|
import { importVaultSecrets, previewVaultImport } from "../secrets-vault/import";
|
|
6
6
|
import { VaultError } from "../secrets-vault/keys";
|
|
7
7
|
import { SecretsBrokerError } from "../secrets-broker/error";
|
|
@@ -16,9 +16,8 @@ function importRoute(preview: boolean): Handler {
|
|
|
16
16
|
const parsed = await parseBody<unknown>(req);
|
|
17
17
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
18
18
|
if (!isRecord(parsed.body)) return error("import must be an object", 400);
|
|
19
|
-
const ctx = authContextFromRequest(req);
|
|
20
19
|
try {
|
|
21
|
-
return json(preview ? await previewVaultImport(parsed.body.source) : await importVaultSecrets(
|
|
20
|
+
return json(preview ? await previewVaultImport(parsed.body.source) : await importVaultSecrets(vaultActorOf(req), parsed.body), preview ? 200 : 201);
|
|
22
21
|
} catch (cause) {
|
|
23
22
|
if (cause instanceof ValidationError) return error(cause.message, 400);
|
|
24
23
|
if (cause instanceof VaultError) return error(cause.message, cause.code === "exists" ? 409 : cause.code === "not_found" ? 404 : 503);
|
|
@@ -2,7 +2,7 @@ import { listSecretAuditAsync } from "../db/secrets-broker-async";
|
|
|
2
2
|
import { parseSecretName, scopedSecretName, secretEnvironment } from "../secrets-vault/scope";
|
|
3
3
|
import { builtinVault, backendCall, readVaultLastResolved } from "../secrets-vault/backend";
|
|
4
4
|
import { editSecretMetadata, getSecretMetadata, createOperatorSecret, deleteOperatorSecret, requireVault, rotateOperatorSecret } from "../secrets-vault/service";
|
|
5
|
-
import {
|
|
5
|
+
import { vaultActorOf } from "../services/auth-context";
|
|
6
6
|
import { ValidationError } from "../db/connection";
|
|
7
7
|
import { resolveProjectAsync } from "../db/projects-async";
|
|
8
8
|
import { isRecord } from "@humwick/sdk";
|
|
@@ -39,8 +39,6 @@ export const getVaultMetadataRoute: Handler = async (req) => {
|
|
|
39
39
|
} catch (cause) { return error(cause instanceof Error ? cause.message : "vault unavailable", cause instanceof ValidationError ? 400 : 503); }
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const operatorActor = (req: Request) => { const ctx = authContextFromRequest(req); return ctx.actor.kind === "system" ? "operator" : ctx.actor.id; };
|
|
43
|
-
|
|
44
42
|
function vaultWriteError(cause: unknown): Response {
|
|
45
43
|
if (cause instanceof ValidationError) return error(cause.message, 400);
|
|
46
44
|
if (cause instanceof VaultError) return error(cause.message, cause.code === "exists" ? 409 : cause.code === "not_found" ? 404 : 503);
|
|
@@ -54,7 +52,7 @@ export const postVaultSecretRoute: Handler = async (req) => {
|
|
|
54
52
|
const parsed = await parseBody<unknown>(req);
|
|
55
53
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
56
54
|
if (!isRecord(parsed.body)) return error("secret must be an object", 400);
|
|
57
|
-
try { return json(await createOperatorSecret(
|
|
55
|
+
try { return json(await createOperatorSecret(vaultActorOf(req), parsed.body), 201); }
|
|
58
56
|
catch (cause) { return vaultWriteError(cause); }
|
|
59
57
|
};
|
|
60
58
|
|
|
@@ -66,7 +64,7 @@ export const putVaultSecretRoute: Handler = async (req, params) => {
|
|
|
66
64
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
67
65
|
if (!isRecord(parsed.body)) return error("secret must be an object", 400);
|
|
68
66
|
const environment = parsed.body.environment === undefined ? new URL(req.url).searchParams.get("environment") ?? undefined : parsed.body.environment;
|
|
69
|
-
try { return json(await rotateOperatorSecret(
|
|
67
|
+
try { return json(await rotateOperatorSecret(vaultActorOf(req), params.name!, { ...parsed.body, environment })); }
|
|
70
68
|
catch (cause) { return vaultWriteError(cause); }
|
|
71
69
|
};
|
|
72
70
|
|
|
@@ -74,7 +72,7 @@ export const putVaultSecretRoute: Handler = async (req, params) => {
|
|
|
74
72
|
export const deleteVaultSecretRoute: Handler = async (req, params) => {
|
|
75
73
|
const denied = (await authorizeRoute(req, { scope: "secrets:write" }));
|
|
76
74
|
if (denied) return denied;
|
|
77
|
-
try { return json(await deleteOperatorSecret(
|
|
75
|
+
try { return json(await deleteOperatorSecret(vaultActorOf(req), params.name!, new URL(req.url).searchParams.get("environment") ?? undefined)); }
|
|
78
76
|
catch (cause) { return vaultWriteError(cause); }
|
|
79
77
|
};
|
|
80
78
|
|
|
@@ -116,6 +114,6 @@ export const patchVaultMetadataRoute: Handler = async (req, params) => {
|
|
|
116
114
|
if (denied) return denied;
|
|
117
115
|
const parsed = await parseBody<unknown>(req);
|
|
118
116
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
119
|
-
try { return json(await editSecretMetadata(
|
|
117
|
+
try { return json(await editSecretMetadata(vaultActorOf(req), params.name!, parsed.body, new URL(req.url).searchParams.get("environment") ?? undefined)); }
|
|
120
118
|
catch (cause) { return vaultWriteError(cause); }
|
|
121
119
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Install Humwick packages into a runtime prefix: from the registry, or from packed tarballs
|
|
2
|
+
// (`dev install`, and `init --tarball-dir` while the registry set is broken, #2680/#2324).
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
const SERVER_TARBALL = /^humwick-server-.*\.tgz$/;
|
|
11
|
+
|
|
12
|
+
/** `npm install` into `prefix`; bounded, and the error carries npm's output. */
|
|
13
|
+
export async function npmInstallIntoPrefix(prefix: string, specs: string[]): Promise<string> {
|
|
14
|
+
mkdirSync(prefix, { recursive: true });
|
|
15
|
+
const manifest = join(prefix, "package.json");
|
|
16
|
+
if (!existsSync(manifest)) writeFileSync(manifest, JSON.stringify({ private: true, name: "humwick-runtime" }, null, 2) + "\n", "utf8");
|
|
17
|
+
const args = ["install", "--prefix", prefix, "--no-audit", "--no-fund", "--force", ...specs];
|
|
18
|
+
try {
|
|
19
|
+
const result = await execFileAsync("npm", args, { cwd: prefix, timeout: INSTALL_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
} catch (e) {
|
|
22
|
+
const err = e as Error & { stdout?: string; stderr?: string; killed?: boolean };
|
|
23
|
+
const tail = `${err.stdout ?? ""}\n${err.stderr ?? ""}`.trim().split("\n").slice(-20).join("\n");
|
|
24
|
+
throw new Error(`npm ${args.slice(0, 6).join(" ")} … failed${err.killed ? ` (timed out after ${INSTALL_TIMEOUT_MS / 1000} s)` : ""}:\n${tail || err.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isServerTarball(name: string): boolean {
|
|
29
|
+
return SERVER_TARBALL.test(name);
|
|
30
|
+
}
|