@netnodeag/kraftwerk 0.46.2 → 0.48.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 +122 -2
- package/dist/cli/doctor.js +39 -1
- package/dist/cli/init.js +15 -8
- package/dist/cli/kraftwerk.js +14 -1
- 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/dotenv.d.ts +40 -0
- package/dist/dotenv.js +84 -0
- package/dist/inspector/access.d.ts +32 -0
- package/dist/inspector/access.js +121 -0
- package/dist/inspector/server.js +48 -4
- package/inspector/dist/assets/{dist-CmOlZvwa.js → dist-BNGsfwfO.js} +1 -1
- package/inspector/dist/assets/{dist-BOQhQPN9.js → dist-BWkBM49t.js} +1 -1
- package/inspector/dist/assets/{dist-CUeUDZ9j.js → dist-Bz3XVWXl.js} +1 -1
- package/inspector/dist/assets/{dist-DnmDnMK2.js → dist-CmH9PvLb.js} +1 -1
- package/inspector/dist/assets/{dist-C2Mu5bT-.js → dist-D6fPO3Jz.js} +1 -1
- package/inspector/dist/assets/{dist-DfAFwiGQ.js → dist-DUq31xBw.js} +1 -1
- package/inspector/dist/assets/{dist-D8pjVMTl.js → dist-Dk5ZcwMb.js} +1 -1
- package/inspector/dist/assets/{dist-CjMETDfE.js → dist-Dv40dCIn.js} +1 -1
- package/inspector/dist/assets/dist-JaBY_kZV.js +1 -0
- package/inspector/dist/assets/{dist-BNUGzZS7.js → dist-O97r5Ket.js} +1 -1
- package/inspector/dist/assets/{dist-CdyUhzAt.js → dist-n8GR1Slo.js} +1 -1
- package/inspector/dist/assets/{editor-DJbeG376.js → editor-toVFopCO.js} +3 -3
- package/inspector/dist/assets/{index-DgEUOA11.js → index-CC0tY40i.js} +12 -12
- package/inspector/dist/index.html +1 -1
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +75 -2
- package/inspector/dist/assets/dist-rrIMDK_-.js +0 -1
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,18 @@ 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).
|
|
195
|
+
|
|
196
|
+
A `.env` next to kraftwerk.yml (`KEY=value` lines, `#` comments, quotes)
|
|
197
|
+
is loaded into every kraftwerk process at start: `kraftwerk ui` and the
|
|
198
|
+
server it supervises, so a restart from the UI re-reads the file; the chat
|
|
199
|
+
agents, routines and workflow runs the inspector spawns; the tunnel
|
|
200
|
+
(`TUNNEL_TOKEN`); `requires:` checks in `run` and `doctor`. A variable the
|
|
201
|
+
shell already sets wins over the file. A project started from another
|
|
202
|
+
workspace's inspector gets its own `.env`, not the other one's. The file is
|
|
203
|
+
never synced (it is on the workspace git's deny list and in the .gitignore
|
|
204
|
+
`kraftwerk init` writes); `kraftwerk doctor` lists the names it loaded.
|
|
190
205
|
|
|
191
206
|
`switcher:` links other kraftwerk workspaces from the inspector header. The
|
|
192
207
|
workspace name becomes a dropdown listing them:
|
|
@@ -283,6 +298,111 @@ repo. Agent logins made inside the container persist in the `agent-home`
|
|
|
283
298
|
volume. This is a different image from the `kraftwerk-runner` sandbox
|
|
284
299
|
(`runner/Dockerfile`) used by `run --sandbox`.
|
|
285
300
|
|
|
301
|
+
### Inspector through a Cloudflare Tunnel
|
|
302
|
+
|
|
303
|
+
The other way to reach a laptop's or server's inspector from elsewhere: no
|
|
304
|
+
open port, no reverse proxy, no certificate. `kraftwerk ui` runs
|
|
305
|
+
[cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)
|
|
306
|
+
next to the inspector and the loopback bind becomes reachable at a hostname
|
|
307
|
+
on a domain you have on Cloudflare.
|
|
308
|
+
|
|
309
|
+
You need a Cloudflare account with a domain added to it (the free plan is
|
|
310
|
+
enough), and cloudflared on the machine:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
brew install cloudflared # macOS; Linux packages: developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**1. Create the tunnel and route a hostname to it.** From the project root:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
kraftwerk tunnel setup kw.example.com
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
This opens the browser for `cloudflared tunnel login` when there is no
|
|
323
|
+
certificate yet (pick the zone `kw.example.com` belongs to), creates a
|
|
324
|
+
tunnel named `kraftwerk-<project name>` (or `--name`), adds the CNAME from
|
|
325
|
+
the hostname to it, and writes the result into kraftwerk.yml:
|
|
326
|
+
|
|
327
|
+
```yaml
|
|
328
|
+
public: https://kw.example.com # the hostname the tunnel routes to
|
|
329
|
+
tunnel:
|
|
330
|
+
name: kraftwerk-agent-playground
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
A second run reuses the login and the tunnel. If the hostname already has a
|
|
334
|
+
DNS record, setup says so and `--overwrite-dns` replaces it. If cloudflared
|
|
335
|
+
quietly routed `kw.example.com.other-zone.com` instead, because the hostname
|
|
336
|
+
is outside the zone you logged in to, setup refuses and names the stray
|
|
337
|
+
record to delete before you log in to the right zone and run it again.
|
|
338
|
+
|
|
339
|
+
**2. Put a Cloudflare Access policy on the hostname.** The UI has no login
|
|
340
|
+
of its own and its chat runs coding agents against the workspace, so the
|
|
341
|
+
tunnel must never be the only thing between the internet and it. In the
|
|
342
|
+
[Zero Trust dashboard](https://one.dash.cloudflare.com/) go to Access →
|
|
343
|
+
Applications → Add an application → Self-hosted, enter `kw.example.com` as
|
|
344
|
+
the domain, and add a policy: emails ending in your domain with a one-time
|
|
345
|
+
PIN, a Google or GitHub login, whatever fits. Access is free for up to 50
|
|
346
|
+
users. From then on Cloudflare shows a login page before anything reaches
|
|
347
|
+
the tunnel.
|
|
348
|
+
|
|
349
|
+
**3. Let the inspector verify that login.** On the application's overview
|
|
350
|
+
page copy the Application Audience (AUD) tag, and take your team name from
|
|
351
|
+
the team domain `https://<team>.cloudflareaccess.com`:
|
|
352
|
+
|
|
353
|
+
```yaml
|
|
354
|
+
tunnel:
|
|
355
|
+
name: kraftwerk-agent-playground
|
|
356
|
+
access:
|
|
357
|
+
team: my-team
|
|
358
|
+
aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
With this block the inspector checks the `Cf-Access-Jwt-Assertion` token
|
|
362
|
+
Access adds to every request, against the team's public keys, the audience
|
|
363
|
+
tag and the clock. A removed or misconfigured policy then fails closed with
|
|
364
|
+
a 401 instead of exposing the UI. Skipping the block works, and
|
|
365
|
+
`kraftwerk doctor` warns about it every time.
|
|
366
|
+
|
|
367
|
+
**4. Start.** `kraftwerk ui` now starts cloudflared with the inspector,
|
|
368
|
+
keeps it across UI restarts and stops it with the UI:
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
✔ Kraftwerk UI: http://localhost:1981
|
|
372
|
+
✔ Public URL: https://kw.example.com
|
|
373
|
+
↗ tunnel: running kraftwerk-agent-playground → https://kw.example.com → http://127.0.0.1:1981
|
|
374
|
+
cloudflared │ ... Registered tunnel connection ...
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Open `https://kw.example.com` from anywhere, log in through Access, and you
|
|
378
|
+
are in the same inspector. `localhost:1981` on the machine itself keeps
|
|
379
|
+
working without a login. `kraftwerk doctor` reports the tunnel, the Access
|
|
380
|
+
block and whether cloudflared is installed.
|
|
381
|
+
|
|
382
|
+
**Variants.** `kraftwerk tunnel` runs the configured tunnel alone, for an
|
|
383
|
+
inspector that already runs elsewhere (started by `kraftwerk projects
|
|
384
|
+
start`, or in a container). For a tunnel created in the Zero Trust
|
|
385
|
+
dashboard instead (Networks → Tunnels), leave `name` out, route the
|
|
386
|
+
hostname to `http://localhost:<port>` there and export its token as
|
|
387
|
+
`TUNNEL_TOKEN` before `kraftwerk ui`. `public:` on its own, without
|
|
388
|
+
`tunnel:`, is for any other proxy that forwards the browser's `Host`
|
|
389
|
+
without setting `X-Forwarded-Host`.
|
|
390
|
+
|
|
391
|
+
**Troubleshooting.** cloudflared's lines appear prefixed with
|
|
392
|
+
`cloudflared │`. "Cannot determine default origin certificate path" means no
|
|
393
|
+
login on this machine: run `cloudflared tunnel login` or setup again. "tunnel
|
|
394
|
+
not found" means the name in kraftwerk.yml does not exist in the account
|
|
395
|
+
that logged in; `cloudflared tunnel list` shows what does. A tunnel that
|
|
396
|
+
dies three times right after launch is given up and the UI stays local
|
|
397
|
+
until the next start. A 421 "unexpected Host header" in the browser means
|
|
398
|
+
`public:` does not match the hostname you opened. A 401 means Access is
|
|
399
|
+
configured in kraftwerk.yml but the token is missing or wrong: the
|
|
400
|
+
hostname has no Access application, or `team`/`aud` do not match it.
|
|
401
|
+
|
|
402
|
+
Quick tunnels (`trycloudflare.com`) are deliberately not supported: Access
|
|
403
|
+
cannot be attached to them, which would leave the UI open to anyone with
|
|
404
|
+
the URL.
|
|
405
|
+
|
|
286
406
|
## Persistent agents
|
|
287
407
|
|
|
288
408
|
The inspector's "agents" screen turns chat agents into persistent teammates. An
|
package/dist/cli/doctor.js
CHANGED
|
@@ -2,9 +2,10 @@ 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
|
+
import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
|
|
8
9
|
const ICONS = {
|
|
9
10
|
ok: chalk.green("✔"),
|
|
10
11
|
warn: chalk.yellow("⚠"),
|
|
@@ -106,6 +107,16 @@ export async function runDoctor(cwd) {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
}
|
|
110
|
+
// .env next to kraftwerk.yml: already applied by the CLI hook; this call
|
|
111
|
+
// only reports what it holds (names, never values).
|
|
112
|
+
const dotenv = await applyDotenv(project.root);
|
|
113
|
+
if (dotenv.file) {
|
|
114
|
+
const names = [...dotenv.applied, ...dotenv.kept.map((k) => `${k} (shell wins)`)];
|
|
115
|
+
report("ok", `${DOTENV_FILE}: ${names.length} variable(s) loaded`, names.join(", ") || "empty");
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
report("info", `no ${DOTENV_FILE}`, "variables for agents, workflows and the tunnel can live there — loaded on every start and restart");
|
|
119
|
+
}
|
|
109
120
|
// Repositories: the clones root must stay out of the workspace git. git
|
|
110
121
|
// itself decides — that covers worktrees (.git is a file), a workspace
|
|
111
122
|
// nested in a larger repo, a .gitignore at the toplevel, global excludes.
|
|
@@ -123,6 +134,33 @@ export async function runDoctor(cwd) {
|
|
|
123
134
|
else
|
|
124
135
|
report("ok", label, "not inside a git repository");
|
|
125
136
|
}
|
|
137
|
+
// Public hostname + Cloudflare Tunnel. The UI has no login of its own, so
|
|
138
|
+
// a tunnel without Access verification is worth a warning every time.
|
|
139
|
+
const publicHost = publicHostFor(project);
|
|
140
|
+
const tunnel = tunnelFor(project);
|
|
141
|
+
if (publicHost && !tunnel)
|
|
142
|
+
report("info", `public: ${publicHost}`, "served when a tunnel or reverse proxy delivers that Host");
|
|
143
|
+
if (tunnel) {
|
|
144
|
+
const cloudflared = cliVersion("cloudflared");
|
|
145
|
+
if (cloudflared)
|
|
146
|
+
report("ok", `cloudflared`, cloudflared);
|
|
147
|
+
else {
|
|
148
|
+
report("fail", "cloudflared missing", "needed by tunnel: — brew install cloudflared (or see developers.cloudflare.com)");
|
|
149
|
+
failures++;
|
|
150
|
+
}
|
|
151
|
+
if (tunnel.name)
|
|
152
|
+
report("ok", `tunnel: ${tunnel.name} → ${publicHost}`, `cloudflared tunnel run --url http://127.0.0.1:${project.config.port ?? 1981} ${tunnel.name}`);
|
|
153
|
+
else if (process.env.TUNNEL_TOKEN)
|
|
154
|
+
report("ok", `tunnel: dashboard-managed → ${publicHost}`, "TUNNEL_TOKEN is set");
|
|
155
|
+
else {
|
|
156
|
+
report("fail", "tunnel: nothing to run", "set tunnel.name (locally-managed) or the TUNNEL_TOKEN env var (dashboard-managed)");
|
|
157
|
+
failures++;
|
|
158
|
+
}
|
|
159
|
+
if (tunnel.access)
|
|
160
|
+
report("ok", "tunnel.access", `tokens verified against ${tunnel.access.team}.cloudflareaccess.com`);
|
|
161
|
+
else
|
|
162
|
+
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");
|
|
163
|
+
}
|
|
126
164
|
const found = project.workflowsRoot ? await discoverWorkflows(cwd) : [];
|
|
127
165
|
if (!project.workflowsRoot) {
|
|
128
166
|
report("warn", "no workflows root", "expected src/workflows/ or workflows/ — `kraftwerk init` scaffolds one");
|
package/dist/cli/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { CONFIG_SCHEMA_URL, gitignoreHas, SCHEMA_URL } from "../config.js";
|
|
5
|
+
import { DOTENV_FILE } from "../dotenv.js";
|
|
5
6
|
import { initBundle, writeConcept } from "../okf.js";
|
|
6
7
|
/**
|
|
7
8
|
* `kraftwerk init` — make any repository a kraftwerk consumer in one
|
|
@@ -25,6 +26,12 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
|
|
|
25
26
|
# repos: # git repositories the agents work on (uncomment both lines to enable)
|
|
26
27
|
# root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
|
|
27
28
|
# 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)
|
|
29
|
+
# public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
|
|
30
|
+
# 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)
|
|
31
|
+
# name: kraftwerk # locally-managed tunnel (cloudflared tunnel create/route dns); omit and export TUNNEL_TOKEN for a dashboard-managed one
|
|
32
|
+
# access: # verify the Access login on every request via public: (team = Zero Trust team name, aud = the application's audience tag)
|
|
33
|
+
# team: my-team
|
|
34
|
+
# aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
|
|
28
35
|
`;
|
|
29
36
|
const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
|
|
30
37
|
name: hello
|
|
@@ -127,24 +134,24 @@ export async function runInit(cwd) {
|
|
|
127
134
|
await writeConcept(knowledgeRoot, DEMO_BUNDLE, "playbooks/refunds", DEMO_CONCEPT, "kraftwerk-init");
|
|
128
135
|
created.push(bundleRel);
|
|
129
136
|
}
|
|
130
|
-
// .gitignore: the output dir
|
|
131
|
-
// gitlinks of the workspace repo). A missing file gets
|
|
132
|
-
// an existing one only the entries it lacks.
|
|
137
|
+
// .gitignore: the output dir, the repos root (clones must never become
|
|
138
|
+
// gitlinks of the workspace repo) and .env (secrets). A missing file gets
|
|
139
|
+
// all in one write; an existing one only the entries it lacks.
|
|
133
140
|
const gitignorePath = path.join(cwd, ".gitignore");
|
|
134
141
|
const gitignore = (await readFile(gitignorePath, "utf8").catch(() => null)) ?? null;
|
|
135
|
-
const entries = [`${DATA_DIR}/output
|
|
142
|
+
const entries = [`${DATA_DIR}/output/`, `${DATA_DIR}/repos/`, DOTENV_FILE];
|
|
136
143
|
if (gitignore === null) {
|
|
137
|
-
await writeFile(gitignorePath, entries.map((e) => `${e}
|
|
144
|
+
await writeFile(gitignorePath, entries.map((e) => `${e}\n`).join(""));
|
|
138
145
|
created.push(".gitignore");
|
|
139
146
|
}
|
|
140
147
|
else {
|
|
141
|
-
const missing = entries.filter((e) => !gitignoreHas(gitignore, e));
|
|
148
|
+
const missing = entries.filter((e) => !gitignoreHas(gitignore, e.replace(/\/$/, "")));
|
|
142
149
|
if (missing.length === 0) {
|
|
143
150
|
skipped.push(".gitignore");
|
|
144
151
|
}
|
|
145
152
|
else {
|
|
146
|
-
await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}${missing.map((e) => `${e}
|
|
147
|
-
created.push(`.gitignore (${missing.
|
|
153
|
+
await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}${missing.map((e) => `${e}\n`).join("")}`);
|
|
154
|
+
created.push(`.gitignore (${missing.join(", ")} added)`);
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
for (const f of created)
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -15,6 +15,9 @@ 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";
|
|
19
|
+
import { applyDotenv } from "../dotenv.js";
|
|
20
|
+
import { resolveProject } from "../config.js";
|
|
18
21
|
import { registerRoutineCommands } from "./routines.js";
|
|
19
22
|
import { listRuns, showRun } from "./runs.js";
|
|
20
23
|
import { runUi } from "./ui.js";
|
|
@@ -30,6 +33,7 @@ import { runUi } from "./ui.js";
|
|
|
30
33
|
* kraftwerk ui start the inspector web UI (localhost:1981)
|
|
31
34
|
* kraftwerk projects ... known projects on this machine (list/start/forget)
|
|
32
35
|
* kraftwerk repos ... repositories the agents work on (list/add/update/remove)
|
|
36
|
+
* kraftwerk tunnel [setup <host>] Cloudflare Tunnel to the inspector: run it alone, or set one up
|
|
33
37
|
* kraftwerk doctor preflight: harness CLIs, docker, workflows, env
|
|
34
38
|
* kraftwerk validate [paths...] validate without executing
|
|
35
39
|
*
|
|
@@ -59,7 +63,15 @@ const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.
|
|
|
59
63
|
const program = new Command()
|
|
60
64
|
.name("kraftwerk")
|
|
61
65
|
.description("kraftwerk — agentic workspace for teams: agents, skills, knowledge, workflows, and the inspector UI")
|
|
62
|
-
.version(pkg.version)
|
|
66
|
+
.version(pkg.version)
|
|
67
|
+
// The project's .env, before any command runs (and so before `kraftwerk
|
|
68
|
+
// ui` spawns its server, and again in that server on every restart). A
|
|
69
|
+
// broken kraftwerk.yml is the command's own error to report, not the hook's.
|
|
70
|
+
.hook("preAction", async () => {
|
|
71
|
+
const project = await resolveProject(process.cwd()).catch(() => null);
|
|
72
|
+
if (project)
|
|
73
|
+
await applyDotenv(project.root);
|
|
74
|
+
});
|
|
63
75
|
const agentLabel = (workflow) => workflow.meta.agents
|
|
64
76
|
.map((a) => {
|
|
65
77
|
const where = [a.harness && a.harness !== "claude" ? a.harness : "", a.protocol === "acp" ? "acp" : ""]
|
|
@@ -270,6 +282,7 @@ registerRoutineCommands(program);
|
|
|
270
282
|
registerProjectCommands(program);
|
|
271
283
|
registerRepoCommands(program);
|
|
272
284
|
registerVibeableCommands(program);
|
|
285
|
+
registerTunnelCommands(program);
|
|
273
286
|
program
|
|
274
287
|
.command("ui")
|
|
275
288
|
.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
|
+
}
|