@vincemakes/kiso-web 0.0.0-stage → 0.53.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/LICENSE +21 -0
- package/README.md +50 -2
- package/dist/main/agent.js +80 -0
- package/dist/main/approvals.js +68 -0
- package/dist/main/artifacts.js +166 -0
- package/dist/main/auth.js +109 -0
- package/dist/main/context.js +20 -0
- package/dist/main/desk-api.js +249 -0
- package/dist/main/env.js +104 -0
- package/dist/main/faux.js +12 -0
- package/dist/main/files.js +156 -0
- package/dist/main/folder-info.js +36 -0
- package/dist/main/folders.js +102 -0
- package/dist/main/fork.js +25 -0
- package/dist/main/fs-routes.js +69 -0
- package/dist/main/git.js +117 -0
- package/dist/main/headless.js +128 -0
- package/dist/main/history-worker.js +18 -0
- package/dist/main/history.js +151 -0
- package/dist/main/host.js +210 -0
- package/dist/main/login.js +27 -0
- package/dist/main/names.js +15 -0
- package/dist/main/observed-revision.js +69 -0
- package/dist/main/preview.js +141 -0
- package/dist/main/sanitize.js +28 -0
- package/dist/main/settings.js +242 -0
- package/dist/main/space.js +198 -0
- package/dist/main/tasks.js +92 -0
- package/dist/main/times.js +28 -0
- package/dist/main/usage.js +102 -0
- package/dist/main/works.js +21 -0
- package/dist/shared/files.js +2 -0
- package/dist/shared/frames.js +13 -0
- package/dist/shared/git.js +2 -0
- package/dist/shared/history.js +5 -0
- package/dist/shared/settings.js +2 -0
- package/out/renderer/assets/index-C3fCoBdq.css +1 -0
- package/out/renderer/assets/index-DiDTmq9b.js +45 -0
- package/out/renderer/favicon-32.png +0 -0
- package/out/renderer/favicon.svg +1 -0
- package/out/renderer/icon.png +0 -0
- package/out/renderer/index.html +18 -0
- package/out/renderer/manifest.webmanifest +10 -0
- package/package.json +64 -5
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { canonicalPath, claimProjectDir, projectDirFor } from "@vincemakes/kiso-code/config";
|
|
4
|
+
import { atFiles, deskRead, SAFE_ID } from "./fs-routes.js";
|
|
5
|
+
import { contextParts } from "./context.js";
|
|
6
|
+
import { rename } from "./names.js";
|
|
7
|
+
import { forkWork } from "./fork.js";
|
|
8
|
+
import { WORK_ACTS, workAct } from "./works.js";
|
|
9
|
+
import { commit, gitOf, openPr, prDraft } from "./git.js";
|
|
10
|
+
import { existing, Refusal, resolveInside } from "./files.js";
|
|
11
|
+
import { artifactKind } from "./artifacts.js";
|
|
12
|
+
import { listDirs, validateFolder } from "./folders.js";
|
|
13
|
+
import { folderInfo } from "./folder-info.js";
|
|
14
|
+
const json = (status, body) => ({ status, body });
|
|
15
|
+
const BODY_CAP = 16 * 1024;
|
|
16
|
+
async function readJson(req) {
|
|
17
|
+
let text = "";
|
|
18
|
+
for await (const chunk of req) {
|
|
19
|
+
text += chunk;
|
|
20
|
+
if (text.length > BODY_CAP)
|
|
21
|
+
throw new Refusal(413, "too large");
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(text);
|
|
25
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// fall through
|
|
30
|
+
}
|
|
31
|
+
throw new Refusal(400, "a JSON object is expected");
|
|
32
|
+
}
|
|
33
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
34
|
+
export async function deskApi(ctx, req, url) {
|
|
35
|
+
const path = url.pathname;
|
|
36
|
+
const where = url.searchParams.get("key");
|
|
37
|
+
const space = where === null || where === "" ? ctx.current : ctx.spaceFor(where);
|
|
38
|
+
const post = req.method === "POST";
|
|
39
|
+
try {
|
|
40
|
+
if (path === "/desk/at-files") {
|
|
41
|
+
if (space === null)
|
|
42
|
+
return json(404, {});
|
|
43
|
+
return req.method === "GET" ? json(200, atFiles(space)) : json(405, {});
|
|
44
|
+
}
|
|
45
|
+
if (path === "/desk/context") {
|
|
46
|
+
if (space === null)
|
|
47
|
+
return json(404, {});
|
|
48
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
49
|
+
let window = null;
|
|
50
|
+
try {
|
|
51
|
+
const m = ctx.settings.model(session === undefined ? undefined : { sessionId: session, sessionsDir: space.dir });
|
|
52
|
+
window = m.kind === "profile" ? (m.contextWindow ?? null) : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// no model: the parts are still known, the compaction sizes are not
|
|
56
|
+
}
|
|
57
|
+
return req.method === "GET" ? json(200, contextParts(space.workspace, ctx.settings.tierOf(session), window)) : json(405, {});
|
|
58
|
+
}
|
|
59
|
+
if (path === "/desk/git") {
|
|
60
|
+
if (space === null)
|
|
61
|
+
return json(404, {});
|
|
62
|
+
return req.method === "GET" ? json(200, url.searchParams.has("draft") ? await prDraft(space.workspace) : await gitOf(space.workspace)) : json(405, {});
|
|
63
|
+
}
|
|
64
|
+
if (path === "/desk/commit" || path === "/desk/pr") {
|
|
65
|
+
if (!post)
|
|
66
|
+
return json(405, {});
|
|
67
|
+
if (space === null)
|
|
68
|
+
return json(404, {});
|
|
69
|
+
const body = await readJson(req);
|
|
70
|
+
if (path === "/desk/commit")
|
|
71
|
+
return json(200, await commit(space.workspace, str(body["message"]) ?? "", Array.isArray(body["paths"]) ? body["paths"].filter((p) => typeof p === "string") : []));
|
|
72
|
+
return json(200, await openPr(space.workspace, str(body["title"]) ?? "", str(body["body"]) ?? ""));
|
|
73
|
+
}
|
|
74
|
+
if (path === "/desk/rename") {
|
|
75
|
+
if (!post)
|
|
76
|
+
return json(405, {});
|
|
77
|
+
if (space === null)
|
|
78
|
+
return json(404, {});
|
|
79
|
+
const body = await readJson(req);
|
|
80
|
+
const session = str(body["session"]) ?? "";
|
|
81
|
+
if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
|
|
82
|
+
return json(404, {});
|
|
83
|
+
return json(200, { name: rename(space.dir, session, str(body["name"]) ?? "") });
|
|
84
|
+
}
|
|
85
|
+
if (path === "/desk/fork") {
|
|
86
|
+
if (!post)
|
|
87
|
+
return json(405, {});
|
|
88
|
+
if (space === null)
|
|
89
|
+
return json(404, {});
|
|
90
|
+
const body = await readJson(req);
|
|
91
|
+
const session = str(body["session"]) ?? "";
|
|
92
|
+
if (!SAFE_ID.test(session))
|
|
93
|
+
return json(404, {});
|
|
94
|
+
const atSeq = typeof body["atSeq"] === "number" ? body["atSeq"] : undefined;
|
|
95
|
+
return json(200, { id: forkWork(space, ctx.settings, session, atSeq).id });
|
|
96
|
+
}
|
|
97
|
+
if (path === "/desk/work") {
|
|
98
|
+
if (!post)
|
|
99
|
+
return json(405, {});
|
|
100
|
+
if (space === null)
|
|
101
|
+
return json(404, {});
|
|
102
|
+
const body = await readJson(req);
|
|
103
|
+
const session = str(body["session"]) ?? "";
|
|
104
|
+
const act = str(body["act"]) ?? "";
|
|
105
|
+
if (!SAFE_ID.test(session) || !WORK_ACTS.includes(act))
|
|
106
|
+
return json(404, {});
|
|
107
|
+
workAct(space, ctx.settings, session, act);
|
|
108
|
+
return json(200, { ok: true });
|
|
109
|
+
}
|
|
110
|
+
if (path === "/desk/task-stop") {
|
|
111
|
+
if (!post)
|
|
112
|
+
return json(405, {});
|
|
113
|
+
if (space === null)
|
|
114
|
+
return json(404, {});
|
|
115
|
+
const body = await readJson(req);
|
|
116
|
+
const session = str(body["session"]) ?? "";
|
|
117
|
+
const task = str(body["task"]) ?? "";
|
|
118
|
+
if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
|
|
119
|
+
return json(404, {});
|
|
120
|
+
return json(200, { stopped: space.tasks.managerFor(session).stop(task, "person") });
|
|
121
|
+
}
|
|
122
|
+
if (path === "/desk/fs/tree" || path === "/desk/fs/file" || path === "/desk/task-log") {
|
|
123
|
+
if (space === null)
|
|
124
|
+
return json(404, {});
|
|
125
|
+
return req.method === "GET" ? deskRead(url, space) : json(405, {});
|
|
126
|
+
}
|
|
127
|
+
if (path === "/desk/exists") {
|
|
128
|
+
if (space === null)
|
|
129
|
+
return json(404, {});
|
|
130
|
+
return post ? json(200, { found: existing(realpathSync(space.workspace), (await readJson(req))["paths"]) }) : json(405, {});
|
|
131
|
+
}
|
|
132
|
+
// an image block of a result, from the Work's log: its bytes, or an https address passed on
|
|
133
|
+
const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+|k\d+)$/.exec(path);
|
|
134
|
+
if (image !== null) {
|
|
135
|
+
if (space === null || req.method !== "GET" || !space.store.ids().includes(image[1]))
|
|
136
|
+
return json(404, {});
|
|
137
|
+
const b = space.images.block(image[1], Number(image[2]), image[3].startsWith("k") ? image[3] : Number(image[3]));
|
|
138
|
+
if (b === null)
|
|
139
|
+
return json(404, {});
|
|
140
|
+
return "url" in b ? { status: 302, body: null, headers: { location: b.url } } : { status: 200, body: b.bytes, headers: { "content-type": b.type, "x-content-type-options": "nosniff", "cache-control": "private, max-age=86400" } };
|
|
141
|
+
}
|
|
142
|
+
// a page, a pdf or media of the folder on its own preview server (preview.ts); the address it answers
|
|
143
|
+
if (path === "/desk/preview" && post) {
|
|
144
|
+
if (space === null)
|
|
145
|
+
return json(404, {});
|
|
146
|
+
const entry = resolveInside(realpathSync(space.workspace), String((await readJson(req))["path"] ?? ""));
|
|
147
|
+
if (!statSync(entry).isFile() || artifactKind(entry) === undefined)
|
|
148
|
+
throw new Refusal(400, "not a page, a document or media");
|
|
149
|
+
const host = req.headers.host ?? "";
|
|
150
|
+
return json(200, { url: await ctx.previews.open(entry, host, `http://${host}`) });
|
|
151
|
+
}
|
|
152
|
+
if (path === "/desk/preview-close" && post) {
|
|
153
|
+
ctx.previews.close(String((await readJson(req))["url"] ?? ""));
|
|
154
|
+
return json(200, { ok: true });
|
|
155
|
+
}
|
|
156
|
+
// the line the person may send next after the run that ended at `seq` (asked once, after that run; "" for none)
|
|
157
|
+
if (path === "/desk/followup") {
|
|
158
|
+
if (space === null)
|
|
159
|
+
return json(404, {});
|
|
160
|
+
if (req.method !== "GET")
|
|
161
|
+
return json(405, {});
|
|
162
|
+
const session = url.searchParams.get("session") ?? "";
|
|
163
|
+
const seq = Number(url.searchParams.get("seq"));
|
|
164
|
+
if (!space.store.ids().includes(session) || !Number.isInteger(seq))
|
|
165
|
+
return json(404, {});
|
|
166
|
+
return json(200, { line: await space.followUp(session, seq) });
|
|
167
|
+
}
|
|
168
|
+
if (path === "/desk/env") {
|
|
169
|
+
if (space === null)
|
|
170
|
+
return json(404, {});
|
|
171
|
+
if (req.method !== "GET")
|
|
172
|
+
return json(405, {});
|
|
173
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
174
|
+
const fork = session === undefined ? undefined : ctx.settings.forkOf(session);
|
|
175
|
+
return json(200, { ...folderInfo(space.workspace, ctx.settings.tierOf(session)), ...(fork === undefined ? {} : { fork: { parent: fork.parent, title: fork.title, at: fork.at } }) });
|
|
176
|
+
}
|
|
177
|
+
if (path === "/desk/history")
|
|
178
|
+
return req.method === "GET" ? json(200, await ctx.history.snapshot({ key: ctx.current.key, dir: ctx.current.dir }, url.searchParams.get("fresh") ?? undefined)) : json(405, {});
|
|
179
|
+
if (path === "/desk/settings") {
|
|
180
|
+
let session = url.searchParams.get("session") ?? undefined;
|
|
181
|
+
if (post) {
|
|
182
|
+
const patch = (await readJson(req));
|
|
183
|
+
// a patch for one Work is answered with that Work's settings
|
|
184
|
+
session ??= patch.session;
|
|
185
|
+
const before = patch.session === undefined ? undefined : ctx.settings.workOf(patch.session);
|
|
186
|
+
const work = ctx.settings.apply(patch);
|
|
187
|
+
// a Work's model changed: rebuild it from its log so its next run uses it; a run in flight refuses, and the choice is put back
|
|
188
|
+
if (work !== null && space !== null && space.opened().has(work)) {
|
|
189
|
+
try {
|
|
190
|
+
await space.service.reopen(work);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
ctx.settings.restoreWork(work, before);
|
|
194
|
+
return json(409, { message: "a run is going: switch the model when it ends" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (req.method !== "GET")
|
|
199
|
+
return json(405, {});
|
|
200
|
+
return json(200, ctx.settings.snapshot(session));
|
|
201
|
+
}
|
|
202
|
+
if (path === "/desk/folder" && post) {
|
|
203
|
+
const workspace = validateFolder((await readJson(req))["path"]);
|
|
204
|
+
const dir = projectDirFor(ctx.home, workspace);
|
|
205
|
+
claimProjectDir(dir, workspace);
|
|
206
|
+
ctx.settings.addRecent(workspace);
|
|
207
|
+
ctx.settings.write({ lastFolder: workspace });
|
|
208
|
+
const opened = ctx.spaceFor(basename(dir));
|
|
209
|
+
return opened === null ? json(500, {}) : json(200, { key: opened.key, folder: basename(workspace) });
|
|
210
|
+
}
|
|
211
|
+
if (path === "/desk/recent" && req.method === "GET") {
|
|
212
|
+
const rows = ctx.settings.recents().flatMap((p) => {
|
|
213
|
+
try {
|
|
214
|
+
const real = validateFolder(p);
|
|
215
|
+
return [{ folder: basename(real), path: real, key: basename(projectDirFor(ctx.home, canonicalPath(real))) }];
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return json(200, { recent: rows });
|
|
222
|
+
}
|
|
223
|
+
if (path === "/desk/login" && post) {
|
|
224
|
+
const b = await readJson(req);
|
|
225
|
+
ctx.settings.saveKey(str(b["profile"]) ?? "", b["key"]);
|
|
226
|
+
return json(200, { ok: true });
|
|
227
|
+
}
|
|
228
|
+
if (path === "/desk/demo" && post) {
|
|
229
|
+
await ctx.settings.startDemo();
|
|
230
|
+
return json(200, { ok: true });
|
|
231
|
+
}
|
|
232
|
+
if (path === "/desk/notices" && req.method === "GET")
|
|
233
|
+
return json(200, ctx.notices(Number(url.searchParams.get("after") ?? "0") || 0));
|
|
234
|
+
if (path === "/desk/dirs" && req.method === "GET")
|
|
235
|
+
return json(200, listDirs(url.searchParams.get("root"), url.searchParams.get("path") ?? ""));
|
|
236
|
+
if (path === "/desk/presence" && post) {
|
|
237
|
+
const b = await readJson(req);
|
|
238
|
+
const key = str(b["key"]) ?? "";
|
|
239
|
+
ctx.setPresence({ key: key === "" ? ctx.current.key : key, session: str(b["session"]) ?? "", focused: b["focused"] === true, at: Date.now() });
|
|
240
|
+
return json(200, { ok: true });
|
|
241
|
+
}
|
|
242
|
+
return json(404, {});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof Refusal)
|
|
246
|
+
return json(error.status, { code: "refused", message: error.message });
|
|
247
|
+
return json(500, { code: "internal", message: "failed" });
|
|
248
|
+
}
|
|
249
|
+
}
|
package/dist/main/env.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalPath, projectDirFor } from "@vincemakes/kiso-code/config";
|
|
5
|
+
import { kisoHome } from "@vincemakes/kiso-runtime";
|
|
6
|
+
import { fauxSkip } from "./faux.js";
|
|
7
|
+
import { startHost } from "./host.js";
|
|
8
|
+
import { Settings } from "./settings.js";
|
|
9
|
+
/**
|
|
10
|
+
* The host's configuration from the environment (the `kiso-web` command
|
|
11
|
+
* reads its flags in headless.ts), so a credential never rides argv:
|
|
12
|
+
*
|
|
13
|
+
* KISO_WEB_DATA the kiso home: `projects/<enc>/` under it holds the Works (default: the terminal's own home)
|
|
14
|
+
* KISO_WEB_WORKSPACE the folder a new task starts in (default: the folder used last, else the working directory)
|
|
15
|
+
* KISO_WEB_FAUX path to a JSON FauxScript (the keyless model)
|
|
16
|
+
* KISO_WEB_FAUX_SKIP a session id: skip the script turns its log already consumed
|
|
17
|
+
* KISO_WEB_MODE the permission tier: default | accept-edits | plan | full-access
|
|
18
|
+
* KISO_WEB_NOTIFY_LOG a file: notices (an approval waits, a run ended) are also appended to it, as JSON lines (the proofs)
|
|
19
|
+
* KISO_WEB_REQUEST_LOG, KISO_WEB_PREVIEW_LOG files: each request the host, or a preview server, received (the proofs)
|
|
20
|
+
* KISO_WEB_SANITIZER "default" | "web"
|
|
21
|
+
* KISO_WEB_THINKING 0: leave reasoning text off the wire (default: on)
|
|
22
|
+
* KISO_WEB_KEEPALIVE keepalive period in ms
|
|
23
|
+
* KISO_WEB_MIN_FG_MS the shell's least foreground wait (0 keeps the model's value)
|
|
24
|
+
* KISO_WEB_PORT the port (default 4317; 0: the system picks), and KISO_WEB_TOKEN the scripts' bearer
|
|
25
|
+
* token (default: a fresh one per launch, never printed) — the crash script restarts
|
|
26
|
+
* the host where a live client expects it
|
|
27
|
+
*
|
|
28
|
+
*/
|
|
29
|
+
/** A scripted model, for the proofs; otherwise null: the model comes from the shared configuration (settings.ts). */
|
|
30
|
+
async function fauxFromEnv(sessionsDir) {
|
|
31
|
+
const fauxPath = process.env["KISO_WEB_FAUX"];
|
|
32
|
+
if (fauxPath === undefined || fauxPath === "")
|
|
33
|
+
return null;
|
|
34
|
+
const { createFauxProvider } = await import("@vincemakes/kiso-evals");
|
|
35
|
+
const script = JSON.parse(readFileSync(fauxPath, "utf8"));
|
|
36
|
+
const skipFor = process.env["KISO_WEB_FAUX_SKIP"];
|
|
37
|
+
const skip = skipFor === undefined || skipFor === "" ? 0 : fauxSkip(sessionsDir, skipFor);
|
|
38
|
+
return { kind: "adapter", adapter: createFauxProvider(script.slice(skip)), model: "faux", route: "adapter" };
|
|
39
|
+
}
|
|
40
|
+
const MODES = ["default", "accept-edits", "plan", "full-access"];
|
|
41
|
+
function modeFromEnv() {
|
|
42
|
+
const raw = process.env["KISO_WEB_MODE"];
|
|
43
|
+
if (raw === undefined || raw === "")
|
|
44
|
+
return "default";
|
|
45
|
+
const mode = MODES.find((m) => m === raw);
|
|
46
|
+
if (mode === undefined)
|
|
47
|
+
throw new Error(`KISO_WEB_MODE must be one of ${MODES.join(", ")}`);
|
|
48
|
+
return mode;
|
|
49
|
+
}
|
|
50
|
+
const isFolder = (path) => {
|
|
51
|
+
try {
|
|
52
|
+
return statSync(path).isDirectory();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/** The folder a new task starts in: named by the environment, else the one used last while it still exists, else where the command was started. */
|
|
59
|
+
function workspaceFor(settings) {
|
|
60
|
+
const named = process.env["KISO_WEB_WORKSPACE"];
|
|
61
|
+
if (named !== undefined && named !== "")
|
|
62
|
+
return named;
|
|
63
|
+
// a remembered folder can be gone (a deleted scratch folder); "/" is never a place to work: both fall back to the home folder
|
|
64
|
+
const last = settings.lastFolder();
|
|
65
|
+
if (last !== undefined && isFolder(last))
|
|
66
|
+
return last;
|
|
67
|
+
const cwd = process.cwd();
|
|
68
|
+
return cwd === "/" || last !== undefined ? homedir() : cwd;
|
|
69
|
+
}
|
|
70
|
+
/** A copy of every notice in a file, for the proofs. */
|
|
71
|
+
function noticeLog() {
|
|
72
|
+
const log = process.env["KISO_WEB_NOTIFY_LOG"];
|
|
73
|
+
return log === undefined || log === "" ? undefined : (n) => appendFileSync(log, `${JSON.stringify(n)}\n`);
|
|
74
|
+
}
|
|
75
|
+
export async function startFromEnv(options = {}) {
|
|
76
|
+
const home = process.env["KISO_WEB_DATA"] ?? kisoHome();
|
|
77
|
+
// the terminal's configuration and sign-in are read from the one root the terminal uses
|
|
78
|
+
process.env["KISO_HOME"] ??= home;
|
|
79
|
+
const keepalive = Number(process.env["KISO_WEB_KEEPALIVE"] ?? "");
|
|
80
|
+
const minFg = Number(process.env["KISO_WEB_MIN_FG_MS"] ?? "");
|
|
81
|
+
// settings first: its saved tier and folder memory are read before anything is opened
|
|
82
|
+
const probe = new Settings(home, null, modeFromEnv());
|
|
83
|
+
const workspace = canonicalPath(workspaceFor(probe));
|
|
84
|
+
const faux = await fauxFromEnv(projectDirFor(home, workspace));
|
|
85
|
+
const settings = faux === null ? probe : new Settings(home, faux, modeFromEnv());
|
|
86
|
+
// the folder used last is only ever one the person chose (POST /desk/folder): a folder named by the environment, a proof's or a
|
|
87
|
+
// scratch run's, is not remembered for them
|
|
88
|
+
return startHost({
|
|
89
|
+
home,
|
|
90
|
+
workspace,
|
|
91
|
+
settings,
|
|
92
|
+
...(noticeLog() !== undefined ? { notify: noticeLog() } : {}),
|
|
93
|
+
exposeThinking: process.env["KISO_WEB_THINKING"] !== "0",
|
|
94
|
+
requestLog: process.env["KISO_WEB_REQUEST_LOG"] || undefined,
|
|
95
|
+
previewLog: process.env["KISO_WEB_PREVIEW_LOG"] || undefined,
|
|
96
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
97
|
+
...(process.env["KISO_WEB_TOKEN"] !== undefined ? { token: process.env["KISO_WEB_TOKEN"] } : {}),
|
|
98
|
+
sanitizer: process.env["KISO_WEB_SANITIZER"] === "default" ? "default" : "web",
|
|
99
|
+
followUps: process.env["KISO_WEB_FOLLOWUP"] !== "off",
|
|
100
|
+
titles: process.env["KISO_AUTO_TITLE"] !== "off",
|
|
101
|
+
...(Number.isFinite(keepalive) && keepalive > 0 ? { keepaliveMs: keepalive } : {}),
|
|
102
|
+
...(process.env["KISO_WEB_MIN_FG_MS"] !== undefined && Number.isFinite(minFg) ? { toolLimits: { minForegroundMs: minFg } } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SessionStore } from "@vincemakes/kiso-runtime";
|
|
2
|
+
/** How many faux-script turns a session's log has consumed (the same rule the terminal's fauxSkip states: a turn is consumed by a tool
|
|
3
|
+
* result or an end_turn stop, or by an unfinished call the recovery completes). */
|
|
4
|
+
export function fauxSkip(sessionsDir, sessionId) {
|
|
5
|
+
const store = new SessionStore(sessionsDir);
|
|
6
|
+
const events = store.load(sessionId).map((r) => r.event);
|
|
7
|
+
store.closeAll();
|
|
8
|
+
const results = new Set(events.filter((e) => e.type === "tool_result").map((e) => e.callId));
|
|
9
|
+
return (events.filter((e) => e.type === "tool_result").length +
|
|
10
|
+
events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
|
|
11
|
+
events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
|
|
12
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { canonicalPath } from "@vincemakes/kiso-code/config";
|
|
4
|
+
import { kisoHome } from "@vincemakes/kiso-runtime";
|
|
5
|
+
import { protectedFiles } from "./agent.js";
|
|
6
|
+
/**
|
|
7
|
+
* The read-only doors to a Work's folder, for the Files and Changes panels.
|
|
8
|
+
*
|
|
9
|
+
* What may be read is decided here, never by the page: a path is relative to
|
|
10
|
+
* the folder's real path; it is resolved through every symbolic link and must
|
|
11
|
+
* land inside the folder; the kiso home (credentials, sessions) and the
|
|
12
|
+
* files the policy protects are refused even when they sit inside the folder;
|
|
13
|
+
* a file is read up to a size cap and a file with a NUL byte is never read as
|
|
14
|
+
* text. A link that leads outside is listed as such and never followed.
|
|
15
|
+
*/
|
|
16
|
+
export const TEXT_CAP = 256 * 1024;
|
|
17
|
+
export const IMAGE_CAP = 2 * 1024 * 1024;
|
|
18
|
+
const LIST_CAP = 500;
|
|
19
|
+
const LOG_CAP = 64 * 1024;
|
|
20
|
+
const IMAGES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml" };
|
|
21
|
+
/** A refusal with the status the host answers. */
|
|
22
|
+
export class Refusal extends Error {
|
|
23
|
+
status;
|
|
24
|
+
constructor(status, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const inside = (root, real) => real === root || real.startsWith(root + sep);
|
|
30
|
+
const folded = (name, dir) => dir && (name === "node_modules" || name.startsWith("."));
|
|
31
|
+
/** Places no door reads: the kiso home and the policy's protected files, by real path. */
|
|
32
|
+
function protectedPaths() {
|
|
33
|
+
return { dirs: [canonicalPath(kisoHome())], files: protectedFiles().map(canonicalPath) };
|
|
34
|
+
}
|
|
35
|
+
function isProtected(real) {
|
|
36
|
+
const p = protectedPaths();
|
|
37
|
+
return p.dirs.some((d) => inside(d, real)) || p.files.includes(real);
|
|
38
|
+
}
|
|
39
|
+
/** The real path of `rel` inside `root` (a real path), or a Refusal. */
|
|
40
|
+
export function resolveInside(root, rel) {
|
|
41
|
+
if (rel.includes("\0") || isAbsolute(rel))
|
|
42
|
+
throw new Refusal(400, "the path must be relative to the folder");
|
|
43
|
+
const target = resolve(root, rel);
|
|
44
|
+
if (!inside(root, target))
|
|
45
|
+
throw new Refusal(403, "outside the folder");
|
|
46
|
+
// a folder the listing does not open is not read through either (a dot folder can hold credentials)
|
|
47
|
+
const parts = rel.split("/").filter((p) => p !== "" && p !== ".");
|
|
48
|
+
if (parts.slice(0, -1).some((p) => folded(p, true)))
|
|
49
|
+
throw new Refusal(403, "not listed");
|
|
50
|
+
let real;
|
|
51
|
+
try {
|
|
52
|
+
real = realpathSync(target);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Refusal(404, "no such file");
|
|
56
|
+
}
|
|
57
|
+
if (!inside(root, real))
|
|
58
|
+
throw new Refusal(403, "outside the folder");
|
|
59
|
+
if (isProtected(real))
|
|
60
|
+
throw new Refusal(403, "protected");
|
|
61
|
+
return real;
|
|
62
|
+
}
|
|
63
|
+
export function listDir(root, rel) {
|
|
64
|
+
const dir = resolveInside(root, rel);
|
|
65
|
+
if (!statSync(dir).isDirectory())
|
|
66
|
+
throw new Refusal(400, "not a folder");
|
|
67
|
+
const last = rel.split("/").filter((p) => p !== "" && p !== ".").at(-1);
|
|
68
|
+
if (last !== undefined && folded(last, true))
|
|
69
|
+
throw new Refusal(403, "not listed");
|
|
70
|
+
const entries = [];
|
|
71
|
+
let more = 0;
|
|
72
|
+
const names = readdirSync(dir, { withFileTypes: true }).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
73
|
+
for (const ent of names) {
|
|
74
|
+
const full = join(dir, ent.name);
|
|
75
|
+
let real;
|
|
76
|
+
try {
|
|
77
|
+
real = realpathSync(full);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isProtected(real))
|
|
83
|
+
continue;
|
|
84
|
+
if (entries.length >= LIST_CAP) {
|
|
85
|
+
more += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const link = lstatSync(full).isSymbolicLink();
|
|
89
|
+
if (!inside(root, real)) {
|
|
90
|
+
entries.push({ name: ent.name, kind: "link", size: 0, outside: true });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const stat = statSync(real);
|
|
94
|
+
const isDir = stat.isDirectory();
|
|
95
|
+
entries.push({ name: ent.name, kind: link && !isDir ? "link" : isDir ? "dir" : "file", size: isDir ? 0 : stat.size, ...(folded(ent.name, isDir) ? { folded: true } : {}) });
|
|
96
|
+
}
|
|
97
|
+
return { root, path: rel, entries, more };
|
|
98
|
+
}
|
|
99
|
+
function readHead(path, length) {
|
|
100
|
+
const fd = openSync(path, "r");
|
|
101
|
+
try {
|
|
102
|
+
const buf = Buffer.alloc(Math.min(length, fstatSync(fd).size));
|
|
103
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
104
|
+
return buf;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function readPreview(root, rel) {
|
|
111
|
+
const file = resolveInside(root, rel);
|
|
112
|
+
const stat = statSync(file);
|
|
113
|
+
if (!stat.isFile())
|
|
114
|
+
throw new Refusal(400, "not a file");
|
|
115
|
+
const mime = IMAGES[extname(file).toLowerCase()];
|
|
116
|
+
if (mime !== undefined)
|
|
117
|
+
return stat.size > IMAGE_CAP ? { kind: "toolarge", size: stat.size } : { kind: "image", size: stat.size, dataUrl: `data:${mime};base64,${readHead(file, IMAGE_CAP).toString("base64")}` };
|
|
118
|
+
const head = readHead(file, TEXT_CAP);
|
|
119
|
+
// a NUL byte in the first 8 KB: not text, however it is named
|
|
120
|
+
if (head.subarray(0, 8192).includes(0))
|
|
121
|
+
return { kind: "binary", size: stat.size };
|
|
122
|
+
const markdown = /\.(md|markdown)$/i.test(file);
|
|
123
|
+
return { kind: markdown ? "markdown" : "text", size: stat.size, text: head.toString("utf8"), truncated: stat.size > TEXT_CAP };
|
|
124
|
+
}
|
|
125
|
+
/** Which of the paths name a file inside the folder (the stream's chips): each judged as a read would be; a refusal is "no". */
|
|
126
|
+
export function existing(root, paths) {
|
|
127
|
+
if (!Array.isArray(paths) || paths.length > 64)
|
|
128
|
+
throw new Refusal(400, "at most 64 paths");
|
|
129
|
+
return paths.filter((p) => typeof p === "string" && p.length <= 512).filter((rel) => {
|
|
130
|
+
try {
|
|
131
|
+
return statSync(resolveInside(root, rel)).isFile();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** The last part of a task's output file, as lines. */
|
|
139
|
+
export function readTaskLog(outputPath) {
|
|
140
|
+
try {
|
|
141
|
+
const size = statSync(outputPath).size;
|
|
142
|
+
const fd = openSync(outputPath, "r");
|
|
143
|
+
try {
|
|
144
|
+
const length = Math.min(size, LOG_CAP);
|
|
145
|
+
const buf = Buffer.alloc(length);
|
|
146
|
+
readSync(fd, buf, 0, length, size - length);
|
|
147
|
+
return { lines: buf.toString("utf8").split("\n").filter((l, i, all) => l !== "" || i < all.length - 1), truncated: size > LOG_CAP };
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return { lines: [], truncated: false };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
/** The git directory of a folder (it or the nearest parent holding `.git`, a directory or a worktree's pointer file). */
|
|
5
|
+
function gitDir(from) {
|
|
6
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
7
|
+
const dot = join(dir, ".git");
|
|
8
|
+
if (existsSync(dot)) {
|
|
9
|
+
if (statSync(dot).isDirectory())
|
|
10
|
+
return dot;
|
|
11
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, "utf8"));
|
|
12
|
+
return pointer === null ? null : join(dir, pointer[1].trim());
|
|
13
|
+
}
|
|
14
|
+
if (dirname(dir) === dir)
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The branch a folder is on (a short commit when detached); null outside a git folder. */
|
|
19
|
+
function branchOf(workspace) {
|
|
20
|
+
try {
|
|
21
|
+
const git = gitDir(workspace);
|
|
22
|
+
if (git === null)
|
|
23
|
+
return null;
|
|
24
|
+
const head = readFileSync(join(git, "HEAD"), "utf8").trim();
|
|
25
|
+
const ref = /^ref: refs\/heads\/(.+)$/.exec(head);
|
|
26
|
+
return ref !== null ? ref[1] : head.slice(0, 7);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** What the rail's footer says about a Work's folder: its name, its path (the home shortened), its branch. */
|
|
33
|
+
export function folderInfo(workspace, mode) {
|
|
34
|
+
const home = homedir();
|
|
35
|
+
return { folder: basename(workspace), path: workspace.startsWith(`${home}/`) ? `~${workspace.slice(home.length)}` : workspace, root: workspace, branch: branchOf(workspace), mode };
|
|
36
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { accessSync, constants, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, parse, relative, sep } from "node:path";
|
|
4
|
+
import { canonicalPath } from "@vincemakes/kiso-code/config";
|
|
5
|
+
import { kisoHome } from "@vincemakes/kiso-runtime";
|
|
6
|
+
import { Refusal } from "./files.js";
|
|
7
|
+
/** A folder the person picked, judged here and never trusted from the page: absolute, there, a folder, and not the kiso home or inside it. Returns its real path. */
|
|
8
|
+
export function validateFolder(raw) {
|
|
9
|
+
if (typeof raw !== "string" || raw === "" || raw.includes("\0") || !isAbsolute(raw))
|
|
10
|
+
throw new Refusal(400, "pick a folder");
|
|
11
|
+
let real;
|
|
12
|
+
try {
|
|
13
|
+
real = canonicalPath(raw);
|
|
14
|
+
if (!statSync(real).isDirectory())
|
|
15
|
+
throw new Error("not a folder");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Refusal(400, "that is not a folder that exists");
|
|
19
|
+
}
|
|
20
|
+
if (real === parse(real).root)
|
|
21
|
+
throw new Refusal(400, "the whole disk is not a project folder");
|
|
22
|
+
const home = canonicalPath(kisoHome());
|
|
23
|
+
if (real === home || real.startsWith(home + sep))
|
|
24
|
+
throw new Refusal(400, "the kiso home holds credentials and sessions: pick a project folder");
|
|
25
|
+
return real;
|
|
26
|
+
}
|
|
27
|
+
const DIRS_CAP = 500;
|
|
28
|
+
const inside = (root, path) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
29
|
+
/** A root of the folder browser: the home folder, or a typed absolute path that exists, is a readable folder, is not the whole disk and not the kiso home. */
|
|
30
|
+
function browseRoot(raw) {
|
|
31
|
+
if (raw === null || raw === "")
|
|
32
|
+
return realpathSync(homedir());
|
|
33
|
+
if (raw.includes("\0") || !isAbsolute(raw))
|
|
34
|
+
throw new Refusal(400, "type a full path, starting with /");
|
|
35
|
+
let real;
|
|
36
|
+
try {
|
|
37
|
+
real = realpathSync(raw);
|
|
38
|
+
if (!statSync(real).isDirectory())
|
|
39
|
+
throw new Error("not a folder");
|
|
40
|
+
accessSync(real, constants.R_OK | constants.X_OK);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Refusal(400, "that is not a folder that exists and can be read");
|
|
44
|
+
}
|
|
45
|
+
if (real === parse(real).root)
|
|
46
|
+
throw new Refusal(400, "the whole disk is not a place to browse: type a folder in it");
|
|
47
|
+
if (inside(realpathSync(kisoHome()), real))
|
|
48
|
+
throw new Refusal(400, "the kiso home holds credentials and sessions");
|
|
49
|
+
return real;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One folder of the in-page folder browser: the folders in it, by name, nothing else (no files, no contents). The path is relative to
|
|
53
|
+
* the root and resolved through every link; it must stay inside the root, so a link never leads the browsing out of it (such a link is
|
|
54
|
+
* listed as `outside`). Dot folders and the kiso home are not listed.
|
|
55
|
+
*/
|
|
56
|
+
export function listDirs(rootRaw, rel) {
|
|
57
|
+
const root = browseRoot(rootRaw);
|
|
58
|
+
if (rel.includes("\0") || isAbsolute(rel) || rel.split(/[\\/]/).includes(".."))
|
|
59
|
+
throw new Refusal(400, "a path inside the root is expected");
|
|
60
|
+
let full;
|
|
61
|
+
try {
|
|
62
|
+
full = realpathSync(join(root, rel));
|
|
63
|
+
if (!statSync(full).isDirectory())
|
|
64
|
+
throw new Error("not a folder");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Refusal(404, "no such folder");
|
|
68
|
+
}
|
|
69
|
+
const home = realpathSync(kisoHome());
|
|
70
|
+
if (!inside(root, full) || inside(home, full))
|
|
71
|
+
throw new Refusal(403, "outside the folder you are browsing");
|
|
72
|
+
const dirs = [];
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(full, { withFileTypes: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Refusal(403, "that folder cannot be read");
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.name.startsWith("."))
|
|
82
|
+
continue;
|
|
83
|
+
const path = join(full, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
if (!inside(home, path))
|
|
86
|
+
dirs.push({ name: entry.name });
|
|
87
|
+
}
|
|
88
|
+
else if (entry.isSymbolicLink()) {
|
|
89
|
+
try {
|
|
90
|
+
const target = realpathSync(path);
|
|
91
|
+
if (!statSync(target).isDirectory() || inside(home, target))
|
|
92
|
+
continue;
|
|
93
|
+
dirs.push(inside(root, target) ? { name: entry.name } : { name: entry.name, outside: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// a broken link is not a folder
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
101
|
+
return { root, path: relative(root, full), full, dirs: dirs.slice(0, DIRS_CAP), more: Math.max(0, dirs.length - DIRS_CAP) };
|
|
102
|
+
}
|