@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 +112 -2
- package/dist/cli/doctor.js +28 -1
- package/dist/cli/init.js +6 -0
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/cli/tunnel.d.ts +10 -0
- package/dist/cli/tunnel.js +280 -0
- package/dist/cli/ui.js +13 -3
- package/dist/config.d.ts +55 -0
- package/dist/config.js +87 -1
- package/dist/inspector/access.d.ts +32 -0
- package/dist/inspector/access.js +121 -0
- package/dist/inspector/server.js +48 -4
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +75 -2
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
|
|
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
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/inspector/server.js
CHANGED
|
@@ -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.
|
|
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": [
|
|
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": [
|
|
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
|
}
|