@netnodeag/kraftwerk 0.46.2 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,6 +164,8 @@ kraftwerk create "was der Workflow tun soll" # for LLM agents: prints a build
164
164
  kraftwerk runner build # build the Docker sandbox image (once)
165
165
  kraftwerk run --sandbox website-check "https://..." # isolated container per run; --ssh forwards the agent
166
166
  kraftwerk runner ps / stop <run-id> # see / stop running sandbox containers
167
+ kraftwerk tunnel setup kw.example.com # Cloudflare Tunnel to the inspector: login, create, route dns, kraftwerk.yml
168
+ kraftwerk tunnel # run that tunnel alone (the UI runs elsewhere)
167
169
  ```
168
170
 
169
171
  The inspector binds `127.0.0.1` — it has no authentication of its own and
@@ -177,8 +179,9 @@ overrides the default either way. A reverse proxy in front of it must set
177
179
  `X-Forwarded-Host` to the host the browser addressed (Caddy and traefik do
178
180
  by default; nginx needs `proxy_set_header X-Forwarded-Host $host;`): the
179
181
  loopback bind answers a non-loopback `Host` only when that header is
180
- present, and state-changing requests are refused when `Origin` names a
181
- different host.
182
+ present or the name is the project's `public:` hostname, and state-changing
183
+ requests are refused when `Origin` names a different host. See
184
+ [Inspector through a Cloudflare Tunnel](#inspector-through-a-cloudflare-tunnel).
182
185
 
183
186
  ### The kraftwerk.yml project config
184
187
 
@@ -187,6 +190,8 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
187
190
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
188
191
  (default `knowledge/`), and `agents:` the agent-definition root (default
189
192
  `agents/`). `repos:` turns on the [repositories](#repositories) folder.
193
+ `public:` and `tunnel:` expose the inspector through a
194
+ [Cloudflare Tunnel](#inspector-through-a-cloudflare-tunnel).
190
195
 
191
196
  `switcher:` links other kraftwerk workspaces from the inspector header. The
192
197
  workspace name becomes a dropdown listing them:
@@ -283,6 +288,111 @@ repo. Agent logins made inside the container persist in the `agent-home`
283
288
  volume. This is a different image from the `kraftwerk-runner` sandbox
284
289
  (`runner/Dockerfile`) used by `run --sandbox`.
285
290
 
291
+ ### Inspector through a Cloudflare Tunnel
292
+
293
+ The other way to reach a laptop's or server's inspector from elsewhere: no
294
+ open port, no reverse proxy, no certificate. `kraftwerk ui` runs
295
+ [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)
296
+ next to the inspector and the loopback bind becomes reachable at a hostname
297
+ on a domain you have on Cloudflare.
298
+
299
+ You need a Cloudflare account with a domain added to it (the free plan is
300
+ enough), and cloudflared on the machine:
301
+
302
+ ```bash
303
+ brew install cloudflared # macOS; Linux packages: developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
304
+ ```
305
+
306
+ **1. Create the tunnel and route a hostname to it.** From the project root:
307
+
308
+ ```bash
309
+ kraftwerk tunnel setup kw.example.com
310
+ ```
311
+
312
+ This opens the browser for `cloudflared tunnel login` when there is no
313
+ certificate yet (pick the zone `kw.example.com` belongs to), creates a
314
+ tunnel named `kraftwerk-<project name>` (or `--name`), adds the CNAME from
315
+ the hostname to it, and writes the result into kraftwerk.yml:
316
+
317
+ ```yaml
318
+ public: https://kw.example.com # the hostname the tunnel routes to
319
+ tunnel:
320
+ name: kraftwerk-agent-playground
321
+ ```
322
+
323
+ A second run reuses the login and the tunnel. If the hostname already has a
324
+ DNS record, setup says so and `--overwrite-dns` replaces it. If cloudflared
325
+ quietly routed `kw.example.com.other-zone.com` instead, because the hostname
326
+ is outside the zone you logged in to, setup refuses and names the stray
327
+ record to delete before you log in to the right zone and run it again.
328
+
329
+ **2. Put a Cloudflare Access policy on the hostname.** The UI has no login
330
+ of its own and its chat runs coding agents against the workspace, so the
331
+ tunnel must never be the only thing between the internet and it. In the
332
+ [Zero Trust dashboard](https://one.dash.cloudflare.com/) go to Access →
333
+ Applications → Add an application → Self-hosted, enter `kw.example.com` as
334
+ the domain, and add a policy: emails ending in your domain with a one-time
335
+ PIN, a Google or GitHub login, whatever fits. Access is free for up to 50
336
+ users. From then on Cloudflare shows a login page before anything reaches
337
+ the tunnel.
338
+
339
+ **3. Let the inspector verify that login.** On the application's overview
340
+ page copy the Application Audience (AUD) tag, and take your team name from
341
+ the team domain `https://<team>.cloudflareaccess.com`:
342
+
343
+ ```yaml
344
+ tunnel:
345
+ name: kraftwerk-agent-playground
346
+ access:
347
+ team: my-team
348
+ aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
349
+ ```
350
+
351
+ With this block the inspector checks the `Cf-Access-Jwt-Assertion` token
352
+ Access adds to every request, against the team's public keys, the audience
353
+ tag and the clock. A removed or misconfigured policy then fails closed with
354
+ a 401 instead of exposing the UI. Skipping the block works, and
355
+ `kraftwerk doctor` warns about it every time.
356
+
357
+ **4. Start.** `kraftwerk ui` now starts cloudflared with the inspector,
358
+ keeps it across UI restarts and stops it with the UI:
359
+
360
+ ```
361
+ ✔ Kraftwerk UI: http://localhost:1981
362
+ ✔ Public URL: https://kw.example.com
363
+ ↗ tunnel: running kraftwerk-agent-playground → https://kw.example.com → http://127.0.0.1:1981
364
+ cloudflared │ ... Registered tunnel connection ...
365
+ ```
366
+
367
+ Open `https://kw.example.com` from anywhere, log in through Access, and you
368
+ are in the same inspector. `localhost:1981` on the machine itself keeps
369
+ working without a login. `kraftwerk doctor` reports the tunnel, the Access
370
+ block and whether cloudflared is installed.
371
+
372
+ **Variants.** `kraftwerk tunnel` runs the configured tunnel alone, for an
373
+ inspector that already runs elsewhere (started by `kraftwerk projects
374
+ start`, or in a container). For a tunnel created in the Zero Trust
375
+ dashboard instead (Networks → Tunnels), leave `name` out, route the
376
+ hostname to `http://localhost:<port>` there and export its token as
377
+ `TUNNEL_TOKEN` before `kraftwerk ui`. `public:` on its own, without
378
+ `tunnel:`, is for any other proxy that forwards the browser's `Host`
379
+ without setting `X-Forwarded-Host`.
380
+
381
+ **Troubleshooting.** cloudflared's lines appear prefixed with
382
+ `cloudflared │`. "Cannot determine default origin certificate path" means no
383
+ login on this machine: run `cloudflared tunnel login` or setup again. "tunnel
384
+ not found" means the name in kraftwerk.yml does not exist in the account
385
+ that logged in; `cloudflared tunnel list` shows what does. A tunnel that
386
+ dies three times right after launch is given up and the UI stays local
387
+ until the next start. A 421 "unexpected Host header" in the browser means
388
+ `public:` does not match the hostname you opened. A 401 means Access is
389
+ configured in kraftwerk.yml but the token is missing or wrong: the
390
+ hostname has no Access application, or `team`/`aud` do not match it.
391
+
392
+ Quick tunnels (`trycloudflare.com`) are deliberately not supported: Access
393
+ cannot be attached to them, which would leave the UI open to anyone with
394
+ the URL.
395
+
286
396
  ## Persistent agents
287
397
 
288
398
  The inspector's "agents" screen turns chat agents into persistent teammates. An
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
- import { ignoreEntryFor, isDir, reposRootFor, resolveProject } from "../config.js";
5
+ import { ignoreEntryFor, isDir, publicHostFor, reposRootFor, resolveProject, tunnelFor } from "../config.js";
6
6
  import { discoverWorkflows } from "../discover.js";
7
7
  import { missingEnv } from "../yaml.js";
8
8
  const ICONS = {
@@ -123,6 +123,33 @@ export async function runDoctor(cwd) {
123
123
  else
124
124
  report("ok", label, "not inside a git repository");
125
125
  }
126
+ // Public hostname + Cloudflare Tunnel. The UI has no login of its own, so
127
+ // a tunnel without Access verification is worth a warning every time.
128
+ const publicHost = publicHostFor(project);
129
+ const tunnel = tunnelFor(project);
130
+ if (publicHost && !tunnel)
131
+ report("info", `public: ${publicHost}`, "served when a tunnel or reverse proxy delivers that Host");
132
+ if (tunnel) {
133
+ const cloudflared = cliVersion("cloudflared");
134
+ if (cloudflared)
135
+ report("ok", `cloudflared`, cloudflared);
136
+ else {
137
+ report("fail", "cloudflared missing", "needed by tunnel: — brew install cloudflared (or see developers.cloudflare.com)");
138
+ failures++;
139
+ }
140
+ if (tunnel.name)
141
+ report("ok", `tunnel: ${tunnel.name} → ${publicHost}`, `cloudflared tunnel run --url http://127.0.0.1:${project.config.port ?? 1981} ${tunnel.name}`);
142
+ else if (process.env.TUNNEL_TOKEN)
143
+ report("ok", `tunnel: dashboard-managed → ${publicHost}`, "TUNNEL_TOKEN is set");
144
+ else {
145
+ report("fail", "tunnel: nothing to run", "set tunnel.name (locally-managed) or the TUNNEL_TOKEN env var (dashboard-managed)");
146
+ failures++;
147
+ }
148
+ if (tunnel.access)
149
+ report("ok", "tunnel.access", `tokens verified against ${tunnel.access.team}.cloudflareaccess.com`);
150
+ else
151
+ report("warn", "tunnel without access", "the UI has no login of its own — put a Cloudflare Access policy on the hostname and set tunnel.access so a removed policy fails closed");
152
+ }
126
153
  const found = project.workflowsRoot ? await discoverWorkflows(cwd) : [];
127
154
  if (!project.workflowsRoot) {
128
155
  report("warn", "no workflows root", "expected src/workflows/ or workflows/ — `kraftwerk init` scaffolds one");
package/dist/cli/init.js CHANGED
@@ -25,6 +25,12 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
25
25
  # repos: # git repositories the agents work on (uncomment both lines to enable)
26
26
  # root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
27
27
  # vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
28
+ # public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
29
+ # tunnel: # Cloudflare Tunnel run by \`kraftwerk ui\` (needs public:; put a Cloudflare Access policy on the hostname — the UI has no login of its own)
30
+ # name: kraftwerk # locally-managed tunnel (cloudflared tunnel create/route dns); omit and export TUNNEL_TOKEN for a dashboard-managed one
31
+ # access: # verify the Access login on every request via public: (team = Zero Trust team name, aud = the application's audience tag)
32
+ # team: my-team
33
+ # aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
28
34
  `;
29
35
  const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
30
36
  name: hello
@@ -15,6 +15,7 @@ import { registerKnowledgeCommands } from "./knowledge.js";
15
15
  import { registerProjectCommands } from "./projects.js";
16
16
  import { registerRepoCommands } from "./repos.js";
17
17
  import { registerVibeableCommands } from "./vibeables.js";
18
+ import { registerTunnelCommands } from "./tunnel.js";
18
19
  import { registerRoutineCommands } from "./routines.js";
19
20
  import { listRuns, showRun } from "./runs.js";
20
21
  import { runUi } from "./ui.js";
@@ -30,6 +31,7 @@ import { runUi } from "./ui.js";
30
31
  * kraftwerk ui start the inspector web UI (localhost:1981)
31
32
  * kraftwerk projects ... known projects on this machine (list/start/forget)
32
33
  * kraftwerk repos ... repositories the agents work on (list/add/update/remove)
34
+ * kraftwerk tunnel [setup <host>] Cloudflare Tunnel to the inspector: run it alone, or set one up
33
35
  * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
34
36
  * kraftwerk validate [paths...] validate without executing
35
37
  *
@@ -270,6 +272,7 @@ registerRoutineCommands(program);
270
272
  registerProjectCommands(program);
271
273
  registerRepoCommands(program);
272
274
  registerVibeableCommands(program);
275
+ registerTunnelCommands(program);
273
276
  program
274
277
  .command("ui")
275
278
  .description("Start the inspector web UI for this project's runs and workflows")
@@ -0,0 +1,10 @@
1
+ import type { Command } from "commander";
2
+ import { type Project } from "../config.js";
3
+ export interface RunningTunnel {
4
+ stop(): void;
5
+ /** Settles when the tunnel was stopped, or gave up on a cloudflared that keeps dying. */
6
+ done: Promise<"stopped" | "failed">;
7
+ }
8
+ /** Start cloudflared for the project's tunnel, or return undefined (nothing to run, or told why not). */
9
+ export declare function startTunnel(project: Project, port: number): RunningTunnel | undefined;
10
+ export declare function registerTunnelCommands(program: Command): void;
@@ -0,0 +1,280 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import http from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import chalk from "chalk";
8
+ import { parseDocument } from "yaml";
9
+ import { parsePublic, publicHostFor, resolveProject, tunnelFor } from "../config.js";
10
+ /**
11
+ * Cloudflare Tunnel next to the inspector. `kraftwerk ui` runs cloudflared
12
+ * as a sibling of the server so the loopback bind is reachable at the
13
+ * project's public hostname without opening a port. The tunnel itself is
14
+ * created once, by `kraftwerk tunnel setup` or by hand:
15
+ *
16
+ * locally-managed cloudflared tunnel login
17
+ * cloudflared tunnel create kraftwerk
18
+ * cloudflared tunnel route dns kraftwerk kw.example.com
19
+ * → tunnel: { name: kraftwerk }; everything routes to the inspector port
20
+ * dashboard-managed create the tunnel in Zero Trust → Networks → Tunnels, route the
21
+ * public hostname to http://localhost:<port> there, export TUNNEL_TOKEN
22
+ * → tunnel: {} ; cloudflared reads the token from the environment
23
+ *
24
+ * cloudflared reconnects on its own; this only respawns it when the process
25
+ * itself dies, after a pause. A process that keeps dying right after launch
26
+ * (no login, unknown tunnel name) is a configuration problem: after a few
27
+ * such exits the tunnel gives up and says so, and the UI stays local.
28
+ *
29
+ * kraftwerk tunnel run the configured tunnel alone (the UI runs elsewhere)
30
+ * kraftwerk tunnel setup <hostname> login, create, route dns, write public: + tunnel.name
31
+ */
32
+ const RESPAWN_DELAY = 5_000;
33
+ /** An exit this soon after launch is a configuration problem, not a hiccup. */
34
+ const FAST_EXIT = 10_000;
35
+ const MAX_FAST_EXITS = 3;
36
+ /** Start cloudflared for the project's tunnel, or return undefined (nothing to run, or told why not). */
37
+ export function startTunnel(project, port) {
38
+ const tunnel = tunnelFor(project);
39
+ if (!tunnel)
40
+ return undefined;
41
+ const host = publicHostFor(project);
42
+ let args;
43
+ if (tunnel.name) {
44
+ args = ["tunnel", "--no-autoupdate", "run", "--url", `http://127.0.0.1:${port}`, tunnel.name];
45
+ }
46
+ else if (process.env.TUNNEL_TOKEN) {
47
+ args = ["tunnel", "--no-autoupdate", "run"];
48
+ }
49
+ else {
50
+ console.error(`${chalk.red("✖")} tunnel: nothing to run — set ${chalk.bold("tunnel.name")} in kraftwerk.yml (locally-managed) ` +
51
+ `or the ${chalk.bold("TUNNEL_TOKEN")} environment variable (dashboard-managed). The UI stays local.`);
52
+ return undefined;
53
+ }
54
+ let child;
55
+ let stopped = false;
56
+ let timer;
57
+ let fastExits = 0;
58
+ let settle = () => { };
59
+ const done = new Promise((resolve) => (settle = resolve));
60
+ const launch = () => {
61
+ const startedAt = Date.now();
62
+ console.log(`${chalk.dim("↗")} tunnel: ${tunnel.name ? `running ${chalk.bold(tunnel.name)}` : "running the dashboard-managed tunnel"}` +
63
+ chalk.dim(` → https://${host} → http://127.0.0.1:${port}`));
64
+ child = spawn("cloudflared", args, { stdio: ["ignore", "pipe", "pipe"] });
65
+ // cloudflared logs on stderr, one line per event; keep them recognisable.
66
+ const relay = (chunk) => {
67
+ for (const line of chunk.toString().split("\n"))
68
+ if (line.trim())
69
+ console.log(chalk.dim(` cloudflared │ ${line}`));
70
+ };
71
+ child.stdout?.on("data", relay);
72
+ child.stderr?.on("data", relay);
73
+ child.once("error", (err) => {
74
+ stopped = true;
75
+ if (err.code === "ENOENT") {
76
+ console.error(`${chalk.red("✖")} tunnel: cloudflared not found — brew install cloudflared (or see developers.cloudflare.com). The UI stays local.`);
77
+ }
78
+ else {
79
+ console.error(`${chalk.red("✖")} tunnel: cannot start cloudflared: ${err.message}`);
80
+ }
81
+ settle("failed");
82
+ });
83
+ // "close", not "exit": the last log lines are still in flight when the
84
+ // process ends, and they are what explains a failed launch.
85
+ child.once("close", (code, signal) => {
86
+ child = undefined;
87
+ if (stopped)
88
+ return settle("stopped");
89
+ fastExits = Date.now() - startedAt < FAST_EXIT ? fastExits + 1 : 0;
90
+ if (fastExits >= MAX_FAST_EXITS) {
91
+ console.error(`${chalk.red("✖")} tunnel: cloudflared exited ${MAX_FAST_EXITS} times right after launch — giving up. ` +
92
+ `Check the messages above (cloudflared tunnel login? does the tunnel exist?), then restart the UI. The UI stays local.`);
93
+ return settle("failed");
94
+ }
95
+ console.error(`${chalk.red("✖")} tunnel: cloudflared exited (${signal ?? `code ${code}`}) — retrying in ${RESPAWN_DELAY / 1000}s`);
96
+ timer = setTimeout(launch, RESPAWN_DELAY);
97
+ });
98
+ };
99
+ launch();
100
+ return {
101
+ done,
102
+ stop() {
103
+ stopped = true;
104
+ if (timer)
105
+ clearTimeout(timer);
106
+ if (child)
107
+ child.kill("SIGTERM");
108
+ else
109
+ settle("stopped");
110
+ },
111
+ };
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // CLI
115
+ const die = (msg, code = 1) => {
116
+ console.error(chalk.red(msg));
117
+ process.exit(code);
118
+ };
119
+ /** cloudflared's version line, or undefined when the binary is not on PATH. */
120
+ function cloudflaredVersion() {
121
+ const r = spawnSync("cloudflared", ["--version"], { encoding: "utf8", timeout: 10_000 });
122
+ if (r.error || r.status !== 0)
123
+ return undefined;
124
+ return (r.stdout || r.stderr).trim().split("\n")[0];
125
+ }
126
+ /** Run one cloudflared command to completion, capturing its output (stderr carries the log lines). */
127
+ function cloudflared(args) {
128
+ const r = spawnSync("cloudflared", args, { encoding: "utf8", timeout: 120_000, stdio: ["inherit", "pipe", "pipe"] });
129
+ return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
130
+ }
131
+ /** Whether something already answers on the inspector port (so the tunnel has an origin). */
132
+ function inspectorListening(port) {
133
+ return new Promise((resolve) => {
134
+ const req = http.get({ host: "127.0.0.1", port, path: "/api/meta?probe=1", timeout: 1_500 }, (res) => {
135
+ res.resume();
136
+ resolve(true);
137
+ });
138
+ req.on("error", () => resolve(false));
139
+ req.on("timeout", () => {
140
+ req.destroy();
141
+ resolve(false);
142
+ });
143
+ });
144
+ }
145
+ /** A tunnel name from the project's display name or folder: "kraftwerk-agent-playground". */
146
+ function defaultTunnelName(project) {
147
+ const base = (project.config.name ?? path.basename(project.root)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
148
+ return `kraftwerk-${base || "inspector"}`;
149
+ }
150
+ /** The origin certificate `cloudflared tunnel login` writes; its presence means an authorized zone. */
151
+ function originCertPath() {
152
+ return process.env.TUNNEL_ORIGIN_CERT || path.join(os.homedir(), ".cloudflared", "cert.pem");
153
+ }
154
+ /** Write public: and tunnel.name into kraftwerk.yml, keeping everything else (comments, an access block) as it is. */
155
+ async function writeTunnelConfig(project, publicUrl, name) {
156
+ const configPath = project.configPath;
157
+ const doc = parseDocument(await readFile(configPath, "utf8"));
158
+ if (doc.errors.length > 0)
159
+ throw new Error(`${path.basename(configPath)}: ${doc.errors[0].message}`);
160
+ doc.set("public", publicUrl);
161
+ const tunnel = doc.get("tunnel");
162
+ if (tunnel && typeof tunnel === "object") {
163
+ doc.setIn(["tunnel", "name"], name);
164
+ doc.deleteIn(["tunnel", "enabled"]);
165
+ }
166
+ else {
167
+ doc.set("tunnel", { name });
168
+ }
169
+ await writeFile(configPath, doc.toString());
170
+ return configPath;
171
+ }
172
+ export function registerTunnelCommands(program) {
173
+ const tunnel = program
174
+ .command("tunnel")
175
+ .description("Cloudflare Tunnel to the inspector: run the configured one alone, or set one up");
176
+ tunnel
177
+ .command("run", { isDefault: true })
178
+ .description("Run the project's tunnel in the foreground (the inspector runs separately, e.g. `kraftwerk ui` elsewhere)")
179
+ .option("--port <port>", "Inspector port to route to (default: kraftwerk.yml `port`, else 1981)")
180
+ .action(async (opts) => {
181
+ const project = await resolveProject(process.cwd()).catch((err) => die(err.message, 2));
182
+ if (!tunnelFor(project)) {
183
+ die("tunnel is off — `kraftwerk tunnel setup <hostname>` creates one, or add `public:` and `tunnel:` to kraftwerk.yml (see kraftwerk doctor).");
184
+ }
185
+ const port = opts.port ? Number(opts.port) : (project.config.port ?? 1981);
186
+ if (!Number.isInteger(port) || port <= 0)
187
+ die("--port must be a port number", 2);
188
+ if (!(await inspectorListening(port))) {
189
+ console.log(chalk.yellow(`⚠ nothing answers on http://127.0.0.1:${port} yet — start \`kraftwerk ui\`; the tunnel serves errors until then.`));
190
+ }
191
+ const running = startTunnel(project, port);
192
+ if (!running)
193
+ process.exit(1);
194
+ for (const sig of ["SIGINT", "SIGTERM"])
195
+ process.once(sig, () => running.stop());
196
+ const outcome = await running.done;
197
+ process.exit(outcome === "failed" ? 1 : 0);
198
+ });
199
+ tunnel
200
+ .command("setup <hostname>")
201
+ .description("Create a locally-managed tunnel for this project and route the hostname to it (login, create, route dns, kraftwerk.yml)")
202
+ .option("--name <name>", "Tunnel name (default: kraftwerk-<project name>)")
203
+ .option("--overwrite-dns", "Replace an existing DNS record for the hostname")
204
+ .action(async (hostname, opts) => {
205
+ const host = parsePublic(hostname)?.hostname ?? die(`"${hostname}" is not a hostname — expected something like kw.example.com`, 2);
206
+ const project = await resolveProject(process.cwd()).catch((err) => die(err.message, 2));
207
+ if (!project.configPath)
208
+ die("no kraftwerk.yml here — `kraftwerk init` scaffolds one, then run setup again.", 2);
209
+ const name = opts.name ?? defaultTunnelName(project);
210
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
211
+ die(`"${name}" is not a plain tunnel name (letters, digits, ".", "_", "-")`, 2);
212
+ const version = cloudflaredVersion();
213
+ if (!version)
214
+ die("cloudflared not found — brew install cloudflared (or see developers.cloudflare.com), then run setup again.");
215
+ console.log(`${chalk.green("✔")} ${version}`);
216
+ // 1. Login: one authorized zone at a time, stored as cert.pem.
217
+ if (existsSync(originCertPath())) {
218
+ console.log(`${chalk.green("✔")} logged in to Cloudflare ${chalk.dim(`(${originCertPath()})`)}`);
219
+ }
220
+ else {
221
+ console.log(`${chalk.dim("↗")} cloudflared tunnel login — pick the zone that ${chalk.bold(host)} belongs to in the browser`);
222
+ const login = spawnSync("cloudflared", ["tunnel", "login"], { stdio: "inherit" });
223
+ if (login.error || login.status !== 0 || !existsSync(originCertPath()))
224
+ die("cloudflared tunnel login did not complete.");
225
+ console.log(`${chalk.green("✔")} logged in to Cloudflare`);
226
+ }
227
+ // 2. The tunnel: reuse one of that name, else create it.
228
+ const list = cloudflared(["tunnel", "list", "--output", "json", "--name", name]);
229
+ if (!list.ok)
230
+ die(`cloudflared tunnel list failed:\n${list.out}`);
231
+ let existing;
232
+ try {
233
+ existing = JSON.parse(list.out || "[]").find((t) => t.name === name);
234
+ }
235
+ catch {
236
+ die(`cloudflared tunnel list returned no JSON:\n${list.out}`);
237
+ }
238
+ let id = existing?.id;
239
+ if (existing) {
240
+ console.log(`${chalk.green("✔")} tunnel ${chalk.bold(name)} exists ${chalk.dim(id ?? "")}`);
241
+ }
242
+ else {
243
+ const created = cloudflared(["tunnel", "create", "--output", "json", name]);
244
+ if (!created.ok)
245
+ die(`cloudflared tunnel create failed:\n${created.out}`);
246
+ try {
247
+ id = JSON.parse(created.out).id;
248
+ }
249
+ catch {
250
+ // Not fatal: the route step addresses the tunnel by name.
251
+ }
252
+ console.log(`${chalk.green("✔")} created tunnel ${chalk.bold(name)} ${chalk.dim(id ?? "")}`);
253
+ }
254
+ // 3. DNS: a CNAME from the hostname to the tunnel. cloudflared can
255
+ // exit 0 after quietly appending the authorized zone to a hostname
256
+ // outside it — compare the record it reports with the one asked for.
257
+ const route = cloudflared(["tunnel", "route", "dns", ...(opts.overwriteDns ? ["--overwrite-dns"] : []), name, host]);
258
+ const added = /Added CNAME (\S+)/.exec(route.out)?.[1]?.replace(/\.$/, "").toLowerCase();
259
+ if (!route.ok) {
260
+ const exists = /already exists/i.test(route.out);
261
+ die(`cloudflared tunnel route dns failed:\n${route.out}` +
262
+ (exists && !opts.overwriteDns ? `\n\nA record for ${host} exists. If it should point at this tunnel, run setup again with --overwrite-dns.` : ""));
263
+ }
264
+ if (added && added !== host.toLowerCase()) {
265
+ die(`cloudflared routed ${chalk.bold(added)} instead of ${chalk.bold(host)}: the hostname is outside the zone you logged in to.\n` +
266
+ `Delete the stray CNAME ${added} in the Cloudflare dashboard, run \`cloudflared tunnel login\` for the right zone, then setup again.`);
267
+ }
268
+ console.log(`${chalk.green("✔")} ${host} → tunnel ${name}`);
269
+ // 4. kraftwerk.yml
270
+ const publicUrl = `https://${host}`;
271
+ const configPath = await writeTunnelConfig(project, publicUrl, name).catch((err) => die(err.message));
272
+ console.log(`${chalk.green("✔")} ${path.basename(configPath)}: public: ${publicUrl}, tunnel.name: ${name}`);
273
+ const port = project.config.port ?? 1981;
274
+ console.log(`\n${chalk.bold("Next:")} put a Cloudflare Access policy on ${host} — the UI has no login of its own.\n` +
275
+ ` Zero Trust → Access → Applications → Add an application → Self-hosted, domain ${host}: https://one.dash.cloudflare.com/\n` +
276
+ ` Then copy the team name and the application's Audience tag into kraftwerk.yml so the inspector verifies every login:\n` +
277
+ chalk.dim(` tunnel:\n name: ${name}\n access:\n team: <team> # https://<team>.cloudflareaccess.com\n aud: <audience tag>\n`) +
278
+ `\n\`kraftwerk ui\` now starts the tunnel with the inspector (port ${port}); \`kraftwerk tunnel\` runs it alone. \`kraftwerk doctor\` checks it.`);
279
+ });
280
+ }
package/dist/cli/ui.js CHANGED
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import chalk from "chalk";
7
- import { absolutePath, resolveProject } from "../config.js";
7
+ import { absolutePath, publicUrlFor, resolveProject } from "../config.js";
8
+ import { startTunnel } from "./tunnel.js";
8
9
  import { selfCommand } from "../inspector/self-command.js";
9
10
  import { RESTART_EXIT_CODE, startInspector } from "../inspector/server.js";
10
11
  /**
@@ -49,7 +50,7 @@ function ensureBuilt() {
49
50
  }
50
51
  export async function runUi(cwd, opts) {
51
52
  if (process.env.KRAFTWERK_UI_SUPERVISED !== "1")
52
- return superviseUi(opts);
53
+ return superviseUi(cwd, opts);
53
54
  const staticDir = ensureBuilt();
54
55
  const project = await resolveProject(cwd);
55
56
  const outputDir = opts.output ? absolutePath(opts.output, cwd) : project.outputDir;
@@ -58,6 +59,9 @@ export async function runUi(cwd, opts) {
58
59
  await startInspector({ outputDir, staticDir, port, projectRoot: project.root });
59
60
  console.log(`${chalk.green("✔")} Kraftwerk UI: ${chalk.cyan(`http://localhost:${port}`)} ` +
60
61
  chalk.dim(`(output: ${outputDir})`));
62
+ const publicUrl = publicUrlFor(project);
63
+ if (publicUrl)
64
+ console.log(`${chalk.green("✔")} Public URL: ${chalk.cyan(publicUrl)}`);
61
65
  }
62
66
  /**
63
67
  * Respawn loop around the real server. Spawns this same bin script via the
@@ -68,18 +72,23 @@ export async function runUi(cwd, opts) {
68
72
  * projects start` reports) takes the whole UI down; Ctrl-C signals the
69
73
  * foreground group and reaches both anyway.
70
74
  */
71
- async function superviseUi(opts) {
75
+ async function superviseUi(cwd, opts) {
72
76
  const { cmd, args } = selfCommand([
73
77
  "ui",
74
78
  ...(opts.port ? ["--port", opts.port] : []),
75
79
  ...(opts.output ? ["--output", opts.output] : []),
76
80
  ]);
81
+ // The tunnel lives with the supervisor, not the server: a self-restart
82
+ // (new version) swaps the server process and the tunnel stays up.
83
+ const project = await resolveProject(cwd).catch(() => null);
84
+ const tunnel = project ? startTunnel(project, opts.port ? Number(opts.port) : (project.config.port ?? 1981)) : undefined;
77
85
  for (;;) {
78
86
  const child = spawn(cmd, args, {
79
87
  stdio: "inherit",
80
88
  env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1" },
81
89
  });
82
90
  const forward = (sig) => () => {
91
+ tunnel?.stop();
83
92
  child.kill(sig);
84
93
  };
85
94
  const handlers = { SIGTERM: forward("SIGTERM"), SIGINT: forward("SIGINT") };
@@ -91,6 +100,7 @@ async function superviseUi(opts) {
91
100
  process.off("SIGTERM", handlers.SIGTERM);
92
101
  process.off("SIGINT", handlers.SIGINT);
93
102
  if (result.code !== RESTART_EXIT_CODE) {
103
+ tunnel?.stop();
94
104
  // A signal-killed server is not a clean exit — say so in the exit code
95
105
  // (128 + signal, the shell convention) so callers can tell.
96
106
  if (result.signal)
package/dist/config.d.ts CHANGED
@@ -30,6 +30,12 @@
30
30
  * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
31
31
  * vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
32
32
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
33
+ * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
34
+ * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
35
+ * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
36
+ * access: # verify the Cloudflare Access login on every request that arrives via `public`
37
+ * team: acme # Zero Trust team name (https://<team>.cloudflareaccess.com)
38
+ * aud: 4714c135… # Application Audience tag of the Access application
33
39
  */
34
40
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
35
41
  export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -96,6 +102,47 @@ export interface VibeablesConfig {
96
102
  export declare const VIBEABLES_DEFAULT_ROOT = "kraftwerk-data/vibeables";
97
103
  /** Absolute vibeables root when the feature is on, undefined otherwise. */
98
104
  export declare function vibeablesRootFor(project: Project): string | undefined;
105
+ /**
106
+ * Cloudflare Access in front of the public hostname. Access puts a login
107
+ * page on the hostname at Cloudflare's edge and hands the origin a signed
108
+ * JWT per request (Cf-Access-Jwt-Assertion). With this block the inspector
109
+ * verifies that token itself, so a removed or misconfigured Access policy
110
+ * fails closed instead of exposing the UI, which has no login of its own.
111
+ */
112
+ export interface AccessConfig {
113
+ /** Zero Trust team name: the subdomain of https://<team>.cloudflareaccess.com */
114
+ team: string;
115
+ /** Application Audience (AUD) tag of the Access application, from its overview page. */
116
+ aud: string;
117
+ }
118
+ /**
119
+ * Cloudflare Tunnel: `kraftwerk ui` runs cloudflared next to the inspector
120
+ * so the loopback bind is reachable at `public` without an open port. The
121
+ * tunnel itself is created once with cloudflared (or in the Zero Trust
122
+ * dashboard); this block only says which one to run.
123
+ */
124
+ export interface TunnelConfig {
125
+ /** false keeps the block but turns the feature off. Default: true. */
126
+ enabled?: boolean;
127
+ /**
128
+ * Name of a locally-managed tunnel (`cloudflared tunnel create <name>`,
129
+ * `cloudflared tunnel route dns <name> <public host>`); cloudflared routes
130
+ * everything to the inspector port. Absent: a dashboard-managed tunnel,
131
+ * whose token comes from the TUNNEL_TOKEN environment variable and whose
132
+ * route to http://localhost:<port> is configured in the dashboard.
133
+ */
134
+ name?: string;
135
+ /** Verify the Cloudflare Access token on every request that arrives via `public`. */
136
+ access?: AccessConfig;
137
+ }
138
+ /** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
139
+ export declare function publicHostFor(project: Project): string | undefined;
140
+ /** The public origin ("https://kw.example.com") when `public` is set, undefined otherwise. */
141
+ export declare function publicUrlFor(project: Project): string | undefined;
142
+ /** The tunnel block when the feature is on, undefined otherwise. */
143
+ export declare function tunnelFor(project: Project): TunnelConfig | undefined;
144
+ /** "kw.example.com" or "https://kw.example.com[:port]" → its URL; undefined when it is neither. */
145
+ export declare function parsePublic(value: string): URL | undefined;
99
146
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
100
147
  export declare function ignoreEntryFor(projectRoot: string, dir: string): string | undefined;
101
148
  /** True when .gitignore text already covers the entry (with or without a leading or trailing slash). */
@@ -136,6 +183,14 @@ export interface ProjectConfig {
136
183
  repos?: ReposConfig;
137
184
  /** Vibeables: apps built live in a chat. Absent = off. */
138
185
  vibeables?: VibeablesConfig;
186
+ /**
187
+ * Hostname the inspector is reached at through a tunnel or reverse proxy,
188
+ * e.g. "https://kw.example.com". The loopback bind then answers requests
189
+ * carrying that Host even without X-Forwarded-Host (cloudflared sends none).
190
+ */
191
+ public?: string;
192
+ /** Cloudflare Tunnel run alongside the inspector. Absent = off. */
193
+ tunnel?: TunnelConfig;
139
194
  }
140
195
  export interface Project {
141
196
  /** Absolute project root the CLI operates on. */
package/dist/config.js CHANGED
@@ -34,6 +34,12 @@ import { parse } from "yaml";
34
34
  * root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
35
35
  * vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
36
36
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
37
+ * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
38
+ * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
39
+ * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
40
+ * access: # verify the Cloudflare Access login on every request that arrives via `public`
41
+ * team: acme # Zero Trust team name (https://<team>.cloudflareaccess.com)
42
+ * aud: 4714c135… # Application Audience tag of the Access application
37
43
  */
38
44
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
39
45
  export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -78,6 +84,34 @@ export function vibeablesRootFor(project) {
78
84
  return undefined;
79
85
  return path.resolve(project.root, v.root ?? VIBEABLES_DEFAULT_ROOT);
80
86
  }
87
+ /** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
88
+ export function publicHostFor(project) {
89
+ return project.config.public ? parsePublic(project.config.public)?.hostname : undefined;
90
+ }
91
+ /** The public origin ("https://kw.example.com") when `public` is set, undefined otherwise. */
92
+ export function publicUrlFor(project) {
93
+ return project.config.public ? parsePublic(project.config.public)?.origin : undefined;
94
+ }
95
+ /** The tunnel block when the feature is on, undefined otherwise. */
96
+ export function tunnelFor(project) {
97
+ const t = project.config.tunnel;
98
+ if (!t || t.enabled === false)
99
+ return undefined;
100
+ return t;
101
+ }
102
+ /** "kw.example.com" or "https://kw.example.com[:port]" → its URL; undefined when it is neither. */
103
+ export function parsePublic(value) {
104
+ const text = value.trim();
105
+ try {
106
+ const url = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`);
107
+ if (!url.hostname || url.pathname !== "/" || url.search || url.hash || url.username)
108
+ return undefined;
109
+ return url;
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ }
81
115
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
82
116
  export function ignoreEntryFor(projectRoot, dir) {
83
117
  const rel = path.relative(projectRoot, path.resolve(projectRoot, dir)).split(path.sep).join("/");
@@ -153,7 +187,7 @@ export async function resolveProject(cwd) {
153
187
  const root = gitFallback ?? start;
154
188
  return { root, config: {}, outputDir: path.join(root, "output") };
155
189
  }
156
- const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables"];
190
+ const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "public", "tunnel"];
157
191
  async function loadConfig(configPath) {
158
192
  let raw;
159
193
  try {
@@ -204,12 +238,64 @@ async function loadConfig(configPath) {
204
238
  config[key] = {};
205
239
  validateRootBlock(configPath, "vibeables", config[key]);
206
240
  }
241
+ else if (key === "public") {
242
+ if (typeof config[key] !== "string" || !parsePublic(config[key])) {
243
+ throw new Error(`${path.basename(configPath)}: public must be a hostname or https URL like "https://kw.example.com"`);
244
+ }
245
+ }
246
+ else if (key === "tunnel") {
247
+ if (config[key] === null)
248
+ config[key] = {};
249
+ validateTunnel(configPath, config[key]);
250
+ }
207
251
  else if (typeof config[key] !== "string") {
208
252
  throw new Error(`${path.basename(configPath)}: ${key} must be a string`);
209
253
  }
210
254
  }
255
+ const tunnel = config.tunnel;
256
+ if (tunnel && tunnel.enabled !== false && !config.public) {
257
+ throw new Error(`${path.basename(configPath)}: tunnel needs public: the hostname the tunnel routes to`);
258
+ }
211
259
  return config;
212
260
  }
261
+ /** tunnel: { enabled?, name?, access?: { team, aud } } */
262
+ function validateTunnel(configPath, value) {
263
+ const file = path.basename(configPath);
264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
265
+ throw new Error(`${file}: tunnel must be a mapping (enabled, name, access)`);
266
+ }
267
+ const t = value;
268
+ for (const key of Object.keys(t)) {
269
+ if (!["enabled", "name", "access"].includes(key)) {
270
+ throw new Error(`${file}: tunnel.${key} is unknown (allowed: enabled, name, access)`);
271
+ }
272
+ }
273
+ if (t.enabled !== undefined && typeof t.enabled !== "boolean") {
274
+ throw new Error(`${file}: tunnel.enabled must be true or false`);
275
+ }
276
+ // The name is handed to cloudflared as a positional argument.
277
+ if (t.name !== undefined && (typeof t.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(t.name))) {
278
+ throw new Error(`${file}: tunnel.name must be a plain tunnel name (letters, digits, ".", "_", "-")`);
279
+ }
280
+ if (t.access !== undefined) {
281
+ if (typeof t.access !== "object" || t.access === null || Array.isArray(t.access)) {
282
+ throw new Error(`${file}: tunnel.access must be a mapping (team, aud)`);
283
+ }
284
+ const a = t.access;
285
+ for (const key of Object.keys(a)) {
286
+ if (!["team", "aud"].includes(key)) {
287
+ throw new Error(`${file}: tunnel.access.${key} is unknown (allowed: team, aud)`);
288
+ }
289
+ }
290
+ // The team becomes a hostname (<team>.cloudflareaccess.com).
291
+ if (typeof a.team !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(a.team)) {
292
+ throw new Error(`${file}: tunnel.access.team must be the Zero Trust team name (the subdomain of cloudflareaccess.com)`);
293
+ }
294
+ if (typeof a.aud !== "string" || !/^[a-f0-9]{64}$/i.test(a.aud)) {
295
+ throw new Error(`${file}: tunnel.access.aud must be the 64-character Application Audience tag`);
296
+ }
297
+ }
298
+ }
213
299
  function validateSwitcher(configPath, value) {
214
300
  const file = path.basename(configPath);
215
301
  if (!Array.isArray(value)) {
@@ -0,0 +1,32 @@
1
+ import type { AccessConfig } from "../config.js";
2
+ /**
3
+ * Cloudflare Access token verification for requests that arrive via the
4
+ * public hostname. Access authenticates the browser at Cloudflare's edge and
5
+ * forwards a signed JWT per request in Cf-Access-Jwt-Assertion; the signing
6
+ * keys are public at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs.
7
+ * Verifying the token here means the inspector, which has no login of its
8
+ * own, does not have to trust that the policy in the dashboard still exists.
9
+ *
10
+ * Dependency-free: RS256 over node:crypto. Keys are cached per team and
11
+ * refetched when a token names an unknown key id (rotation), at most once
12
+ * per REFETCH_INTERVAL so a flood of bogus tokens cannot hammer Cloudflare.
13
+ */
14
+ export declare const ACCESS_HEADER = "cf-access-jwt-assertion";
15
+ export type AccessResult = {
16
+ ok: true;
17
+ email?: string;
18
+ } | {
19
+ ok: false;
20
+ reason: string;
21
+ };
22
+ /** Team domain Access issues tokens for. Tests override the certs location. */
23
+ export declare function teamDomain(team: string): string;
24
+ /**
25
+ * Verify one Access token against the team's keys, the application's
26
+ * audience and the clock. Returns a reason instead of throwing so the
27
+ * caller can answer 401 with it; a certs fetch failure is a reason too
28
+ * (fail closed).
29
+ */
30
+ export declare function verifyAccessToken(token: string, access: AccessConfig): Promise<AccessResult>;
31
+ /** Forget cached keys (tests). */
32
+ export declare function resetAccessKeys(): void;
@@ -0,0 +1,121 @@
1
+ import { createPublicKey, verify as verifySignature } from "node:crypto";
2
+ /**
3
+ * Cloudflare Access token verification for requests that arrive via the
4
+ * public hostname. Access authenticates the browser at Cloudflare's edge and
5
+ * forwards a signed JWT per request in Cf-Access-Jwt-Assertion; the signing
6
+ * keys are public at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs.
7
+ * Verifying the token here means the inspector, which has no login of its
8
+ * own, does not have to trust that the policy in the dashboard still exists.
9
+ *
10
+ * Dependency-free: RS256 over node:crypto. Keys are cached per team and
11
+ * refetched when a token names an unknown key id (rotation), at most once
12
+ * per REFETCH_INTERVAL so a flood of bogus tokens cannot hammer Cloudflare.
13
+ */
14
+ export const ACCESS_HEADER = "cf-access-jwt-assertion";
15
+ const KEY_TTL = 60 * 60 * 1000;
16
+ const REFETCH_INTERVAL = 10 * 1000;
17
+ const FETCH_TIMEOUT = 5_000;
18
+ const cache = new Map();
19
+ /** Team domain Access issues tokens for. Tests override the certs location. */
20
+ export function teamDomain(team) {
21
+ return `https://${team}.cloudflareaccess.com`;
22
+ }
23
+ function certsUrl(team) {
24
+ return process.env.KRAFTWERK_ACCESS_CERTS_URL || `${teamDomain(team)}/cdn-cgi/access/certs`;
25
+ }
26
+ async function fetchKeys(team) {
27
+ const res = await fetch(certsUrl(team), { signal: AbortSignal.timeout(FETCH_TIMEOUT) });
28
+ if (!res.ok)
29
+ throw new Error(`certs endpoint answered ${res.status}`);
30
+ const body = (await res.json());
31
+ const keys = new Map();
32
+ for (const jwk of body.keys ?? []) {
33
+ if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
34
+ continue;
35
+ try {
36
+ keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" }));
37
+ }
38
+ catch {
39
+ // One malformed key must not take the rest down.
40
+ }
41
+ }
42
+ return keys;
43
+ }
44
+ async function keyFor(team, kid) {
45
+ const now = Date.now();
46
+ let entry = cache.get(team);
47
+ if (!entry || now - entry.fetchedAt > KEY_TTL) {
48
+ entry = { keys: await fetchKeys(team), fetchedAt: now, missedAt: 0 };
49
+ cache.set(team, entry);
50
+ }
51
+ const hit = entry.keys.get(kid);
52
+ if (hit)
53
+ return hit;
54
+ if (now - entry.missedAt < REFETCH_INTERVAL)
55
+ return undefined;
56
+ entry.missedAt = now;
57
+ entry.keys = await fetchKeys(team);
58
+ entry.fetchedAt = now;
59
+ return entry.keys.get(kid);
60
+ }
61
+ function decodeSegment(seg) {
62
+ try {
63
+ const parsed = JSON.parse(Buffer.from(seg, "base64url").toString("utf8"));
64
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Verify one Access token against the team's keys, the application's
72
+ * audience and the clock. Returns a reason instead of throwing so the
73
+ * caller can answer 401 with it; a certs fetch failure is a reason too
74
+ * (fail closed).
75
+ */
76
+ export async function verifyAccessToken(token, access) {
77
+ const parts = token.split(".");
78
+ if (parts.length !== 3)
79
+ return { ok: false, reason: "malformed token" };
80
+ const [h, p, s] = parts;
81
+ const header = decodeSegment(h);
82
+ const payload = decodeSegment(p);
83
+ if (!header || !payload)
84
+ return { ok: false, reason: "malformed token" };
85
+ if (header.alg !== "RS256" || typeof header.kid !== "string")
86
+ return { ok: false, reason: "unsupported token" };
87
+ let key;
88
+ try {
89
+ key = await keyFor(access.team, header.kid);
90
+ }
91
+ catch (err) {
92
+ return { ok: false, reason: `cannot fetch Access keys: ${err.message}` };
93
+ }
94
+ if (!key)
95
+ return { ok: false, reason: "unknown signing key" };
96
+ let valid = false;
97
+ try {
98
+ valid = verifySignature("RSA-SHA256", Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"));
99
+ }
100
+ catch {
101
+ valid = false;
102
+ }
103
+ if (!valid)
104
+ return { ok: false, reason: "bad signature" };
105
+ if (payload.iss !== teamDomain(access.team))
106
+ return { ok: false, reason: "wrong issuer" };
107
+ const aud = payload.aud;
108
+ const audOk = Array.isArray(aud) ? aud.includes(access.aud) : aud === access.aud;
109
+ if (!audOk)
110
+ return { ok: false, reason: "wrong audience" };
111
+ const now = Math.floor(Date.now() / 1000);
112
+ if (typeof payload.exp !== "number" || payload.exp <= now)
113
+ return { ok: false, reason: "token expired" };
114
+ if (typeof payload.nbf === "number" && payload.nbf > now)
115
+ return { ok: false, reason: "token not yet valid" };
116
+ return { ok: true, email: typeof payload.email === "string" ? payload.email : undefined };
117
+ }
118
+ /** Forget cached keys (tests). */
119
+ export function resetAccessKeys() {
120
+ cache.clear();
121
+ }
@@ -3,7 +3,8 @@ import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { attachmentPath, saveAttachment } from "./chat/store.js";
5
5
  import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
6
- import { resolveProject } from "../config.js";
6
+ import { publicHostFor, publicUrlFor, resolveProject, tunnelFor } from "../config.js";
7
+ import { ACCESS_HEADER, verifyAccessToken } from "./access.js";
7
8
  import { listRuns, getRun, readRunFile, deleteRun } from "./runs.js";
8
9
  import { canSelfUpdate, startUpdate, updateStatus } from "./update.js";
9
10
  import { listWorkflows, getWorkflow } from "./workflows.js";
@@ -99,6 +100,16 @@ const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv
99
100
  const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
100
101
  const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
101
102
  const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
103
+ /**
104
+ * The hostname from kraftwerk.yml `public`, when set: the name a tunnel or
105
+ * reverse proxy delivers as Host. A Cloudflare Tunnel forwards the
106
+ * browser's Host untouched and sets no X-Forwarded-Host, so without this
107
+ * the loopback bind would refuse every request that came through it. A
108
+ * rebinding page cannot exploit it: the name is one the operator owns.
109
+ */
110
+ let publicHost = "";
111
+ /** Access verification for requests arriving via the public hostname (kraftwerk.yml `tunnel.access`). */
112
+ let access;
102
113
  /**
103
114
  * Whether the Host header names this server. A loopback bind alone does not
104
115
  * keep other sites out: a page on evil.example can re-point that name at
@@ -123,7 +134,32 @@ function hostAllowed(req) {
123
134
  if (!host)
124
135
  return true;
125
136
  const name = hostnameOf(host);
126
- return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost");
137
+ return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost") || (!!publicHost && name === publicHost);
138
+ }
139
+ /** Whether the browser addressed the public hostname (directly or, via a proxy, in X-Forwarded-Host). */
140
+ function viaPublicHost(req) {
141
+ if (!publicHost)
142
+ return false;
143
+ const host = forwardedHost(req) || req.headers.host;
144
+ return !!host && hostnameOf(host) === publicHost;
145
+ }
146
+ /**
147
+ * The Access gate: a request that arrived via the public hostname must
148
+ * carry a valid Cloudflare Access token when `tunnel.access` is configured.
149
+ * Requests addressed to a loopback name are the operator's own browser on
150
+ * this machine and pass; bound to loopback, only the tunnel (or a local
151
+ * proxy) can deliver the public name in the first place. Returns the reason
152
+ * to refuse, or undefined to proceed.
153
+ */
154
+ async function accessRefusal(req) {
155
+ if (!access || !viaPublicHost(req))
156
+ return undefined;
157
+ const raw = req.headers[ACCESS_HEADER];
158
+ const token = Array.isArray(raw) ? raw[0] : raw;
159
+ if (!token)
160
+ return "Cloudflare Access token missing";
161
+ const result = await verifyAccessToken(token, access);
162
+ return result.ok ? undefined : `Cloudflare Access token refused: ${result.reason}`;
127
163
  }
128
164
  /** First X-Forwarded-Host value, or undefined when no proxy set one. */
129
165
  function forwardedHost(req) {
@@ -348,6 +384,7 @@ async function handleApi(req, res, url) {
348
384
  git: (project?.config.git && project.config.git.enabled !== false) === true,
349
385
  repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
350
386
  vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
387
+ publicUrl: (project && publicUrlFor(project)) ?? "",
351
388
  switcher,
352
389
  });
353
390
  }
@@ -1181,10 +1218,14 @@ async function serveStatic(res, staticDir, pathname) {
1181
1218
  res.end(buf);
1182
1219
  }
1183
1220
  /** Start the server; resolves once it listens. Runs until the process ends. */
1184
- export function startInspector(opts) {
1221
+ export async function startInspector(opts) {
1185
1222
  setOutputDir(opts.outputDir);
1186
1223
  if (opts.projectRoot)
1187
1224
  setProjectRoot(opts.projectRoot);
1225
+ // Read once: like the port, the public hostname takes effect on restart.
1226
+ const project = await resolveProject(getProjectRoot()).catch(() => null);
1227
+ publicHost = (project && publicHostFor(project)) ?? "";
1228
+ access = project ? tunnelFor(project)?.access : undefined;
1188
1229
  startRoutineScheduler();
1189
1230
  startGitSync();
1190
1231
  // Chat agent subprocesses must die with the server — signals bypass
@@ -1211,6 +1252,9 @@ export function startInspector(opts) {
1211
1252
  const url = new URL(req.url ?? "/", "http://localhost");
1212
1253
  if (!hostAllowed(req))
1213
1254
  return json(res, { error: "unexpected Host header" }, 421);
1255
+ const refusal = await accessRefusal(req);
1256
+ if (refusal)
1257
+ return json(res, { error: refusal }, 401);
1214
1258
  if (url.pathname.startsWith("/api/"))
1215
1259
  await handleApi(req, res, url);
1216
1260
  // /vibeables/<slug>/… is an app's own files, served for the preview pane.
@@ -1227,7 +1271,7 @@ export function startInspector(opts) {
1227
1271
  server.on("upgrade", (req, socket, head) => {
1228
1272
  if (!hostAllowed(req))
1229
1273
  return void socket.destroy();
1230
- proxyUpgrade(req, socket, head);
1274
+ accessRefusal(req).then((refusal) => (refusal ? socket.destroy() : proxyUpgrade(req, socket, head)), () => socket.destroy());
1231
1275
  });
1232
1276
  return new Promise((resolve, reject) => {
1233
1277
  server.once("error", reject);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
4
4
  "description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
5
5
  "keywords": [
6
6
  "agents",
@@ -14,6 +14,11 @@
14
14
  "type": "string",
15
15
  "description": "Emoji used as the inspector favicon (browser-tab icon), e.g. \"⚡\""
16
16
  },
17
+ "color": {
18
+ "type": "string",
19
+ "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$",
20
+ "description": "Accent colour for this workspace in the switcher and the inspector, e.g. \"#c2410c\". Default: derived from the project root"
21
+ },
17
22
  "port": {
18
23
  "type": "integer",
19
24
  "minimum": 1,
@@ -69,7 +74,10 @@
69
74
  }
70
75
  },
71
76
  "git": {
72
- "type": ["object", "null"],
77
+ "type": [
78
+ "object",
79
+ "null"
80
+ ],
73
81
  "additionalProperties": false,
74
82
  "description": "Git sync for the workspace: commit and push knowledge, agents, skills and workflows from the inspector. Absent = feature off, a bare `git:` = on with defaults.",
75
83
  "properties": {
@@ -100,7 +108,10 @@
100
108
  }
101
109
  },
102
110
  "repos": {
103
- "type": ["object", "null"],
111
+ "type": [
112
+ "object",
113
+ "null"
114
+ ],
104
115
  "additionalProperties": false,
105
116
  "description": "Repositories: git clones the agents work on, kept under one root inside the project. Absent = feature off, a bare `repos:` = on with defaults.",
106
117
  "properties": {
@@ -113,6 +124,68 @@
113
124
  "description": "Where clones land, relative to the project root. Should be git-ignored. Default: repos"
114
125
  }
115
126
  }
127
+ },
128
+ "vibeables": {
129
+ "type": [
130
+ "object",
131
+ "null"
132
+ ],
133
+ "additionalProperties": false,
134
+ "description": "Vibeables: small apps built live in a chat with a preview pane, one folder each under the root, part of the workspace. Absent = feature off, a bare `vibeables:` = on with defaults.",
135
+ "properties": {
136
+ "enabled": {
137
+ "type": "boolean",
138
+ "description": "Turn the feature off while keeping the config. Default: true when this block exists"
139
+ },
140
+ "root": {
141
+ "type": "string",
142
+ "description": "Where the apps live, relative to the project root. Default: kraftwerk-data/vibeables"
143
+ }
144
+ }
145
+ },
146
+ "public": {
147
+ "type": "string",
148
+ "description": "Hostname the inspector is reached at through a tunnel or reverse proxy, e.g. \"https://kw.example.com\". The loopback bind then answers requests carrying that Host even without X-Forwarded-Host (cloudflared sends none)."
149
+ },
150
+ "tunnel": {
151
+ "type": [
152
+ "object",
153
+ "null"
154
+ ],
155
+ "additionalProperties": false,
156
+ "description": "Cloudflare Tunnel run by `kraftwerk ui` next to the inspector, so the loopback bind is reachable at `public` without an open port. Needs `public`. Absent = feature off, a bare `tunnel:` = on with defaults.",
157
+ "properties": {
158
+ "enabled": {
159
+ "type": "boolean",
160
+ "description": "Turn the feature off while keeping the config. Default: true when this block exists"
161
+ },
162
+ "name": {
163
+ "type": "string",
164
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
165
+ "description": "Name of a locally-managed tunnel (`cloudflared tunnel create <name>` + `cloudflared tunnel route dns <name> <public host>`); cloudflared routes everything to the inspector port. Absent: a dashboard-managed tunnel whose token is read from the TUNNEL_TOKEN environment variable"
166
+ },
167
+ "access": {
168
+ "type": "object",
169
+ "additionalProperties": false,
170
+ "required": [
171
+ "team",
172
+ "aud"
173
+ ],
174
+ "description": "Verify the Cloudflare Access token (Cf-Access-Jwt-Assertion) on every request that arrives via `public`. Recommended: the UI has no login of its own, and with this a removed Access policy fails closed",
175
+ "properties": {
176
+ "team": {
177
+ "type": "string",
178
+ "pattern": "^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$",
179
+ "description": "Zero Trust team name: the subdomain of https://<team>.cloudflareaccess.com"
180
+ },
181
+ "aud": {
182
+ "type": "string",
183
+ "pattern": "^[a-fA-F0-9]{64}$",
184
+ "description": "Application Audience (AUD) tag of the Access application, shown on its overview page in the Zero Trust dashboard"
185
+ }
186
+ }
187
+ }
188
+ }
116
189
  }
117
190
  }
118
191
  }