@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/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)) {
|
package/dist/dotenv.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
|
|
3
|
+
* this process at CLI start (a commander preAction hook in kraftwerk.ts),
|
|
4
|
+
* so every kraftwerk process sees them and everything it spawns inherits
|
|
5
|
+
* them — the inspector, the chat agents and routines, workflow runs, the
|
|
6
|
+
* tunnel (TUNNEL_TOKEN), `requires:` checks.
|
|
7
|
+
*
|
|
8
|
+
* A variable already set in the shell wins over the file, the dotenv
|
|
9
|
+
* convention, with one exception that makes restarts work: a value this
|
|
10
|
+
* process inherited from an earlier kraftwerk process that itself took it
|
|
11
|
+
* from a `.env` is not a shell setting. Those keys are listed in
|
|
12
|
+
* KRAFTWERK_DOTENV_KEYS by whoever injected them, so a restarted server
|
|
13
|
+
* (the `kraftwerk ui` supervisor respawns it with its own environment)
|
|
14
|
+
* re-reads the file and applies changed values, and a project launched
|
|
15
|
+
* from another workspace's inspector drops that workspace's variables
|
|
16
|
+
* instead of running with them.
|
|
17
|
+
*
|
|
18
|
+
* Never synced: `.env` is on the workspace git's deny list (git.ts) and in
|
|
19
|
+
* the .gitignore `kraftwerk init` writes.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DOTENV_FILE = ".env";
|
|
22
|
+
export declare const DOTENV_MARKER = "KRAFTWERK_DOTENV_KEYS";
|
|
23
|
+
/** Parse dotenv text: comments, `export ` prefixes, single/double quotes, `\n` in double quotes. */
|
|
24
|
+
export declare function parseDotenv(text: string): Record<string, string>;
|
|
25
|
+
export interface DotenvResult {
|
|
26
|
+
/** Absolute path of the file, undefined when the project has none. */
|
|
27
|
+
file?: string;
|
|
28
|
+
/** Keys set from the file. */
|
|
29
|
+
applied: string[];
|
|
30
|
+
/** Keys in the file left alone because the shell had set them. */
|
|
31
|
+
kept: string[];
|
|
32
|
+
/** Keys an earlier kraftwerk process had injected that this file no longer has. */
|
|
33
|
+
removed: string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Apply `<root>/.env` to process.env (see the module comment for the
|
|
37
|
+
* precedence). Idempotent: a second call re-applies the same keys. Without
|
|
38
|
+
* a file it still drops keys an earlier process injected.
|
|
39
|
+
*/
|
|
40
|
+
export declare function applyDotenv(root: string): Promise<DotenvResult>;
|
package/dist/dotenv.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
|
|
5
|
+
* this process at CLI start (a commander preAction hook in kraftwerk.ts),
|
|
6
|
+
* so every kraftwerk process sees them and everything it spawns inherits
|
|
7
|
+
* them — the inspector, the chat agents and routines, workflow runs, the
|
|
8
|
+
* tunnel (TUNNEL_TOKEN), `requires:` checks.
|
|
9
|
+
*
|
|
10
|
+
* A variable already set in the shell wins over the file, the dotenv
|
|
11
|
+
* convention, with one exception that makes restarts work: a value this
|
|
12
|
+
* process inherited from an earlier kraftwerk process that itself took it
|
|
13
|
+
* from a `.env` is not a shell setting. Those keys are listed in
|
|
14
|
+
* KRAFTWERK_DOTENV_KEYS by whoever injected them, so a restarted server
|
|
15
|
+
* (the `kraftwerk ui` supervisor respawns it with its own environment)
|
|
16
|
+
* re-reads the file and applies changed values, and a project launched
|
|
17
|
+
* from another workspace's inspector drops that workspace's variables
|
|
18
|
+
* instead of running with them.
|
|
19
|
+
*
|
|
20
|
+
* Never synced: `.env` is on the workspace git's deny list (git.ts) and in
|
|
21
|
+
* the .gitignore `kraftwerk init` writes.
|
|
22
|
+
*/
|
|
23
|
+
export const DOTENV_FILE = ".env";
|
|
24
|
+
export const DOTENV_MARKER = "KRAFTWERK_DOTENV_KEYS";
|
|
25
|
+
const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
26
|
+
/** Parse dotenv text: comments, `export ` prefixes, single/double quotes, `\n` in double quotes. */
|
|
27
|
+
export function parseDotenv(text) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.startsWith("#"))
|
|
32
|
+
continue;
|
|
33
|
+
const eq = line.indexOf("=");
|
|
34
|
+
if (eq < 0)
|
|
35
|
+
continue;
|
|
36
|
+
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
|
37
|
+
if (!KEY.test(key))
|
|
38
|
+
continue;
|
|
39
|
+
let value = line.slice(eq + 1).trim();
|
|
40
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
41
|
+
value = value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
42
|
+
}
|
|
43
|
+
else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
44
|
+
value = value.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// An unquoted value ends at a comment: `PORT=1981 # inspector`.
|
|
48
|
+
value = value.replace(/\s+#.*$/, "").trim();
|
|
49
|
+
}
|
|
50
|
+
out[key] = value;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Apply `<root>/.env` to process.env (see the module comment for the
|
|
56
|
+
* precedence). Idempotent: a second call re-applies the same keys. Without
|
|
57
|
+
* a file it still drops keys an earlier process injected.
|
|
58
|
+
*/
|
|
59
|
+
export async function applyDotenv(root) {
|
|
60
|
+
const file = path.join(root, DOTENV_FILE);
|
|
61
|
+
const text = await readFile(file, "utf8").catch(() => undefined);
|
|
62
|
+
const parsed = text === undefined ? {} : parseDotenv(text);
|
|
63
|
+
const injected = new Set((process.env[DOTENV_MARKER] ?? "").split(",").filter(Boolean));
|
|
64
|
+
const applied = [];
|
|
65
|
+
const kept = [];
|
|
66
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
67
|
+
// An empty shell value is as good as unset (`requires:` treats it so too).
|
|
68
|
+
if (!process.env[key] || injected.has(key)) {
|
|
69
|
+
process.env[key] = value;
|
|
70
|
+
applied.push(key);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
kept.push(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const removed = [...injected].filter((key) => !(key in parsed));
|
|
77
|
+
for (const key of removed)
|
|
78
|
+
delete process.env[key];
|
|
79
|
+
if (applied.length > 0)
|
|
80
|
+
process.env[DOTENV_MARKER] = applied.join(",");
|
|
81
|
+
else
|
|
82
|
+
delete process.env[DOTENV_MARKER];
|
|
83
|
+
return { file: text === undefined ? undefined : file, applied, kept, removed };
|
|
84
|
+
}
|
|
@@ -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);
|