@netnodeag/kraftwerk 0.64.0 → 0.66.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.
Files changed (97) hide show
  1. package/README.md +126 -138
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/devices.d.ts +14 -0
  6. package/dist/cli/devices.js +53 -0
  7. package/dist/cli/doctor-legacy.js +9 -1
  8. package/dist/cli/doctor.js +17 -30
  9. package/dist/cli/init.js +1 -6
  10. package/dist/cli/kraftwerk.js +8 -3
  11. package/dist/cli/ui.d.ts +9 -0
  12. package/dist/cli/ui.js +47 -23
  13. package/dist/cli/workspaces.js +5 -3
  14. package/dist/client/index.d.ts +17 -0
  15. package/dist/client/index.js +10 -2
  16. package/dist/client/live.d.ts +1 -2
  17. package/dist/client/live.js +4 -2
  18. package/dist/client/routes.d.ts +8 -0
  19. package/dist/client/routes.js +8 -0
  20. package/dist/config.d.ts +18 -55
  21. package/dist/config.js +39 -92
  22. package/dist/core/chat/context.js +1 -1
  23. package/dist/core/chat/pi.js +2 -1
  24. package/dist/core/chat/sessions.d.ts +1 -1
  25. package/dist/core/chat/sessions.js +1 -1
  26. package/dist/core/chat/state.d.ts +2 -0
  27. package/dist/core/chat/state.js +5 -0
  28. package/dist/core/daemon.d.ts +27 -0
  29. package/dist/core/daemon.js +61 -0
  30. package/dist/core/devices.d.ts +35 -0
  31. package/dist/core/devices.js +156 -0
  32. package/dist/core/env.d.ts +16 -0
  33. package/dist/core/env.js +45 -0
  34. package/dist/core/git.d.ts +2 -0
  35. package/dist/core/git.js +20 -4
  36. package/dist/core/instances.d.ts +32 -12
  37. package/dist/core/instances.js +136 -64
  38. package/dist/core/routines.d.ts +6 -0
  39. package/dist/core/routines.js +11 -0
  40. package/dist/core/runner.js +7 -3
  41. package/dist/core/vibeables.d.ts +2 -0
  42. package/dist/core/vibeables.js +17 -5
  43. package/dist/core/workspace.d.ts +13 -0
  44. package/dist/core/workspace.js +25 -0
  45. package/dist/dotenv.d.ts +2 -2
  46. package/dist/dotenv.js +2 -2
  47. package/dist/server/api/devices.d.ts +20 -0
  48. package/dist/server/api/devices.js +55 -0
  49. package/dist/server/api/hub.d.ts +25 -0
  50. package/dist/server/api/hub.js +32 -0
  51. package/dist/server/api/index.d.ts +42 -2
  52. package/dist/server/api/index.js +5 -1
  53. package/dist/server/api/router.d.ts +21 -6
  54. package/dist/server/api/router.js +37 -9
  55. package/dist/server/api/socket.d.ts +15 -1
  56. package/dist/server/api/socket.js +59 -8
  57. package/dist/server/api/workspace.d.ts +2 -0
  58. package/dist/server/api/workspace.js +4 -2
  59. package/dist/server/bind.d.ts +16 -0
  60. package/dist/server/bind.js +25 -0
  61. package/dist/server/cloud.d.ts +5 -1
  62. package/dist/server/cloud.js +89 -56
  63. package/dist/server/hub-context.d.ts +7 -0
  64. package/dist/server/hub-context.js +18 -0
  65. package/dist/server/live.d.ts +2 -0
  66. package/dist/server/live.js +9 -0
  67. package/dist/server/preview.js +2 -1
  68. package/dist/server/server.d.ts +58 -1
  69. package/dist/server/server.js +267 -103
  70. package/dist/server/target.d.ts +20 -0
  71. package/dist/server/target.js +18 -0
  72. package/dist/server/trust.d.ts +41 -0
  73. package/dist/server/trust.js +42 -0
  74. package/package.json +1 -1
  75. package/schema/kraftwerk.schema.json +7 -46
  76. package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
  77. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  78. package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
  79. package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
  80. package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
  81. package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
  82. package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
  83. package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
  84. package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
  85. package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
  86. package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
  87. package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
  88. package/web/dist/assets/index-BAYs-2WC.css +2 -0
  89. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  90. package/web/dist/index.html +2 -2
  91. package/dist/cli/tunnel.d.ts +0 -10
  92. package/dist/cli/tunnel.js +0 -280
  93. package/dist/server/access.d.ts +0 -32
  94. package/dist/server/access.js +0 -121
  95. package/web/dist/assets/dist-CVqmJrYd.js +0 -1
  96. package/web/dist/assets/index-BR7HvDzD.js +0 -229
  97. package/web/dist/assets/index-Bra74Nxe.css +0 -2
@@ -1,36 +1,28 @@
1
- import { existsSync, promises as fs } from "node:fs";
1
+ import { promises as fs } from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
5
- import { publicHostFor, resolveWorkspace, tunnelFor } from "../config.js";
6
- import { ACCESS_HEADER, verifyAccessToken } from "./access.js";
7
- import { cloudGoodbye, startCloudSync, stopCloudSync } from "./cloud.js";
8
- import { disposeAllBackends } from "../core/chat/sessions.js";
5
+ import { cloudGoodbye, cloudGoodbyeAll, startCloudSync, stopCloudSync } from "./cloud.js";
6
+ import { disposeAllBackends, disposeWorkspaceBackends } from "../core/chat/sessions.js";
9
7
  import { markWorkspaceStopped, registerInstance, registerWorkspace, unregisterInstance } from "../core/instances.js";
10
- import { startGitSync } from "../core/git.js";
11
- import { disposeAllDevs } from "../core/vibeables.js";
8
+ import { startGitSync, stopGitSync } from "../core/git.js";
9
+ import { disposeAllDevs, disposeWorkspaceDevs } from "../core/vibeables.js";
12
10
  import { proxyUpgrade, serveVibeable } from "./preview.js";
13
- import { startRoutineScheduler } from "../core/routines.js";
11
+ import { startRoutineScheduler, stopRoutineScheduler } from "../core/routines.js";
12
+ import { readWorkspaceDotenv } from "../core/env.js";
13
+ import { closeWatches } from "./live.js";
14
+ import { MACHINE_PATHS, runInHub } from "./hub-context.js";
14
15
  import { apiRouter } from "./api/index.js";
15
16
  import { serveSocket } from "./api/socket.js";
16
17
  import { acceptWebSocket } from "./ws.js";
17
18
  import { json } from "./api/router.js";
18
19
  import { MIME } from "./api/mime.js";
19
20
  import { getPkgVersion, RESTART_EXIT_CODE } from "./version.js";
21
+ import { trustOf } from "./trust.js";
22
+ import { targetOf } from "./target.js";
23
+ import { isDir, resolveWorkspace, workspaceSlug } from "../config.js";
20
24
  export { RESTART_EXIT_CODE };
21
- /**
22
- * Interface to bind. Loopback by default: the UI is unauthenticated and its
23
- * chat runs coding agents against the repo, so it must not appear on the LAN
24
- * because someone started it on a laptop in a café. Inside a container the
25
- * default flips to all interfaces — loopback there would make the published
26
- * port unreachable while the in-container health check keeps passing, and
27
- * the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
28
- * KRAFTWERK_UI_HOST overrides either way.
29
- */
30
- const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv");
31
- const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
32
- const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
33
- const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
25
+ import { IN_CONTAINER, INSPECTOR_HOST, LOOPBACK_BIND, LOOPBACK_NAMES } from "./bind.js";
34
26
  /**
35
27
  * Whether the Host header names this server. A loopback bind alone does not
36
28
  * keep other sites out: a page on evil.example can re-point that name at
@@ -46,7 +38,7 @@ const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
46
38
  * browser only sends it after a CORS preflight, which this server never
47
39
  * answers, and a form post cannot set headers at all.
48
40
  */
49
- function hostAllowed(req, { publicHost }) {
41
+ function hostAllowed(req) {
50
42
  if (!LOOPBACK_BIND)
51
43
  return true;
52
44
  if (forwardedHost(req))
@@ -55,33 +47,7 @@ function hostAllowed(req, { publicHost }) {
55
47
  if (!host)
56
48
  return true;
57
49
  const name = hostnameOf(host);
58
- return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost") || (!!publicHost && name === publicHost);
59
- }
60
- /** Whether the browser addressed the public hostname (directly or, via a proxy, in X-Forwarded-Host). */
61
- function viaPublicHost(req, { publicHost }) {
62
- if (!publicHost)
63
- return false;
64
- const host = forwardedHost(req) || req.headers.host;
65
- return !!host && hostnameOf(host) === publicHost;
66
- }
67
- /**
68
- * The Access gate: a request that arrived via the public hostname must
69
- * carry a valid Cloudflare Access token when `tunnel.access` is configured.
70
- * Requests addressed to a loopback name are the operator's own browser on
71
- * this machine and pass; bound to loopback, only the tunnel (or a local
72
- * proxy) can deliver the public name in the first place. Returns the reason
73
- * to refuse, or undefined to proceed.
74
- */
75
- async function accessRefusal(req, gate) {
76
- const { access } = gate;
77
- if (!access || !viaPublicHost(req, gate))
78
- return undefined;
79
- const raw = req.headers[ACCESS_HEADER];
80
- const token = Array.isArray(raw) ? raw[0] : raw;
81
- if (!token)
82
- return "Cloudflare Access token missing";
83
- const result = await verifyAccessToken(token, access);
84
- return result.ok ? undefined : `Cloudflare Access token refused: ${result.reason}`;
50
+ return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost");
85
51
  }
86
52
  /** First X-Forwarded-Host value, or undefined when no proxy set one. */
87
53
  function forwardedHost(req) {
@@ -155,91 +121,289 @@ async function serveStatic(res, staticDir, pathname) {
155
121
  });
156
122
  res.end(buf);
157
123
  }
158
- /** Start the server; resolves once it listens. Runs until the process ends. */
159
- export async function startInspector(opts) {
160
- // Every request, timer and listener below runs in this workspace (see workspace.ts).
161
- const ws = setDefaultWorkspace(new Workspace({ outputDir: opts.outputDir, root: opts.root }));
162
- // Read once: like the port, the public hostname takes effect on restart.
163
- const project = await resolveWorkspace(ws.root).catch(() => null);
164
- // This server's own: several servers in one process each guard their own workspace.
165
- const gate = { publicHost: (project && publicHostFor(project)) ?? "", access: project ? tunnelFor(project)?.access : undefined };
166
- ws.run(() => {
167
- startRoutineScheduler();
168
- startGitSync();
124
+ /** Other peers count as this machine inside a container (the port mapping is the boundary) or when asked to — the behaviour before devices. */
125
+ const TRUST_NETWORK = IN_CONTAINER || process.env.KRAFTWERK_UI_TRUST_NETWORK === "1";
126
+ /** Every request pairs, this machine's too — behind a local reverse proxy that does not send X-Forwarded-* headers. */
127
+ const REQUIRE_PAIRING = process.env.KRAFTWERK_UI_REQUIRE_PAIRING === "1";
128
+ const hubs = new Set();
129
+ let exitHandlers = false;
130
+ /** Every hub of this process lets go of its workspaces — on SIGINT/SIGTERM and exit (sync parts only there). */
131
+ function installExitHandlers() {
132
+ if (exitHandlers)
133
+ return;
134
+ exitHandlers = true;
135
+ // Something still running for a workspace that was closed (a late callback) finds its state gone and throws
136
+ // "workspace closed". That must not take the other workspaces down with it; anything else still crashes, as by default.
137
+ const closedOnly = (err) => err instanceof Error && err.message === "workspace closed";
138
+ process.on("unhandledRejection", (err) => {
139
+ if (closedOnly(err))
140
+ return;
141
+ throw err;
142
+ });
143
+ process.on("uncaughtException", (err) => {
144
+ if (closedOnly(err))
145
+ return;
146
+ console.error(err);
147
+ process.exit(1);
169
148
  });
170
- // Chat agent subprocesses must die with the server — signals bypass
171
- // "exit" handlers, so hook the signals themselves.
149
+ // Chat agent subprocesses must die with the server — signals bypass "exit" handlers, so hook the signals themselves.
172
150
  for (const sig of ["SIGINT", "SIGTERM"]) {
173
151
  process.once(sig, () => {
174
152
  disposeAllBackends();
175
153
  disposeAllDevs();
176
- unregisterInstance();
154
+ // Stamp first: unregistering forgets which workspaces this process served.
177
155
  markWorkspaceStopped();
178
- // The cloud goodbye is bounded (cloudGoodbye) and a no-op without cloud:, so the exit stays prompt.
179
- void cloudGoodbye().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
156
+ unregisterInstance();
157
+ // The cloud goodbye is bounded and a no-op without cloud:, so the exit stays prompt.
158
+ void cloudGoodbyeAll().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
180
159
  });
181
160
  }
182
161
  process.once("exit", (code) => {
183
162
  disposeAllBackends();
184
163
  disposeAllDevs();
185
- unregisterInstance();
186
- // A self-restart (new version) is not a stop — the project stays "running".
164
+ // A self-restart (new version) is not a stop — the workspaces stay "running".
187
165
  if (code !== RESTART_EXIT_CODE)
188
166
  markWorkspaceStopped();
167
+ unregisterInstance();
168
+ });
169
+ }
170
+ const listen = (server, port) => new Promise((resolve, reject) => {
171
+ server.once("error", reject);
172
+ server.listen(port, INSPECTOR_HOST, () => {
173
+ server.off("error", reject);
174
+ const addr = server.address();
175
+ resolve(typeof addr === "object" && addr ? addr.port : port);
189
176
  });
190
- const server = http.createServer((req, res) => ws.run(() => serve(req, res)));
191
- const serve = async (req, res) => {
177
+ });
178
+ /** A small page at a daemon's own address: the open workspaces, each a link. */
179
+ function hubPage(res, open) {
180
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
181
+ const rows = open.length
182
+ ? open.map((o) => `<li><a href="${esc(o.url)}/">${esc(o.name)}</a> <code>${esc(o.slug)}</code></li>`).join("")
183
+ : "<li>No workspace is open. Run <code>kraftwerk ui</code> in a workspace folder.</li>";
184
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
185
+ res.end(`<!doctype html><meta charset="utf-8"><title>kraftwerk</title><style>body{font:15px/1.6 system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;color:#1b1b1f}code{color:#46464f}</style><h1>kraftwerk</h1><ul>${rows}</ul>`);
186
+ }
187
+ export async function startHub(opts) {
188
+ const trustOpts = { trustLoopback: opts.trustLoopback !== false && !REQUIRE_PAIRING, trustNetwork: TRUST_NETWORK };
189
+ const bySlug = new Map();
190
+ const aliasServers = new Map();
191
+ /** The workspace reached without a slug on the hub's own port (standalone: the one it serves). */
192
+ let fallback;
193
+ /** Open sockets and the workspace each was opened for: closing that workspace closes them. */
194
+ const sockets = new Set();
195
+ const version = await getPkgVersion();
196
+ installExitHandlers();
197
+ /** Which open workspace a request is for: its slug, else the port it came in on, else the default. */
198
+ const pick = (target, portDefault) => target.slug !== undefined ? bySlug.get(target.slug) : portDefault;
199
+ const handler = (portDefault) => async (req, res) => {
192
200
  try {
193
- const url = new URL(req.url ?? "/", "http://localhost");
194
- if (!hostAllowed(req, gate))
201
+ if (!hostAllowed(req))
195
202
  return json(res, { error: "unexpected Host header" }, 421);
196
- const refusal = await accessRefusal(req, gate);
197
- if (refusal)
198
- return json(res, { error: refusal }, 401);
203
+ const target = targetOf(req, new URL(req.url ?? "/", "http://localhost"));
204
+ const open = pick(target, portDefault());
205
+ if (target.slug !== undefined && !open)
206
+ return json(res, { error: `no workspace "${target.slug}" here` }, 404);
207
+ const { url } = target;
208
+ const trust = await trustOf(req, url, trustOpts);
209
+ const run = (fn) => runInHub(hub, () => (open ? open.ws.run(fn) : fn()));
199
210
  if (url.pathname.startsWith("/api/")) {
200
211
  const method = req.method ?? "GET";
201
212
  if (method !== "GET" && method !== "HEAD" && !sameOrigin(req))
202
- json(res, { error: "cross-origin request refused" }, 403);
203
- else
204
- await apiRouter.handle(req, res, url);
213
+ return json(res, { error: "cross-origin request refused" }, 403);
214
+ if (!open && !MACHINE_PATHS.test(url.pathname)) {
215
+ return json(res, { error: "name a workspace: <slug>.localhost or /w/<slug>/", workspaces: [...bySlug.keys()] }, 404);
216
+ }
217
+ return await run(() => apiRouter.handle(req, res, url, trust));
218
+ }
219
+ // /vibeables/<slug>/… is an app's own files, served for the preview pane — workspace data, so not for unpaired callers.
220
+ if (url.pathname === "/vibeables" || url.pathname.startsWith("/vibeables/")) {
221
+ if (trust.kind === "none")
222
+ return json(res, { error: "pair this device first", pair: true }, 401);
223
+ if (!open)
224
+ return json(res, { error: "name a workspace: <slug>.localhost or /w/<slug>/" }, 404);
225
+ return await run(() => serveVibeable(req, res, url));
205
226
  }
206
- // /vibeables/<slug>/… is an app's own files, served for the preview pane.
207
- else if (url.pathname === "/vibeables" || url.pathname.startsWith("/vibeables/"))
208
- await serveVibeable(req, res, url);
209
- else
210
- await serveStatic(res, opts.staticDir, url.pathname);
227
+ // A daemon's own address lists its workspaces; anywhere else, the web UI's shell (public: an unpaired browser needs it to pair).
228
+ if (!open && (url.pathname === "/" || url.pathname === "/index.html"))
229
+ return hubPage(res, [...bySlug.values()]);
230
+ await serveStatic(res, opts.staticDir, url.pathname);
211
231
  }
212
232
  catch (err) {
213
233
  json(res, { error: err.message }, 500);
214
234
  }
215
235
  };
216
236
  // WebSocket upgrades: the control plane's socket (/api/ws), and a vibeable's dev server (HMR).
217
- server.on("upgrade", (req, socket, head) => {
218
- if (!hostAllowed(req, gate))
237
+ const upgrade = (portDefault) => (req, socket, head) => {
238
+ if (!hostAllowed(req))
219
239
  return void socket.destroy();
220
- ws.run(() => accessRefusal(req, gate).then(async (refusal) => {
221
- if (refusal)
222
- return void socket.destroy();
223
- if (new URL(req.url ?? "/", "http://localhost").pathname !== "/api/ws")
224
- return proxyUpgrade(req, socket, head);
240
+ (async () => {
241
+ const target = targetOf(req, new URL(req.url ?? "/", "http://localhost"));
242
+ const open = pick(target, portDefault());
243
+ if (target.slug !== undefined && !open)
244
+ return void socket.end("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n");
245
+ const { url } = target;
246
+ const trust = await trustOf(req, url, trustOpts, true);
247
+ if (trust.kind === "none")
248
+ return void socket.end("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n");
249
+ if (url.pathname !== "/api/ws") {
250
+ if (!open)
251
+ return void socket.destroy();
252
+ // The dev server's HMR socket: what it sees is the path without the workspace prefix.
253
+ req.url = url.pathname + url.search;
254
+ return runInHub(hub, () => open.ws.run(() => proxyUpgrade(req, socket, head)));
255
+ }
225
256
  // A socket is not covered by CORS: a page from another site could open one, so the Origin must be ours.
226
257
  if (!sameOrigin(req))
227
258
  return void socket.end("HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n");
228
259
  const conn = acceptWebSocket(req, socket, head);
229
- if (conn)
230
- serveSocket(conn, ws, await getPkgVersion());
231
- }, () => socket.destroy()));
260
+ if (!conn)
261
+ return;
262
+ // The connection's workspace is the one it was opened for; a message may name another (`ws`).
263
+ // Looked up per message: a workspace closed meanwhile is not served, a reopened one is the new one.
264
+ const own = open?.slug;
265
+ const resolve = (slug) => bySlug.get(slug ?? own ?? "")?.ws;
266
+ const entry = { slug: own, close: () => conn.close(4404, "workspace closed") };
267
+ sockets.add(entry);
268
+ conn.onClose(() => sockets.delete(entry));
269
+ runInHub(hub, () => serveSocket(conn, resolve, version, trust, () => trustOf(req, url, trustOpts, true), hub));
270
+ })().catch(() => socket.destroy());
271
+ };
272
+ const server = http.createServer(handler(() => fallback));
273
+ server.on("upgrade", upgrade(() => fallback));
274
+ const port = await listen(server, opts.port);
275
+ server.once("close", () => {
276
+ for (const s of aliasServers.values())
277
+ s.close();
232
278
  });
233
- return new Promise((resolve, reject) => {
234
- server.once("error", reject);
235
- server.once("close", stopCloudSync);
236
- server.listen(opts.port, INSPECTOR_HOST, () => ws.run(() => {
237
- const addr = server.address();
238
- const port = typeof addr === "object" && addr ? addr.port : opts.port;
239
- void registerInstance(port, ws.root);
240
- void registerWorkspace(ws.root);
241
- void getPkgVersion().then((v) => startCloudSync(port, v));
242
- resolve(server);
279
+ const hub = {
280
+ server,
281
+ port,
282
+ daemon: !!opts.daemon,
283
+ list: () => [...bySlug.values()],
284
+ // One open or close at a time: two overlapping opens of a root (the daemon reopening while `kraftwerk ui`
285
+ // asks, two clicks) must find each other, not make two copies of the workspace.
286
+ open: (root, o = {}) => serial(() => openNow(root, o)),
287
+ close: (root) => serial(() => closeNow(root)),
288
+ async stop() {
289
+ // Closing for good, not one by one: what was open stays noted for the next start.
290
+ const notify = opts.onChange;
291
+ opts.onChange = undefined;
292
+ for (const o of [...bySlug.values()])
293
+ await hub.close(o.root);
294
+ opts.onChange = notify;
295
+ hubs.delete(hub);
296
+ // A connected browser tab (its socket, a keep-alive connection) must not hold the stop open.
297
+ for (const s of [...sockets])
298
+ s.close();
299
+ const closing = new Promise((r) => server.close(() => r()));
300
+ server.closeAllConnections();
301
+ for (const s of aliasServers.values())
302
+ s.closeAllConnections();
303
+ await closing;
304
+ },
305
+ };
306
+ let lock = Promise.resolve();
307
+ function serial(fn) {
308
+ const run = lock.then(fn, fn);
309
+ lock = run.catch(() => { });
310
+ return run;
311
+ }
312
+ /** Tell the daemon what is open; a failure there is reported, the workspace stays as it is. */
313
+ async function noteChange() {
314
+ try {
315
+ await opts.onChange?.(hub);
316
+ }
317
+ catch (err) {
318
+ console.error(`kraftwerk: could not note the open workspaces: ${err.message}`);
319
+ }
320
+ }
321
+ async function openNow(root, o) {
322
+ // A folder that is gone (or a kraftwerk.yml that does not parse) is refused, not served as an empty workspace.
323
+ if (!(await isDir(path.resolve(root))))
324
+ throw new Error(`no such folder: ${root}`);
325
+ const ws0 = await resolveWorkspace(root);
326
+ const abs = ws0.root;
327
+ const already = [...bySlug.values()].find((x) => x.root === abs);
328
+ if (already)
329
+ return already;
330
+ const slug = workspaceSlug(ws0);
331
+ const clash = bySlug.get(slug);
332
+ if (clash)
333
+ throw new Error(`slug "${slug}" is already served for ${clash.root} — set \`slug:\` in one of the two kraftwerk.yml files`);
334
+ const ws = new Workspace({ outputDir: o.outputDir ?? ws0.outputDir, root: abs, slug, env: readWorkspaceDotenv(abs) });
335
+ const name = ws0.config.name ?? path.basename(abs);
336
+ const entry = { slug, root: abs, name, ws, url: opts.daemon ? `http://${slug}.localhost:${port}` : `http://localhost:${port}` };
337
+ // A daemon also listens on the workspace's own port, so its old address keeps working — when that port is free.
338
+ const own = ws0.config.port;
339
+ if (opts.daemon && own && own !== port) {
340
+ const alias = http.createServer(handler(() => entry));
341
+ alias.on("upgrade", upgrade(() => entry));
342
+ try {
343
+ entry.aliasPort = await listen(alias, own);
344
+ aliasServers.set(abs, alias);
345
+ }
346
+ catch {
347
+ alias.close();
348
+ }
349
+ }
350
+ bySlug.set(slug, entry);
351
+ if (!opts.daemon)
352
+ fallback ??= entry;
353
+ runInHub(hub, () => ws.run(() => {
354
+ startRoutineScheduler();
355
+ startGitSync();
356
+ // Best-effort, in the background: none of them may hold up (or, failing, break) the open.
357
+ void registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
358
+ void registerWorkspace(abs).catch(() => { });
359
+ void startCloudSync(entry.url, version).catch(() => { });
243
360
  }));
244
- });
361
+ await noteChange();
362
+ return entry;
363
+ }
364
+ async function closeNow(root) {
365
+ const abs = path.resolve(root);
366
+ const entry = [...bySlug.values()].find((x) => x.root === abs);
367
+ if (!entry)
368
+ return false;
369
+ bySlug.delete(entry.slug);
370
+ if (fallback === entry)
371
+ fallback = undefined;
372
+ for (const s of [...sockets])
373
+ if (s.slug === entry.slug)
374
+ s.close();
375
+ const alias = aliasServers.get(abs);
376
+ alias?.close();
377
+ alias?.closeAllConnections();
378
+ aliasServers.delete(abs);
379
+ await runInHub(hub, () => entry.ws.run(async () => {
380
+ stopRoutineScheduler();
381
+ stopGitSync();
382
+ closeWatches();
383
+ disposeWorkspaceBackends();
384
+ disposeWorkspaceDevs();
385
+ await cloudGoodbye();
386
+ stopCloudSync();
387
+ }));
388
+ unregisterInstance(abs);
389
+ markWorkspaceStopped(abs);
390
+ entry.ws.close();
391
+ await noteChange();
392
+ return true;
393
+ }
394
+ hubs.add(hub);
395
+ return hub;
396
+ }
397
+ /**
398
+ * A standalone server for one workspace (`kraftwerk ui`, tests): a hub
399
+ * serving it as its default, reached with or without its slug. Resolves
400
+ * once it listens.
401
+ */
402
+ export async function startInspector(opts) {
403
+ const hub = await startHub({ port: opts.port, staticDir: opts.staticDir, trustLoopback: opts.trustLoopback });
404
+ const open = await hub.open(opts.root ?? path.dirname(path.resolve(opts.outputDir)), { outputDir: opts.outputDir });
405
+ // Code that runs outside any request (the CLI side of this process) still finds its workspace.
406
+ setDefaultWorkspace(open.ws);
407
+ hub.server.once("close", () => void hub.stop());
408
+ return hub.server;
245
409
  }
@@ -0,0 +1,20 @@
1
+ import type http from "node:http";
2
+ /**
3
+ * Which workspace a request is for. A workspace is addressed by its slug
4
+ * (see workspaceSlug in config.ts), never by a port:
5
+ *
6
+ * - `<slug>.localhost[:port]` — the browser's form on this machine: every
7
+ * workspace its own origin, no setup (browsers resolve *.localhost here);
8
+ * - `/w/<slug>/…` — the path form, one origin for all (where a host name per
9
+ * workspace is not available); the prefix is stripped before routing;
10
+ * - neither — the server's own workspace (a port per workspace, as before).
11
+ *
12
+ * The socket names it per message instead (`ws`), see api/socket.ts.
13
+ */
14
+ export interface Target {
15
+ /** The slug the request named, if it named one. */
16
+ slug?: string;
17
+ /** The request's URL without the `/w/<slug>` prefix. */
18
+ url: URL;
19
+ }
20
+ export declare function targetOf(req: http.IncomingMessage, url: URL): Target;
@@ -0,0 +1,18 @@
1
+ const PATH = /^\/w\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/;
2
+ export function targetOf(req, url) {
3
+ const m = PATH.exec(url.pathname);
4
+ if (m) {
5
+ const rest = new URL(url.href);
6
+ rest.pathname = m[2] ?? "/";
7
+ return { slug: m[1], url: rest };
8
+ }
9
+ const host = req.headers.host ?? "";
10
+ let name = "";
11
+ try {
12
+ name = new URL(`http://${host}`).hostname;
13
+ }
14
+ catch { }
15
+ const label = name.endsWith(".localhost") ? name.slice(0, -".localhost".length) : "";
16
+ // One label only: a.b.localhost names no workspace.
17
+ return label && !label.includes(".") ? { slug: label, url } : { url };
18
+ }
@@ -0,0 +1,41 @@
1
+ import type http from "node:http";
2
+ import { type Device } from "../core/devices.js";
3
+ /**
4
+ * Who is asking. Every request and socket gets one of:
5
+ *
6
+ * - `local`: this machine — a loopback peer that addresses a loopback name
7
+ * (localhost, 127.0.0.1, ::1) and was not forwarded by a proxy. Everything,
8
+ * device management included. The name matters: a page in this machine's
9
+ * browser can point its own domain at 127.0.0.1 (DNS rebinding), and its
10
+ * requests then come from loopback with Host evil.example — not local. A
11
+ * reverse proxy on this machine also connects from loopback; it says so in
12
+ * X-Forwarded-For/-Host/-Proto (a proxy that sends none of them must not be
13
+ * used — or set KRAFTWERK_UI_REQUIRE_PAIRING=1, which makes every request
14
+ * pair). Inside a container — where the published port mapping is the
15
+ * boundary — and with KRAFTWERK_UI_TRUST_NETWORK=1, other peers count as
16
+ * local too (the behaviour before devices existed).
17
+ * - `device`: a paired device's token — `Authorization: Bearer` (apps),
18
+ * the `kw_device` cookie (a phone's browser, set on pairing), or `?token=`
19
+ * on the socket for runtimes whose WebSocket cannot send headers.
20
+ * - `none`: anyone else. Gets the web UI's shell (to show the pairing
21
+ * screen), the protocol description and the pairing route — nothing else.
22
+ */
23
+ export type Trust = {
24
+ kind: "local";
25
+ } | {
26
+ kind: "device";
27
+ device: Device;
28
+ } | {
29
+ kind: "none";
30
+ };
31
+ export declare const DEVICE_COOKIE = "kw_device";
32
+ /** The device token a request carries, if any. `?token=` only counts where asked (the socket). */
33
+ export declare function tokenOf(req: http.IncomingMessage, url: URL, allowQuery: boolean): string | undefined;
34
+ export interface TrustOptions {
35
+ /** A loopback peer is this machine (default). Off, every request needs a token — for tests and hardened setups. */
36
+ trustLoopback: boolean;
37
+ /** Non-loopback peers count as local: a container behind its port mapping, or KRAFTWERK_UI_TRUST_NETWORK=1. */
38
+ trustNetwork: boolean;
39
+ }
40
+ /** The request's trust: this machine (or a trusted network), else a paired device's token, else none. */
41
+ export declare function trustOf(req: http.IncomingMessage, url: URL, opts: TrustOptions, allowQueryToken?: boolean): Promise<Trust>;
@@ -0,0 +1,42 @@
1
+ import { deviceForToken } from "../core/devices.js";
2
+ export const DEVICE_COOKIE = "kw_device";
3
+ const LOOPBACK_PEER = /^(127\.|::1$|::ffff:127\.)/;
4
+ const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
5
+ /** The Host names a loopback name, and no proxy forwarded the request. */
6
+ function addressedLocally(req) {
7
+ if (req.headers["x-forwarded-for"] || req.headers["x-forwarded-host"] || req.headers["x-forwarded-proto"] || req.headers.forwarded)
8
+ return false;
9
+ const host = req.headers.host;
10
+ if (!host)
11
+ return false;
12
+ let name;
13
+ try {
14
+ name = new URL(`http://${host}`).hostname;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost");
20
+ }
21
+ /** The device token a request carries, if any. `?token=` only counts where asked (the socket). */
22
+ export function tokenOf(req, url, allowQuery) {
23
+ const auth = req.headers.authorization;
24
+ if (auth?.startsWith("Bearer "))
25
+ return auth.slice(7).trim();
26
+ const cookie = req.headers.cookie
27
+ ?.split(";")
28
+ .map((c) => c.trim())
29
+ .find((c) => c.startsWith(`${DEVICE_COOKIE}=`));
30
+ if (cookie)
31
+ return decodeURIComponent(cookie.slice(DEVICE_COOKIE.length + 1));
32
+ return allowQuery ? (url.searchParams.get("token") ?? undefined) : undefined;
33
+ }
34
+ /** The request's trust: this machine (or a trusted network), else a paired device's token, else none. */
35
+ export async function trustOf(req, url, opts, allowQueryToken = false) {
36
+ const peer = req.socket.remoteAddress ?? "";
37
+ if ((opts.trustLoopback && LOOPBACK_PEER.test(peer) && addressedLocally(req)) || (opts.trustNetwork && !LOOPBACK_PEER.test(peer)))
38
+ return { kind: "local" };
39
+ const token = tokenOf(req, url, allowQueryToken);
40
+ const device = token ? await deviceForToken(token) : null;
41
+ return device ? { kind: "device", device } : { kind: "none" };
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.64.0",
3
+ "version": "0.66.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",
@@ -10,6 +10,11 @@
10
10
  "type": "string",
11
11
  "description": "Display name of the project (\"environment\"), shown in the inspector header and browser tab"
12
12
  },
13
+ "slug": {
14
+ "type": "string",
15
+ "pattern": "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$",
16
+ "description": "This workspace's address on this machine (<slug>.localhost, /w/<slug>/). Unique per machine. Default: the folder name. Unlike name, it changes only when you mean to move the workspace."
17
+ },
13
18
  "icon": {
14
19
  "type": "string",
15
20
  "description": "Emoji used as the inspector favicon (browser-tab icon), e.g. \"⚡\""
@@ -161,57 +166,13 @@
161
166
  }
162
167
  }
163
168
  },
164
- "public": {
165
- "type": "string",
166
- "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)."
167
- },
168
- "tunnel": {
169
- "type": [
170
- "object",
171
- "null"
172
- ],
173
- "additionalProperties": false,
174
- "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.",
175
- "properties": {
176
- "enabled": {
177
- "type": "boolean",
178
- "description": "Turn the feature off while keeping the config. Default: true when this block exists"
179
- },
180
- "name": {
181
- "type": "string",
182
- "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
183
- "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"
184
- },
185
- "access": {
186
- "type": "object",
187
- "additionalProperties": false,
188
- "required": [
189
- "team",
190
- "aud"
191
- ],
192
- "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",
193
- "properties": {
194
- "team": {
195
- "type": "string",
196
- "pattern": "^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$",
197
- "description": "Zero Trust team name: the subdomain of https://<team>.cloudflareaccess.com"
198
- },
199
- "aud": {
200
- "type": "string",
201
- "pattern": "^[a-fA-F0-9]{64}$",
202
- "description": "Application Audience (AUD) tag of the Access application, shown on its overview page in the Zero Trust dashboard"
203
- }
204
- }
205
- }
206
- }
207
- },
208
169
  "cloud": {
209
170
  "type": [
210
171
  "object",
211
172
  "null"
212
173
  ],
213
174
  "additionalProperties": false,
214
- "description": "kraftwerk cloud manager — ~/.kraftwerk made reachable over the internet. ON BY DEFAULT: every `kraftwerk ui` registers on start and heartbeats while it runs (name, icon, colour, root, machine, version, public URL, agent and channel roster). `enabled: false` opts out. No credential belongs here: the cloud hands the instance its identity and a claim code (Settings → Cloud), and an unattended server uses KRAFTWERK_CLOUD_TOKEN in .env.",
175
+ "description": "kraftwerk cloud manager — ~/.kraftwerk made reachable over the internet. ON BY DEFAULT: every `kraftwerk ui` registers on start and heartbeats while it runs (name, icon, colour, root, machine, version, agent and channel roster). `enabled: false` opts out. No credential belongs here: the cloud hands the instance its identity and a claim code (Settings → Cloud), and an unattended server uses KRAFTWERK_CLOUD_TOKEN in .env.",
215
176
  "properties": {
216
177
  "enabled": {
217
178
  "type": "boolean",
@@ -230,4 +191,4 @@
230
191
  }
231
192
  }
232
193
  }
233
- }\n
194
+ }