@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.
- package/README.md +15 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.js +42 -2
- package/dist/inspector/git.d.ts +83 -0
- package/dist/inspector/git.js +651 -0
- package/dist/inspector/instances.js +9 -1
- package/dist/inspector/server.js +132 -3
- package/dist/inspector/settings.d.ts +11 -1
- package/dist/inspector/settings.js +45 -0
- package/dist/runner/docker.d.ts +1 -0
- package/dist/runner/docker.js +1 -1
- package/inspector/dist/assets/{editor-CXrDJOv_.js → editor-BABv2LmA.js} +3 -3
- package/inspector/dist/assets/index-BXspF2KR.css +1 -0
- package/inspector/dist/assets/{index-DAi1VtJ8.js → index-BbTer62d.js} +1 -1
- package/inspector/dist/assets/{index-C3hpUVfp.js → index-Bmm4zJNU.js} +1 -1
- package/inspector/dist/assets/{index-x5G6Np7T.js → index-ByWpqzAI.js} +1 -1
- package/inspector/dist/assets/{index-Upf-BEsx.js → index-CCACy6uF.js} +1 -1
- package/inspector/dist/assets/{index-BNcXQYbv.js → index-Ca9Qs5LZ.js} +1 -1
- package/inspector/dist/assets/{index-Bumwg9SM.js → index-CdwTmXTV.js} +1 -1
- package/inspector/dist/assets/{index-CMMYSoL3.js → index-CjlJeW9z.js} +1 -1
- package/inspector/dist/assets/{index-CszaAR9T.js → index-CzPQygR7.js} +1 -1
- package/inspector/dist/assets/{index-DrSWAGft.js → index-DCR7u4Xa.js} +1 -1
- package/inspector/dist/assets/{index-BxEG-IdY.js → index-DbWUNbt-.js} +1 -1
- package/inspector/dist/assets/{index-BULB2rC_.js → index-DuPp3MUc.js} +1 -1
- package/inspector/dist/assets/{index-CNJyAniG.js → index-DuwmzZAr.js} +1 -1
- package/inspector/dist/assets/{index-CUCCMUMY.js → index-Gx93brFq.js} +1 -1
- package/inspector/dist/assets/{index-BIDsfw8C.js → index-HBdsdJhD.js} +1 -1
- package/inspector/dist/assets/{index-7J4zAS6J.js → index-hkVov8T4.js} +1 -1
- package/inspector/dist/assets/{index-BQMgADlc.js → index-sLxulPOm.js} +1 -1
- package/inspector/dist/assets/index-uEig4--d.js +217 -0
- package/inspector/dist/assets/{index-ChuqpBnv.js → index-wkYwz0Tl.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +5 -2
- package/schema/kraftwerk.schema.json +35 -1
- package/inspector/dist/assets/index-B7rwNdVj.css +0 -1
- package/inspector/dist/assets/index-BBGjdXk1.js +0 -213
package/dist/inspector/server.js
CHANGED
|
@@ -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
|
}
|
package/dist/runner/docker.d.ts
CHANGED
package/dist/runner/docker.js
CHANGED
|
@@ -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)), "../..");
|