@netnodeag/kraftwerk 0.34.1 → 0.35.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 (36) hide show
  1. package/README.md +15 -1
  2. package/dist/config.d.ts +24 -0
  3. package/dist/config.js +42 -2
  4. package/dist/inspector/git.d.ts +83 -0
  5. package/dist/inspector/git.js +651 -0
  6. package/dist/inspector/instances.js +9 -1
  7. package/dist/inspector/server.js +132 -3
  8. package/dist/inspector/settings.d.ts +11 -1
  9. package/dist/inspector/settings.js +45 -0
  10. package/dist/runner/docker.d.ts +1 -0
  11. package/dist/runner/docker.js +1 -1
  12. package/inspector/dist/assets/{editor-CXrDJOv_.js → editor-BABv2LmA.js} +3 -3
  13. package/inspector/dist/assets/index-BXspF2KR.css +1 -0
  14. package/inspector/dist/assets/{index-DAi1VtJ8.js → index-BbTer62d.js} +1 -1
  15. package/inspector/dist/assets/{index-C3hpUVfp.js → index-Bmm4zJNU.js} +1 -1
  16. package/inspector/dist/assets/{index-x5G6Np7T.js → index-ByWpqzAI.js} +1 -1
  17. package/inspector/dist/assets/{index-Upf-BEsx.js → index-CCACy6uF.js} +1 -1
  18. package/inspector/dist/assets/{index-BNcXQYbv.js → index-Ca9Qs5LZ.js} +1 -1
  19. package/inspector/dist/assets/{index-Bumwg9SM.js → index-CdwTmXTV.js} +1 -1
  20. package/inspector/dist/assets/{index-CMMYSoL3.js → index-CjlJeW9z.js} +1 -1
  21. package/inspector/dist/assets/{index-CszaAR9T.js → index-CzPQygR7.js} +1 -1
  22. package/inspector/dist/assets/{index-DrSWAGft.js → index-DCR7u4Xa.js} +1 -1
  23. package/inspector/dist/assets/{index-BxEG-IdY.js → index-DbWUNbt-.js} +1 -1
  24. package/inspector/dist/assets/{index-BULB2rC_.js → index-DuPp3MUc.js} +1 -1
  25. package/inspector/dist/assets/{index-CNJyAniG.js → index-DuwmzZAr.js} +1 -1
  26. package/inspector/dist/assets/{index-CUCCMUMY.js → index-Gx93brFq.js} +1 -1
  27. package/inspector/dist/assets/{index-BIDsfw8C.js → index-HBdsdJhD.js} +1 -1
  28. package/inspector/dist/assets/{index-7J4zAS6J.js → index-hkVov8T4.js} +1 -1
  29. package/inspector/dist/assets/{index-BQMgADlc.js → index-sLxulPOm.js} +1 -1
  30. package/inspector/dist/assets/index-uEig4--d.js +217 -0
  31. package/inspector/dist/assets/{index-ChuqpBnv.js → index-wkYwz0Tl.js} +1 -1
  32. package/inspector/dist/index.html +2 -2
  33. package/package.json +5 -2
  34. package/schema/kraftwerk.schema.json +35 -1
  35. package/inspector/dist/assets/index-B7rwNdVj.css +0 -1
  36. package/inspector/dist/assets/index-BBGjdXk1.js +0 -213
@@ -1,4 +1,4 @@
1
- import { promises as fs } from "node:fs";
1
+ import { existsSync, promises as fs } from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
@@ -11,6 +11,7 @@ import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept,
11
11
  import { deleteMember, getMember, listMembers, saveMember, setMemberArchived, teamRoot, } from "./team.js";
12
12
  import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
13
13
  import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectStopped, registerInstance, registerProject, startProject, stopProject, tildify, unregisterInstance, } from "./instances.js";
14
+ import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
14
15
  import { getSettings, saveSettings } from "./settings.js";
15
16
  import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
16
17
  /**
@@ -57,6 +58,59 @@ function readBody(req) {
57
58
  req.on("error", reject);
58
59
  });
59
60
  }
61
+ /**
62
+ * Interface to bind. Loopback by default: the UI is unauthenticated and its
63
+ * chat runs coding agents against the repo, so it must not appear on the LAN
64
+ * because someone started it on a laptop in a café. Inside a container the
65
+ * default flips to all interfaces — loopback there would make the published
66
+ * port unreachable while the in-container health check keeps passing, and
67
+ * the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
68
+ * KRAFTWERK_UI_HOST overrides either way.
69
+ */
70
+ const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv");
71
+ const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
72
+ const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
73
+ const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
74
+ /**
75
+ * Whether the Host header names this server. A loopback bind alone does not
76
+ * keep other sites out: a page on evil.example can re-point that name at
77
+ * 127.0.0.1 after it loaded (DNS rebinding) and then talk to us as if it
78
+ * were same-origin — every check that compares Origin to Host passes,
79
+ * because both say evil.example. So while bound to loopback, only loopback
80
+ * names are served. Bound elsewhere (a container behind a proxy) the name
81
+ * is whatever the proxy forwards, and the proxy is the boundary.
82
+ *
83
+ * A reverse proxy on the same machine talks to the loopback bind with the
84
+ * browser's Host, which is not a loopback name. It says so in
85
+ * X-Forwarded-Host, and that header cannot come from a rebinding page: a
86
+ * browser only sends it after a CORS preflight, which this server never
87
+ * answers, and a form post cannot set headers at all.
88
+ */
89
+ function hostAllowed(req) {
90
+ if (!LOOPBACK_BIND)
91
+ return true;
92
+ if (forwardedHost(req))
93
+ return true;
94
+ const host = req.headers.host;
95
+ if (!host)
96
+ return true;
97
+ const name = hostnameOf(host);
98
+ return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost");
99
+ }
100
+ /** First X-Forwarded-Host value, or undefined when no proxy set one. */
101
+ function forwardedHost(req) {
102
+ const forwarded = req.headers["x-forwarded-host"];
103
+ return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0].trim() || undefined;
104
+ }
105
+ /** "localhost:1981" → "localhost", "[::1]:1981" → "[::1]"; "" when unparsable. */
106
+ function hostnameOf(host) {
107
+ try {
108
+ return new URL(`http://${host}`).hostname;
109
+ }
110
+ catch {
111
+ return "";
112
+ }
113
+ }
60
114
  /** Exit code the `kraftwerk ui` supervisor treats as "relaunch me". */
61
115
  export const RESTART_EXIT_CODE = 75;
62
116
  /** Whether a supervisor is around to respawn us (set by `kraftwerk ui`). */
@@ -87,9 +141,48 @@ async function getPkgName() {
87
141
  return "";
88
142
  }
89
143
  }
144
+ /**
145
+ * Whether a state-changing request came from this UI rather than from some
146
+ * other page the user happens to have open. The inspector has no login of
147
+ * its own: every POST here writes files, commits, pushes, or starts a coding
148
+ * agent, and a cross-site form post needs no preflight to reach us. Browsers
149
+ * attach Origin to every POST/PUT/DELETE, form submissions included, so a
150
+ * mismatch is a forgery. A missing Origin is a non-browser client (curl, a
151
+ * script), which was never the attack this guards against.
152
+ */
153
+ function sameOrigin(req) {
154
+ if (req.headers["sec-fetch-site"] === "cross-site")
155
+ return false;
156
+ const origin = req.headers.origin;
157
+ if (origin === undefined)
158
+ return true;
159
+ // A sandboxed iframe or a redirected form sends "null", which matches no host.
160
+ if (origin === "null")
161
+ return false;
162
+ // A reverse proxy that rewrites Host to the upstream (nginx by default)
163
+ // says who the browser addressed in X-Forwarded-Host. Trusting it is safe
164
+ // here: a browser cannot set that header without a CORS preflight, which
165
+ // this API never answers, and a form post cannot set headers at all.
166
+ const host = forwardedHost(req) || req.headers.host;
167
+ if (!host)
168
+ return false;
169
+ try {
170
+ // Parse the host under the origin's scheme so a default port written
171
+ // out by the proxy ("kw.example.com:443") compares equal to the
172
+ // browser's Origin, which never carries one.
173
+ const o = new URL(origin);
174
+ return o.host === new URL(`${o.protocol}//${host}`).host;
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ }
90
180
  async function handleApi(req, res, url) {
91
181
  const seg = url.pathname.split("/").filter(Boolean); // ["api", ...]
92
182
  const method = req.method ?? "GET";
183
+ if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
184
+ return json(res, { error: "cross-origin request refused" }, 403);
185
+ }
93
186
  // GET /api/meta — package version + project name ("environment") for the UI.
94
187
  // ?probe=1 marks a discovery probe from another instance: answer identity
95
188
  // only, skip our own discovery (two instances must not probe each other
@@ -120,9 +213,42 @@ async function handleApi(req, res, url) {
120
213
  projectIcon: project?.config.icon ?? "",
121
214
  projectRoot: project?.root ?? getProjectRoot(),
122
215
  projectRootLabel: tildify(project?.root ?? getProjectRoot()),
216
+ git: (project?.config.git && project.config.git.enabled !== false) === true,
123
217
  switcher,
124
218
  });
125
219
  }
220
+ // GET /api/git — branch, ahead/behind, changed files (the git screen polls
221
+ // this, so it is cached for a beat; ?fresh=1 bypasses the cache).
222
+ if (seg.length === 2 && seg[1] === "git" && method === "GET") {
223
+ return json(res, await gitStatus(url.searchParams.has("fresh")));
224
+ }
225
+ // GET /api/git/diff?path= — unified diff for one file
226
+ if (seg.length === 3 && seg[1] === "git" && seg[2] === "diff" && method === "GET") {
227
+ const file = url.searchParams.get("path");
228
+ if (!file)
229
+ return json(res, { error: "path required" }, 400);
230
+ return json(res, await gitDiff(file));
231
+ }
232
+ // POST /api/git/commit {paths, message} — stage and commit the selection.
233
+ // Commit and push stay manual; the background timer never writes history.
234
+ if (seg.length === 3 && seg[1] === "git" && seg[2] === "commit" && method === "POST") {
235
+ try {
236
+ const body = JSON.parse(await readBody(req));
237
+ const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : [];
238
+ const message = typeof body.message === "string" ? body.message : "";
239
+ const result = await gitCommit(paths, message);
240
+ return json(res, result, result.ok ? 200 : 409);
241
+ }
242
+ catch (err) {
243
+ return json(res, { error: err.message }, 400);
244
+ }
245
+ }
246
+ // POST /api/git/{fetch,pull,push}
247
+ if (seg.length === 3 && seg[1] === "git" && method === "POST" && ["fetch", "pull", "push"].includes(seg[2])) {
248
+ const run = seg[2] === "fetch" ? gitFetch : seg[2] === "pull" ? gitPull : gitPush;
249
+ const result = await run();
250
+ return json(res, result, result.ok ? 200 : 409);
251
+ }
126
252
  // GET /api/projects — every known workspace incl. this one, with state + counts (admin screen)
127
253
  if (seg.length === 2 && seg[1] === "projects" && method === "GET") {
128
254
  return json(res, await listWorkspacesDetailed());
@@ -170,7 +296,7 @@ async function handleApi(req, res, url) {
170
296
  if (seg.length === 2 && seg[1] === "settings" && method === "GET") {
171
297
  return json(res, await getSettings());
172
298
  }
173
- // PUT /api/settings — write the UI-editable subset (name, icon, switcher) back
299
+ // PUT /api/settings — write the UI-editable subset (name, icon, switcher, git) back
174
300
  if (seg.length === 2 && seg[1] === "settings" && method === "PUT") {
175
301
  try {
176
302
  const input = JSON.parse(await readBody(req));
@@ -659,6 +785,7 @@ export function startInspector(opts) {
659
785
  if (opts.projectRoot)
660
786
  setProjectRoot(opts.projectRoot);
661
787
  startRoutineScheduler();
788
+ startGitSync();
662
789
  // Chat agent subprocesses must die with the server — signals bypass
663
790
  // "exit" handlers, so hook the signals themselves.
664
791
  for (const sig of ["SIGINT", "SIGTERM"]) {
@@ -679,6 +806,8 @@ export function startInspector(opts) {
679
806
  const server = http.createServer(async (req, res) => {
680
807
  try {
681
808
  const url = new URL(req.url ?? "/", "http://localhost");
809
+ if (!hostAllowed(req))
810
+ return json(res, { error: "unexpected Host header" }, 421);
682
811
  if (url.pathname.startsWith("/api/"))
683
812
  await handleApi(req, res, url);
684
813
  else
@@ -690,7 +819,7 @@ export function startInspector(opts) {
690
819
  });
691
820
  return new Promise((resolve, reject) => {
692
821
  server.once("error", reject);
693
- server.listen(opts.port, () => {
822
+ server.listen(opts.port, INSPECTOR_HOST, () => {
694
823
  const addr = server.address();
695
824
  const port = typeof addr === "object" && addr ? addr.port : opts.port;
696
825
  void registerInstance(port, getProjectRoot());
@@ -1,7 +1,7 @@
1
1
  import { type ProjectConfig, type SwitcherEntry } from "../config.js";
2
2
  /**
3
3
  * Workspace settings: read kraftwerk.yml for the UI and write the
4
- * UI-editable subset (name, icon, switcher) back. Edits go through the
4
+ * UI-editable subset (name, icon, switcher, git) back. Edits go through the
5
5
  * yaml Document API so comments and every other key survive untouched;
6
6
  * a missing kraftwerk.yml is created at the project root on first save.
7
7
  */
@@ -22,6 +22,16 @@ export interface SaveSettingsInput {
22
22
  icon?: string;
23
23
  /** Omitted = untouched; empty list = remove the key. */
24
24
  switcher?: SwitcherEntry[];
25
+ /** Omitted = untouched. See cleanGit for how the block is written. */
26
+ git?: GitSettingsInput;
27
+ }
28
+ /** The git form as the settings screen posts it. Every field but `enabled` may be blank. */
29
+ export interface GitSettingsInput {
30
+ enabled: boolean;
31
+ remote?: string;
32
+ branch?: string;
33
+ interval?: number | string;
34
+ autosync?: "off" | "pull" | "";
25
35
  }
26
36
  export declare function getSettings(): Promise<SettingsView>;
27
37
  export declare function saveSettings(input: SaveSettingsInput): Promise<SettingsView>;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { parseDocument } from "yaml";
4
4
  import { resolveProject } from "../config.js";
5
5
  import { getProjectRoot } from "./context.js";
6
+ const GIT_DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
6
7
  export async function getSettings() {
7
8
  const project = await resolveProject(getProjectRoot());
8
9
  return {
@@ -31,6 +32,43 @@ function cleanSwitcher(value) {
31
32
  return icon ? { name, url, icon } : { name, url };
32
33
  });
33
34
  }
35
+ /**
36
+ * The git block to write. Defaults are left out so the file only says what
37
+ * differs from them, and `enabled: false` keeps the other fields so turning
38
+ * the feature back on restores them. Returns null when the block should not
39
+ * exist at all: sync off and nothing else set, which is what no block means.
40
+ */
41
+ function cleanGit(value) {
42
+ if (typeof value !== "object" || value === null)
43
+ throw new Error("git must be an object");
44
+ if (typeof value.enabled !== "boolean")
45
+ throw new Error("git.enabled must be true or false");
46
+ const out = {};
47
+ const remote = typeof value.remote === "string" ? value.remote.trim() : "";
48
+ if (remote && remote !== GIT_DEFAULTS.remote)
49
+ out.remote = remote;
50
+ const branch = typeof value.branch === "string" ? value.branch.trim() : "";
51
+ if (branch)
52
+ out.branch = branch;
53
+ if (value.interval !== undefined && value.interval !== "") {
54
+ const n = typeof value.interval === "string" ? Number(value.interval) : value.interval;
55
+ if (!Number.isInteger(n) || n < 0)
56
+ throw new Error("git.interval must be a whole number of seconds (0 disables the timer)");
57
+ if (n !== GIT_DEFAULTS.interval)
58
+ out.interval = n;
59
+ }
60
+ if (value.autosync !== undefined && value.autosync !== "") {
61
+ if (value.autosync !== "off" && value.autosync !== "pull")
62
+ throw new Error('git.autosync must be "off" or "pull"');
63
+ if (value.autosync !== GIT_DEFAULTS.autosync)
64
+ out.autosync = value.autosync;
65
+ }
66
+ if (value.enabled)
67
+ return out;
68
+ if (Object.keys(out).length === 0)
69
+ return null;
70
+ return { enabled: false, ...out };
71
+ }
34
72
  export async function saveSettings(input) {
35
73
  if (input.name !== undefined && typeof input.name !== "string")
36
74
  throw new Error("name must be a string");
@@ -54,6 +92,13 @@ export async function saveSettings(input) {
54
92
  setOrDelete("name", input.name?.trim());
55
93
  setOrDelete("icon", input.icon?.trim());
56
94
  setOrDelete("switcher", switcher);
95
+ if (input.git !== undefined) {
96
+ const git = cleanGit(input.git);
97
+ if (git === null)
98
+ doc.delete("git");
99
+ else
100
+ doc.set("git", git);
101
+ }
57
102
  await fs.writeFile(configPath, doc.toString());
58
103
  return getSettings();
59
104
  }
@@ -1,3 +1,4 @@
1
+ export declare const ENV_FILE = "runner.env";
1
2
  export interface SandboxOptions {
2
3
  /** Consumer project root (where output/ lives). */
3
4
  projectRoot: string;
@@ -22,7 +22,7 @@ import { newRunId, runDirFor } from "../workflow.js";
22
22
  * kraftwerk.run make runs discoverable and cancellable (`docker stop`).
23
23
  */
24
24
  const IMAGE = "kraftwerk-runner";
25
- const ENV_FILE = "runner.env";
25
+ export const ENV_FILE = "runner.env";
26
26
  const PASSTHROUGH_ENV = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
27
27
  /** Framework package root (contains runner/Dockerfile). */
28
28
  const frameworkDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");