@celestea/studio 2.7.1

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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app.d.ts +111 -0
  3. package/dist/app.js +219 -0
  4. package/dist/auth/api-token.d.ts +76 -0
  5. package/dist/auth/api-token.js +172 -0
  6. package/dist/auth/htpasswd.d.ts +22 -0
  7. package/dist/auth/htpasswd.js +61 -0
  8. package/dist/auth/index.d.ts +14 -0
  9. package/dist/auth/index.js +14 -0
  10. package/dist/auth/page.d.ts +19 -0
  11. package/dist/auth/page.js +73 -0
  12. package/dist/auth/rate-limit.d.ts +27 -0
  13. package/dist/auth/rate-limit.js +47 -0
  14. package/dist/auth/token.d.ts +49 -0
  15. package/dist/auth/token.js +112 -0
  16. package/dist/config.d.ts +78 -0
  17. package/dist/config.js +72 -0
  18. package/dist/deployment.d.ts +92 -0
  19. package/dist/deployment.js +154 -0
  20. package/dist/fake-runtime-adapter.d.ts +37 -0
  21. package/dist/fake-runtime-adapter.js +276 -0
  22. package/dist/handlers/auth.d.ts +24 -0
  23. package/dist/handlers/auth.js +155 -0
  24. package/dist/handlers/common.d.ts +76 -0
  25. package/dist/handlers/common.js +127 -0
  26. package/dist/handlers/config-shape.d.ts +76 -0
  27. package/dist/handlers/config-shape.js +227 -0
  28. package/dist/handlers/config.d.ts +26 -0
  29. package/dist/handlers/config.js +161 -0
  30. package/dist/handlers/context-shape.d.ts +33 -0
  31. package/dist/handlers/context-shape.js +59 -0
  32. package/dist/handlers/dialog.d.ts +22 -0
  33. package/dist/handlers/dialog.js +292 -0
  34. package/dist/handlers/exec.d.ts +22 -0
  35. package/dist/handlers/exec.js +131 -0
  36. package/dist/handlers/fs-read.d.ts +63 -0
  37. package/dist/handlers/fs-read.js +159 -0
  38. package/dist/handlers/fs.d.ts +57 -0
  39. package/dist/handlers/fs.js +153 -0
  40. package/dist/handlers/grants-shape.d.ts +45 -0
  41. package/dist/handlers/grants-shape.js +130 -0
  42. package/dist/handlers/grants.d.ts +21 -0
  43. package/dist/handlers/grants.js +267 -0
  44. package/dist/handlers/health.d.ts +37 -0
  45. package/dist/handlers/health.js +139 -0
  46. package/dist/handlers/index.d.ts +40 -0
  47. package/dist/handlers/index.js +85 -0
  48. package/dist/handlers/permissions.d.ts +9 -0
  49. package/dist/handlers/permissions.js +183 -0
  50. package/dist/handlers/plugins.d.ts +18 -0
  51. package/dist/handlers/plugins.js +23 -0
  52. package/dist/handlers/prompts.d.ts +12 -0
  53. package/dist/handlers/prompts.js +120 -0
  54. package/dist/handlers/providers.d.ts +12 -0
  55. package/dist/handlers/providers.js +178 -0
  56. package/dist/handlers/questions.d.ts +23 -0
  57. package/dist/handlers/questions.js +115 -0
  58. package/dist/handlers/session-model.d.ts +38 -0
  59. package/dist/handlers/session-model.js +105 -0
  60. package/dist/handlers/session-move.d.ts +22 -0
  61. package/dist/handlers/session-move.js +188 -0
  62. package/dist/handlers/session-tools.d.ts +19 -0
  63. package/dist/handlers/session-tools.js +75 -0
  64. package/dist/handlers/sessions.d.ts +32 -0
  65. package/dist/handlers/sessions.js +242 -0
  66. package/dist/handlers/usage.d.ts +26 -0
  67. package/dist/handlers/usage.js +77 -0
  68. package/dist/handlers/worker.d.ts +12 -0
  69. package/dist/handlers/worker.js +82 -0
  70. package/dist/handlers/workspaces.d.ts +12 -0
  71. package/dist/handlers/workspaces.js +88 -0
  72. package/dist/harness.test-util.d.ts +101 -0
  73. package/dist/harness.test-util.js +176 -0
  74. package/dist/index.d.ts +33 -0
  75. package/dist/index.js +33 -0
  76. package/dist/main.d.ts +13 -0
  77. package/dist/main.js +35 -0
  78. package/dist/plugins.d.ts +105 -0
  79. package/dist/plugins.js +97 -0
  80. package/dist/question-registry.d.ts +124 -0
  81. package/dist/question-registry.js +212 -0
  82. package/dist/question-rows.d.ts +32 -0
  83. package/dist/question-rows.js +43 -0
  84. package/dist/replay/compare.d.ts +47 -0
  85. package/dist/replay/compare.js +89 -0
  86. package/dist/replay/e2e-replay.d.ts +46 -0
  87. package/dist/replay/e2e-replay.js +91 -0
  88. package/dist/replay/expect-compact.d.ts +31 -0
  89. package/dist/replay/expect-compact.js +92 -0
  90. package/dist/replay/fixtures.d.ts +52 -0
  91. package/dist/replay/fixtures.js +53 -0
  92. package/dist/replay/host.d.ts +32 -0
  93. package/dist/replay/host.js +62 -0
  94. package/dist/replay/index.d.ts +16 -0
  95. package/dist/replay/index.js +16 -0
  96. package/dist/replay/probes.d.ts +55 -0
  97. package/dist/replay/probes.js +225 -0
  98. package/dist/replay/report.d.ts +13 -0
  99. package/dist/replay/report.js +103 -0
  100. package/dist/replay/session-e2e.d.ts +44 -0
  101. package/dist/replay/session-e2e.js +91 -0
  102. package/dist/routes.d.ts +47 -0
  103. package/dist/routes.js +59 -0
  104. package/dist/runtime/attachments-llm.d.ts +13 -0
  105. package/dist/runtime/attachments-llm.js +37 -0
  106. package/dist/runtime/boot-recovery.d.ts +32 -0
  107. package/dist/runtime/boot-recovery.js +71 -0
  108. package/dist/runtime/context-snapshot.d.ts +59 -0
  109. package/dist/runtime/context-snapshot.js +113 -0
  110. package/dist/runtime/engine-grants.d.ts +121 -0
  111. package/dist/runtime/engine-grants.js +338 -0
  112. package/dist/runtime/engine-permissions.d.ts +29 -0
  113. package/dist/runtime/engine-permissions.js +92 -0
  114. package/dist/runtime/engine-plugins.d.ts +189 -0
  115. package/dist/runtime/engine-plugins.js +292 -0
  116. package/dist/runtime/engine-profile.d.ts +28 -0
  117. package/dist/runtime/engine-profile.js +88 -0
  118. package/dist/runtime/engine-session.d.ts +9 -0
  119. package/dist/runtime/engine-session.js +9 -0
  120. package/dist/runtime/fallback-contract.d.ts +32 -0
  121. package/dist/runtime/fallback-contract.js +10 -0
  122. package/dist/runtime/fallback-host.d.ts +134 -0
  123. package/dist/runtime/fallback-host.js +299 -0
  124. package/dist/runtime/host-autowake.d.ts +79 -0
  125. package/dist/runtime/host-autowake.js +86 -0
  126. package/dist/runtime/image-downgrade.d.ts +49 -0
  127. package/dist/runtime/image-downgrade.js +90 -0
  128. package/dist/runtime/inbox-message.d.ts +11 -0
  129. package/dist/runtime/inbox-message.js +20 -0
  130. package/dist/runtime/index.d.ts +17 -0
  131. package/dist/runtime/index.js +17 -0
  132. package/dist/runtime/ledger-view.d.ts +40 -0
  133. package/dist/runtime/ledger-view.js +56 -0
  134. package/dist/runtime/llm-assembly.d.ts +66 -0
  135. package/dist/runtime/llm-assembly.js +114 -0
  136. package/dist/runtime/offline-llm.d.ts +71 -0
  137. package/dist/runtime/offline-llm.js +132 -0
  138. package/dist/runtime/provider-target.d.ts +10 -0
  139. package/dist/runtime/provider-target.js +10 -0
  140. package/dist/runtime/question-host.d.ts +33 -0
  141. package/dist/runtime/question-host.js +41 -0
  142. package/dist/runtime/question-view.d.ts +48 -0
  143. package/dist/runtime/question-view.js +76 -0
  144. package/dist/runtime/real-runtime-adapter.d.ts +95 -0
  145. package/dist/runtime/real-runtime-adapter.js +553 -0
  146. package/dist/runtime/recovery-audit.d.ts +81 -0
  147. package/dist/runtime/recovery-audit.js +95 -0
  148. package/dist/runtime/recovery-view.d.ts +36 -0
  149. package/dist/runtime/recovery-view.js +63 -0
  150. package/dist/runtime/session-compose.d.ts +235 -0
  151. package/dist/runtime/session-compose.js +350 -0
  152. package/dist/runtime/session-grants.d.ts +36 -0
  153. package/dist/runtime/session-grants.js +69 -0
  154. package/dist/runtime/session-lifecycle.d.ts +40 -0
  155. package/dist/runtime/session-lifecycle.js +86 -0
  156. package/dist/runtime/session-publisher.d.ts +40 -0
  157. package/dist/runtime/session-publisher.js +48 -0
  158. package/dist/runtime/session-release.d.ts +53 -0
  159. package/dist/runtime/session-release.js +80 -0
  160. package/dist/runtime/test-util.d.ts +78 -0
  161. package/dist/runtime/test-util.js +162 -0
  162. package/dist/runtime/watchdog-view.d.ts +33 -0
  163. package/dist/runtime/watchdog-view.js +45 -0
  164. package/dist/runtime/worker-bridge.d.ts +76 -0
  165. package/dist/runtime/worker-bridge.js +156 -0
  166. package/dist/runtime/worker-live.d.ts +22 -0
  167. package/dist/runtime/worker-live.js +25 -0
  168. package/dist/runtime/worker-recovery.d.ts +35 -0
  169. package/dist/runtime/worker-recovery.js +64 -0
  170. package/dist/runtime/worker-table.d.ts +58 -0
  171. package/dist/runtime/worker-table.js +70 -0
  172. package/dist/runtime-adapter.d.ts +421 -0
  173. package/dist/runtime-adapter.js +76 -0
  174. package/dist/serial-queue.d.ts +16 -0
  175. package/dist/serial-queue.js +22 -0
  176. package/dist/server.d.ts +44 -0
  177. package/dist/server.js +119 -0
  178. package/dist/settings.d.ts +23 -0
  179. package/dist/settings.js +31 -0
  180. package/dist/sse.d.ts +67 -0
  181. package/dist/sse.js +186 -0
  182. package/dist/static.d.ts +20 -0
  183. package/dist/static.js +120 -0
  184. package/dist/store/builtin-sections.d.ts +52 -0
  185. package/dist/store/builtin-sections.js +118 -0
  186. package/dist/store/celestea-home.d.ts +12 -0
  187. package/dist/store/celestea-home.js +11 -0
  188. package/dist/store/fs-json.d.ts +51 -0
  189. package/dist/store/fs-json.js +119 -0
  190. package/dist/store/grants-audit.d.ts +84 -0
  191. package/dist/store/grants-audit.js +109 -0
  192. package/dist/store/grants-service.d.ts +44 -0
  193. package/dist/store/grants-service.js +41 -0
  194. package/dist/store/grants-tokens.d.ts +62 -0
  195. package/dist/store/grants-tokens.js +105 -0
  196. package/dist/store/grants.d.ts +113 -0
  197. package/dist/store/grants.js +238 -0
  198. package/dist/store/index.d.ts +36 -0
  199. package/dist/store/index.js +36 -0
  200. package/dist/store/mode.d.ts +35 -0
  201. package/dist/store/mode.js +41 -0
  202. package/dist/store/permissions.d.ts +61 -0
  203. package/dist/store/permissions.js +139 -0
  204. package/dist/store/prompts-compose.d.ts +56 -0
  205. package/dist/store/prompts-compose.js +62 -0
  206. package/dist/store/prompts-template.d.ts +26 -0
  207. package/dist/store/prompts-template.js +99 -0
  208. package/dist/store/prompts.d.ts +142 -0
  209. package/dist/store/prompts.js +273 -0
  210. package/dist/store/provider-probe.d.ts +81 -0
  211. package/dist/store/provider-probe.js +154 -0
  212. package/dist/store/providers.d.ts +97 -0
  213. package/dist/store/providers.js +219 -0
  214. package/dist/store/result.d.ts +29 -0
  215. package/dist/store/result.js +30 -0
  216. package/dist/store/session-id.d.ts +119 -0
  217. package/dist/store/session-id.js +220 -0
  218. package/dist/store/session-meta.d.ts +41 -0
  219. package/dist/store/session-meta.js +66 -0
  220. package/dist/store/session-ops.d.ts +109 -0
  221. package/dist/store/session-ops.js +264 -0
  222. package/dist/store/session-tools.d.ts +42 -0
  223. package/dist/store/session-tools.js +69 -0
  224. package/dist/store/sessions.d.ts +162 -0
  225. package/dist/store/sessions.js +307 -0
  226. package/dist/store/validate.d.ts +13 -0
  227. package/dist/store/validate.js +38 -0
  228. package/dist/store/workspaces.d.ts +67 -0
  229. package/dist/store/workspaces.js +228 -0
  230. package/dist/user-questions.d.ts +58 -0
  231. package/dist/user-questions.js +157 -0
  232. package/dist/version.d.ts +14 -0
  233. package/dist/version.js +52 -0
  234. package/package.json +38 -0
  235. package/webdist/assets/index-Bngs7gUm.js +98 -0
  236. package/webdist/assets/index-Bu6ci_rN.css +1 -0
  237. package/webdist/assets/katex-CCbS2qAY.js +257 -0
  238. package/webdist/assets/mhchem-CnUN8HwY.js +1 -0
  239. package/webdist/build-meta.json +7 -0
  240. package/webdist/index.html +261 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `POST /api/exec` (iteration G2) — run a shell command NOW, with no model in
3
+ * the loop. `/run <cmd>` and `!<cmd>` are the UI's callers.
4
+ *
5
+ * Three rules from `docs/iteration-g-workbench.md` §1:
6
+ * 1. it reuses the run_shell execution path — the SAME `selectSandboxDetailed`
7
+ * policy, `sessionSandboxConfig` (workdir/root), `sanitizedEnv` allowlist
8
+ * and output cap the engine's `run_shell` uses, so isolation cannot fork;
9
+ * 2. it passes the PERMISSION gate: a preset that denies `run_shell`, or a
10
+ * sandbox policy that refuses to execute, is a structured refusal — never a
11
+ * silent run;
12
+ * 3. it is NOT a tool: nothing here touches the model-visible tool face.
13
+ *
14
+ * The `sandbox` block carries ONLY the contract fields (provider / net_isolated
15
+ * / tmp_private / seccomp + optional cpu_sec), never host diagnostics.
16
+ */
17
+ import { selectSandboxDetailed, sessionSandboxConfig } from "@celestea/tools";
18
+ import { effectiveGrantsOf } from "../runtime/engine-grants.js";
19
+ import { nowSec } from "../store/grants-service.js";
20
+ import { sessionWorkspaceOf } from "../store/sessions.js";
21
+ import { errText } from "../store/result.js";
22
+ import { failJson, readJsonBody, strField } from "./common.js";
23
+ /** The structured refusal when this session's preset denies the shell. */
24
+ export const SHELL_DENIED_CODE = "shell_denied";
25
+ function readCommand(body) {
26
+ const value = body["command"];
27
+ if (typeof value !== "string")
28
+ return { error: "field 'command' must be a string" };
29
+ if (value.trim() === "")
30
+ return { error: "field 'command' must not be empty" };
31
+ return value;
32
+ }
33
+ /** Resolve the target session (explicit id, else the detached/default scope). */
34
+ function targetSession(deps, id) {
35
+ if (id === undefined || id === "")
36
+ return { resolved: null, status: 0, error: "" };
37
+ const resolved = deps.sessions.require(id);
38
+ if (resolved.ok)
39
+ return { resolved: resolved.value, status: 0, error: "" };
40
+ return { resolved: null, status: resolved.status, error: resolved.error };
41
+ }
42
+ /**
43
+ * The session's shell is denied when the permission baseline's `toolDeny`
44
+ * contains `run_shell`. Read through the SAME grant reader the tool face uses,
45
+ * so the UI cannot disagree with what a turn would allow.
46
+ */
47
+ function shellDeniedReason(deps, resolved) {
48
+ const dir = resolved?.dir ?? null;
49
+ const sessionId = resolved?.id ?? null;
50
+ const grants = effectiveGrantsOf(dir, sessionId, deps.grants.env, nowSec(deps.grants));
51
+ if (grants.grants.toolDeny.includes("run_shell")) {
52
+ return "this session's permission preset denies run_shell; grant a preset that allows the shell (or widen it) and retry";
53
+ }
54
+ return null;
55
+ }
56
+ /**
57
+ * The execution boundary of the target session, exactly as the engine composes
58
+ * it: provider policy + session scope + grants. Denied -> `{sandbox}` is a
59
+ * RefusingSandbox whose `run` throws the structured policy error.
60
+ */
61
+ function sandboxFor(deps, resolved) {
62
+ const env = deps.grants.env;
63
+ const sessionId = resolved?.id ?? null;
64
+ const dir = resolved?.dir ?? null;
65
+ const grants = effectiveGrantsOf(dir, sessionId, env, nowSec(deps.grants)).grants;
66
+ const scope = resolved === null ? null : (sessionWorkspaceOf(resolved) === null ? null : { workspace: resolved.wsPath });
67
+ const config = sessionSandboxConfig(scope, env);
68
+ const selection = selectSandboxDetailed({
69
+ env,
70
+ grants: { network: grants.network, unsandboxed: grants.unsandboxed, workspaceWritable: grants.workspaceWritable, writeRoots: grants.writeRoots },
71
+ config,
72
+ });
73
+ return selection.sandbox;
74
+ }
75
+ /** Only the contract's four fields + optional cpu_sec (never host diagnostics). */
76
+ function sandboxView(result) {
77
+ const meta = result.sandbox;
78
+ return {
79
+ provider: meta.provider,
80
+ net_isolated: meta.net_isolated,
81
+ tmp_private: meta.tmp_private,
82
+ seccomp: meta.seccomp,
83
+ ...(meta.cpu_sec === undefined ? {} : { cpu_sec: meta.cpu_sec }),
84
+ };
85
+ }
86
+ export function registerExec(app, deps, table) {
87
+ const route = table.get("post_exec");
88
+ app.on(route.method, route.honoPath, async (c) => {
89
+ const body = await readJsonBody(c);
90
+ if (!body.ok)
91
+ return body.response;
92
+ const sessionField = strField(c, body.body, "session");
93
+ if (!sessionField.ok)
94
+ return sessionField.response;
95
+ const workdirField = strField(c, body.body, "workdir");
96
+ if (!workdirField.ok)
97
+ return workdirField.response;
98
+ const command = readCommand(body.body);
99
+ if (typeof command !== "string")
100
+ return failJson(c, 422, command.error);
101
+ const target = targetSession(deps, sessionField.value);
102
+ if (target.status !== 0)
103
+ return failJson(c, target.status, target.error);
104
+ const denied = shellDeniedReason(deps, target.resolved);
105
+ if (denied !== null)
106
+ return failJson(c, 403, denied, { code: SHELL_DENIED_CODE });
107
+ const sandbox = sandboxFor(deps, target.resolved);
108
+ const started = Date.now();
109
+ try {
110
+ const run = await sandbox.run({
111
+ command,
112
+ ...(workdirField.value === undefined ? {} : { workdir: workdirField.value }),
113
+ ...(typeof body.body["timeout_ms"] === "number" ? { timeoutMs: body.body["timeout_ms"] } : {}),
114
+ });
115
+ return c.json({
116
+ ok: true,
117
+ exit_code: run.exit_code,
118
+ signal: run.signal ?? null,
119
+ stdout: run.stdout,
120
+ stderr: run.stderr,
121
+ duration_ms: Date.now() - started,
122
+ sandbox: sandboxView(run),
123
+ });
124
+ }
125
+ catch (e) {
126
+ // A sandbox/policy failure is a structured refusal, never a 500 guess.
127
+ return failJson(c, 400, errText(e));
128
+ }
129
+ });
130
+ return [route.id];
131
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `GET /api/fs/read` (iteration G follow-up) — read ONE file for the file
3
+ * manager's viewer. Before this endpoint the Win-style manager could list a
4
+ * directory but not OPEN a file (F2's preview P0 only rendered content the
5
+ * session already had).
6
+ *
7
+ * Frozen wire format (`docs/iteration-g-workbench.md` §0.1 follow-up):
8
+ * 200: { path, size, kind: "text"|"binary", text, offset, limit, totalLines, truncated }
9
+ * 4xx: { error, code? }
10
+ *
11
+ * Semantics are the `read_file` TOOL's, not a second rulebook:
12
+ * - read-only; `path` must be absolute;
13
+ * - a directory is an error (EISDIR), never a listing;
14
+ * - binary detection shares the tool's sniff window (`BINARY_SNIFF_BYTES`):
15
+ * a NUL/C0 control (except \t \n \r \f ESC) or a fatal UTF-8 decode means
16
+ * `kind: "binary"` and NO body text;
17
+ * - the byte budget is `MAX_READ_BYTES` (256 KiB) and pagination is
18
+ * `offset` (1-based line) + `limit` (lines, default `DEFAULT_READ_LIMIT`);
19
+ * an over-budget / beyond-window read sets `truncated: true` EXPLICITLY.
20
+ *
21
+ * Trust boundary: this endpoint shares `GET /api/fs/list`'s exactly — it is
22
+ * read-only, it never follows a symbolic link (the same discipline the listing
23
+ * applies to entries), and it has NO auth of its own: the server binds loopback
24
+ * by default, and a non-loopback bind is refused unless a token is configured
25
+ * (see `auth/api-token.ts`). It must never be widened without that gate.
26
+ */
27
+ import type { Hono } from "hono";
28
+ import type { RouteTable } from "../routes.js";
29
+ import type { Deps } from "./common.js";
30
+ /** The frozen 200 body. */
31
+ export interface FsReadBody {
32
+ path: string;
33
+ size: number;
34
+ kind: "text" | "binary";
35
+ /** The requested window; "" for a binary file (never decoded). */
36
+ text: string;
37
+ /** 1-based first line of the returned window. */
38
+ offset: number;
39
+ /** Effective line budget. */
40
+ limit: number;
41
+ /** Lines in the file (0 for a binary file). */
42
+ totalLines: number;
43
+ /** true when the response is a WINDOW (more lines and/or the 256 KiB budget). */
44
+ truncated: boolean;
45
+ }
46
+ type ReadOutcome = {
47
+ ok: true;
48
+ body: FsReadBody;
49
+ } | {
50
+ ok: false;
51
+ error: string;
52
+ code: string;
53
+ };
54
+ /** NUL, or a C0 control other than \t (09) \n (0a) \r (0d) \f (0c) ESC (1b). */
55
+ export declare function hasBinaryControl(bytes: Buffer): boolean;
56
+ /** true when the window is not valid UTF-8 (fatal decode throws). */
57
+ export declare function isFatalUtf8(bytes: Buffer): boolean;
58
+ /** The shared `read_file` rule: control bytes or invalid UTF-8 ⇒ binary. */
59
+ export declare function classifyBytes(bytes: Buffer): "text" | "binary";
60
+ /** Read one file for the API (exported for the unit tests). */
61
+ export declare function readFileForApi(path: string, offset: number, limit: number): Promise<ReadOutcome>;
62
+ export declare function registerFsRead(app: Hono, _deps: Deps, table: RouteTable): string;
63
+ export {};
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `GET /api/fs/read` (iteration G follow-up) — read ONE file for the file
3
+ * manager's viewer. Before this endpoint the Win-style manager could list a
4
+ * directory but not OPEN a file (F2's preview P0 only rendered content the
5
+ * session already had).
6
+ *
7
+ * Frozen wire format (`docs/iteration-g-workbench.md` §0.1 follow-up):
8
+ * 200: { path, size, kind: "text"|"binary", text, offset, limit, totalLines, truncated }
9
+ * 4xx: { error, code? }
10
+ *
11
+ * Semantics are the `read_file` TOOL's, not a second rulebook:
12
+ * - read-only; `path` must be absolute;
13
+ * - a directory is an error (EISDIR), never a listing;
14
+ * - binary detection shares the tool's sniff window (`BINARY_SNIFF_BYTES`):
15
+ * a NUL/C0 control (except \t \n \r \f ESC) or a fatal UTF-8 decode means
16
+ * `kind: "binary"` and NO body text;
17
+ * - the byte budget is `MAX_READ_BYTES` (256 KiB) and pagination is
18
+ * `offset` (1-based line) + `limit` (lines, default `DEFAULT_READ_LIMIT`);
19
+ * an over-budget / beyond-window read sets `truncated: true` EXPLICITLY.
20
+ *
21
+ * Trust boundary: this endpoint shares `GET /api/fs/list`'s exactly — it is
22
+ * read-only, it never follows a symbolic link (the same discipline the listing
23
+ * applies to entries), and it has NO auth of its own: the server binds loopback
24
+ * by default, and a non-loopback bind is refused unless a token is configured
25
+ * (see `auth/api-token.ts`). It must never be widened without that gate.
26
+ */
27
+ import { lstatSync } from "node:fs";
28
+ import { open } from "node:fs/promises";
29
+ import { BINARY_SNIFF_BYTES, DEFAULT_READ_LIMIT, isToolFailure, readTextLines } from "@celestea/tools";
30
+ import { isAbsolutePath } from "../store/session-id.js";
31
+ import { errText } from "../store/result.js";
32
+ /** NUL, or a C0 control other than \t (09) \n (0a) \r (0d) \f (0c) ESC (1b). */
33
+ export function hasBinaryControl(bytes) {
34
+ for (const byte of bytes) {
35
+ if (byte === 0)
36
+ return true;
37
+ if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && byte !== 0x0c && byte !== 0x1b)
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ /** true when the window is not valid UTF-8 (fatal decode throws). */
43
+ export function isFatalUtf8(bytes) {
44
+ try {
45
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
46
+ return false;
47
+ }
48
+ catch {
49
+ return true;
50
+ }
51
+ }
52
+ /** The shared `read_file` rule: control bytes or invalid UTF-8 ⇒ binary. */
53
+ export function classifyBytes(bytes) {
54
+ if (hasBinaryControl(bytes))
55
+ return "binary";
56
+ return isFatalUtf8(bytes) ? "binary" : "text";
57
+ }
58
+ /** Sniff the first `BINARY_SNIFF_BYTES` bytes (the tool's own window). */
59
+ async function sniffKind(path, size) {
60
+ if (size === 0)
61
+ return "text";
62
+ const handle = await open(path, "r");
63
+ try {
64
+ const length = Math.min(size, BINARY_SNIFF_BYTES);
65
+ const buffer = Buffer.alloc(length);
66
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
67
+ return classifyBytes(buffer.subarray(0, bytesRead));
68
+ }
69
+ finally {
70
+ await handle.close().catch(() => undefined);
71
+ }
72
+ }
73
+ /** Validate one positive-integer query param. */
74
+ function positiveInt(raw, fallback, name) {
75
+ if (raw === undefined || raw.trim() === "")
76
+ return { value: fallback };
77
+ const parsed = Number.parseInt(raw, 10);
78
+ if (!Number.isInteger(parsed) || parsed < 1 || String(parsed) !== raw.trim()) {
79
+ return { error: `query '${name}' must be a positive integer (got ${JSON.stringify(raw)})`, code: `invalid_${name}` };
80
+ }
81
+ return { value: parsed };
82
+ }
83
+ function lstatOutcome(path) {
84
+ let stat;
85
+ try {
86
+ stat = lstatSync(path);
87
+ }
88
+ catch (e) {
89
+ const code = e.code;
90
+ if (code === "ENOENT")
91
+ return { error: `path '${path}' does not exist`, code: "not_found" };
92
+ return { error: `cannot read '${path}': ${errText(e)}`, code: "io_error" };
93
+ }
94
+ // Same discipline as the listing: a link is reported, never traversed.
95
+ if (stat.isSymbolicLink())
96
+ return { error: `path '${path}' is a symbolic link; links are not followed`, code: "symlink" };
97
+ if (stat.isDirectory())
98
+ return { error: `path '${path}' is a directory, not a file`, code: "is_directory" };
99
+ return { size: stat.size };
100
+ }
101
+ /** Read one file for the API (exported for the unit tests). */
102
+ export async function readFileForApi(path, offset, limit) {
103
+ if (!isAbsolutePath(path))
104
+ return { ok: false, error: `path '${path}' must be absolute`, code: "not_absolute" };
105
+ const meta = lstatOutcome(path);
106
+ if ("error" in meta)
107
+ return { ok: false, error: meta.error, code: meta.code };
108
+ const binaryBody = (kind) => ({ path, size: meta.size, kind, text: "", offset, limit, totalLines: 0, truncated: false });
109
+ let kind;
110
+ try {
111
+ kind = await sniffKind(path, meta.size);
112
+ }
113
+ catch (e) {
114
+ return { ok: false, error: `cannot read '${path}': ${errText(e)}`, code: "io_error" };
115
+ }
116
+ if (kind === "binary")
117
+ return { ok: true, body: binaryBody("binary") };
118
+ try {
119
+ const window = await readTextLines(path, offset - 1, limit);
120
+ return {
121
+ ok: true,
122
+ body: {
123
+ path,
124
+ size: meta.size,
125
+ kind: "text",
126
+ text: window.text,
127
+ offset: window.offset + 1,
128
+ limit: window.limit,
129
+ totalLines: window.totalLines,
130
+ truncated: window.truncated || window.hasMore,
131
+ },
132
+ };
133
+ }
134
+ catch (e) {
135
+ // A NUL beyond the sniff window: the tool says binary; classify the same way.
136
+ if (isToolFailure(e) && e.kind === "binary_file")
137
+ return { ok: true, body: binaryBody("binary") };
138
+ return { ok: false, error: `cannot read '${path}': ${errText(e)}`, code: "io_error" };
139
+ }
140
+ }
141
+ export function registerFsRead(app, _deps, table) {
142
+ const route = table.get("get_fs_read");
143
+ app.on(route.method, route.honoPath, async (c) => {
144
+ const path = (c.req.query("path") ?? "").trim();
145
+ if (path === "")
146
+ return c.json({ error: "query 'path' is required", code: "missing_path" }, 400);
147
+ const offset = positiveInt(c.req.query("offset"), 1, "offset");
148
+ if ("error" in offset)
149
+ return c.json({ error: offset.error, code: offset.code }, 400);
150
+ const limit = positiveInt(c.req.query("limit"), DEFAULT_READ_LIMIT, "limit");
151
+ if ("error" in limit)
152
+ return c.json({ error: limit.error, code: limit.code }, 400);
153
+ const out = await readFileForApi(path, offset.value, limit.value);
154
+ if (!out.ok)
155
+ return c.json({ error: out.error, code: out.code }, 400);
156
+ return c.json(out.body);
157
+ });
158
+ return route.id;
159
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `GET /api/fs/browse` — `src/workspaces.rs:700-766` — iteration G's
3
+ * `GET /api/fs/list` (the Win-style file manager's read-only listing) and its
4
+ * follow-up `GET /api/fs/read` (the viewer; see `fs-read.ts`).
5
+ *
6
+ * `browse` lists DIRECTORY names only (the legacy shape). `list` lists
7
+ * directories AND files as `{name,type,size,mtime}`, reusing the same
8
+ * discipline: absolute path, dot-names hidden, symlinks never followed, sorted,
9
+ * capped at `MAX_DIR_ENTRIES` (with an explicit `truncated` flag). `read`
10
+ * applies that SAME discipline to one file (never follows a link, same trust
11
+ * boundary) and shares the `read_file` tool's binary/pagination rules.
12
+ *
13
+ * Wire format is FROZEN by `docs/iteration-g-workbench.md` §0.1:
14
+ * type: "dir" | "file" (a symlink is reported as the link itself, i.e.
15
+ * "file" when it is not a directory — never followed)
16
+ * mtime: ISO-8601 string (null when `lstat` cannot read it)
17
+ * `roots` and `truncated` are ADDITIVE fields on top of that frozen minimum.
18
+ *
19
+ * `roots` is a hardcoded informational constant — listing is NOT restricted to
20
+ * it; the endpoints have no auth, which is why the default bind is loopback.
21
+ *
22
+ * W885 (W883 E3): "absolute" and "parent" are PLATFORM questions, not
23
+ * `startsWith("/")` and `lastIndexOf("/")`. The path helpers take the platform
24
+ * as an argument, so the win32 rules are unit-tested on Linux; the routes
25
+ * themselves still run under the host's own platform.
26
+ */
27
+ import type { Hono } from "hono";
28
+ import type { RouteTable } from "../routes.js";
29
+ import type { Deps } from "./common.js";
30
+ /** One entry of `GET /api/fs/list` (frozen shape, §0.1). */
31
+ export interface FsListEntry {
32
+ name: string;
33
+ /** `dir` for a directory; `file` for everything else (symlinks NOT followed). */
34
+ type: "dir" | "file";
35
+ /** Bytes for files / symlinks; `null` for directories. */
36
+ size: number | null;
37
+ /** ISO-8601 mtime from `lstat`; null when it could not be read. */
38
+ mtime: string | null;
39
+ }
40
+ /** Absolute under `platform` (`C:\…`, `\\\\server\\share`, `/…`). */
41
+ export declare function isBrowsablePath(path: string, platform?: string): boolean;
42
+ /**
43
+ * The parent directory of `path`, never above the filesystem root: `C:\` is
44
+ * its own parent on Windows, `/` on POSIX, `\\\\server\\share\\` on a UNC share.
45
+ */
46
+ export declare function browseParent(path: string, platform?: string): string;
47
+ /**
48
+ * The Win-style listing: directories first, then files, each by name; dot-names
49
+ * hidden; a symlink is `file` (its own lstat, never the target).
50
+ */
51
+ export declare function listDirectory(path: string): {
52
+ entries: FsListEntry[];
53
+ truncated: boolean;
54
+ } | {
55
+ error: string;
56
+ };
57
+ export declare function registerFs(app: Hono, _deps: Deps, table: RouteTable): string[];
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `GET /api/fs/browse` — `src/workspaces.rs:700-766` — iteration G's
3
+ * `GET /api/fs/list` (the Win-style file manager's read-only listing) and its
4
+ * follow-up `GET /api/fs/read` (the viewer; see `fs-read.ts`).
5
+ *
6
+ * `browse` lists DIRECTORY names only (the legacy shape). `list` lists
7
+ * directories AND files as `{name,type,size,mtime}`, reusing the same
8
+ * discipline: absolute path, dot-names hidden, symlinks never followed, sorted,
9
+ * capped at `MAX_DIR_ENTRIES` (with an explicit `truncated` flag). `read`
10
+ * applies that SAME discipline to one file (never follows a link, same trust
11
+ * boundary) and shares the `read_file` tool's binary/pagination rules.
12
+ *
13
+ * Wire format is FROZEN by `docs/iteration-g-workbench.md` §0.1:
14
+ * type: "dir" | "file" (a symlink is reported as the link itself, i.e.
15
+ * "file" when it is not a directory — never followed)
16
+ * mtime: ISO-8601 string (null when `lstat` cannot read it)
17
+ * `roots` and `truncated` are ADDITIVE fields on top of that frozen minimum.
18
+ *
19
+ * `roots` is a hardcoded informational constant — listing is NOT restricted to
20
+ * it; the endpoints have no auth, which is why the default bind is loopback.
21
+ *
22
+ * W885 (W883 E3): "absolute" and "parent" are PLATFORM questions, not
23
+ * `startsWith("/")` and `lastIndexOf("/")`. The path helpers take the platform
24
+ * as an argument, so the win32 rules are unit-tested on Linux; the routes
25
+ * themselves still run under the host's own platform.
26
+ */
27
+ import { lstatSync, readdirSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { FS_ROOTS, MAX_DIR_ENTRIES } from "../config.js";
30
+ import { isAbsolutePath, parentDir, rootOf } from "../store/session-id.js";
31
+ import { errText } from "../store/result.js";
32
+ import { registerFsRead } from "./fs-read.js";
33
+ /** Absolute under `platform` (`C:\…`, `\\\\server\\share`, `/…`). */
34
+ export function isBrowsablePath(path, platform = process.platform) {
35
+ return isAbsolutePath(path, platform);
36
+ }
37
+ /**
38
+ * The parent directory of `path`, never above the filesystem root: `C:\` is
39
+ * its own parent on Windows, `/` on POSIX, `\\\\server\\share\\` on a UNC share.
40
+ */
41
+ export function browseParent(path, platform = process.platform) {
42
+ const root = rootOf(path, platform);
43
+ const trimmed = stripTrailingSeparators(path, platform);
44
+ if (trimmed === "" || trimmed === root)
45
+ return root === "" ? path : root;
46
+ const parent = parentDir(trimmed, platform);
47
+ return parent === "" ? root : parent;
48
+ }
49
+ /** `/a/b/` -> `/a/b`; a bare root keeps its separator (`C:\`, `/`). */
50
+ function stripTrailingSeparators(path, platform) {
51
+ if (path === rootOf(path, platform))
52
+ return path;
53
+ let out = path;
54
+ while (out.length > 1 && (out.endsWith("/") || out.endsWith("\\")))
55
+ out = out.slice(0, -1);
56
+ return out;
57
+ }
58
+ function browseDirs(path) {
59
+ if (!isBrowsablePath(path))
60
+ return { error: "path '" + path + "' must be absolute" };
61
+ const read = readDirectory(path);
62
+ if ("error" in read)
63
+ return read;
64
+ const dirs = [];
65
+ for (const entry of read.entries) {
66
+ if (!entry.isDirectory() || entry.name.startsWith("."))
67
+ continue;
68
+ dirs.push(entry.name);
69
+ }
70
+ dirs.sort();
71
+ return { dirs: dirs.slice(0, MAX_DIR_ENTRIES) };
72
+ }
73
+ /** Read a directory, mapping the two expected failures onto the frozen texts. */
74
+ function readDirectory(path) {
75
+ try {
76
+ return { entries: readdirSync(path, { withFileTypes: true }) };
77
+ }
78
+ catch (e) {
79
+ const code = e.code;
80
+ if (code === "ENOENT" || code === "ENOTDIR")
81
+ return { error: "path '" + path + "' is not an existing directory" };
82
+ return { error: "cannot read '" + path + "': " + errText(e) };
83
+ }
84
+ }
85
+ /**
86
+ * The Win-style listing: directories first, then files, each by name; dot-names
87
+ * hidden; a symlink is `file` (its own lstat, never the target).
88
+ */
89
+ export function listDirectory(path) {
90
+ if (!isBrowsablePath(path))
91
+ return { error: "path '" + path + "' must be absolute" };
92
+ const read = readDirectory(path);
93
+ if ("error" in read)
94
+ return read;
95
+ const entries = [];
96
+ for (const entry of read.entries) {
97
+ if (entry.name.startsWith("."))
98
+ continue;
99
+ entries.push(describeEntry(path, entry.name, entry));
100
+ }
101
+ entries.sort(compareEntries);
102
+ const truncated = entries.length > MAX_DIR_ENTRIES;
103
+ return { entries: entries.slice(0, MAX_DIR_ENTRIES), truncated };
104
+ }
105
+ function describeEntry(dir, name, dirent) {
106
+ const type = dirent.isDirectory() ? "dir" : "file";
107
+ let size = null;
108
+ let mtime = null;
109
+ try {
110
+ // lstat, not stat: a symlink is never followed to its target.
111
+ const info = lstatSync(join(dir, name));
112
+ size = info.isDirectory() ? null : info.size;
113
+ mtime = new Date(info.mtimeMs).toISOString();
114
+ }
115
+ catch {
116
+ // The entry vanished between readdir and lstat: keep it, with unknown stats.
117
+ }
118
+ return { name, type, size, mtime };
119
+ }
120
+ function compareEntries(a, b) {
121
+ const aDir = a.type === "dir" ? 0 : 1;
122
+ const bDir = b.type === "dir" ? 0 : 1;
123
+ if (aDir !== bDir)
124
+ return aDir - bDir;
125
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
126
+ }
127
+ export function registerFs(app, _deps, table) {
128
+ const browse = table.get("get_fs_browse");
129
+ const list = table.get("get_fs_list");
130
+ app.on(browse.method, browse.honoPath, (c) => {
131
+ const asked = (c.req.query("path") ?? "").trim();
132
+ const raw = asked === "" ? "/" : asked;
133
+ const out = browseDirs(raw);
134
+ if ("error" in out) {
135
+ const body = { path: raw, parent: null, dirs: [], roots: FS_ROOTS, error: out.error };
136
+ return c.json(body, 400);
137
+ }
138
+ const body = { path: raw, parent: browseParent(raw), dirs: out.dirs, roots: FS_ROOTS };
139
+ return c.json(body);
140
+ });
141
+ app.on(list.method, list.honoPath, (c) => {
142
+ const asked = (c.req.query("path") ?? "").trim();
143
+ const raw = asked === "" ? "/" : asked;
144
+ const out = listDirectory(raw);
145
+ if ("error" in out) {
146
+ const body = { path: raw, parent: null, entries: [], roots: FS_ROOTS, truncated: false, error: out.error };
147
+ return c.json(body, 400);
148
+ }
149
+ const body = { path: raw, parent: browseParent(raw), entries: out.entries, roots: FS_ROOTS, truncated: out.truncated };
150
+ return c.json(body);
151
+ });
152
+ return [browse.id, list.id, registerFsRead(app, _deps, table)];
153
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Request parsing / response shaping for the grant endpoints (W516 §6).
3
+ *
4
+ * Kept apart from the routes so both stay inside the architecture budget: the
5
+ * error strings here are the FROZEN contract strings of §6.2, and a rejected
6
+ * scope value is never echoed (§5.4 — it may be a credential).
7
+ */
8
+ import type { Context } from "hono";
9
+ import type { EffectiveGrants } from "../runtime/engine-grants.js";
10
+ import { type GrantCap, type GrantRecord, type GrantScope } from "../store/grants.js";
11
+ import { type JsonObject } from "./common.js";
12
+ /** Who the grant is attributed to. Server-side constant: never model text. */
13
+ export declare const GRANT_ACTOR = "ui:operator";
14
+ export interface GrantRequest {
15
+ cap: GrantCap;
16
+ scope: GrantScope;
17
+ ttlSec: number;
18
+ usesLeft: number | null;
19
+ note: string;
20
+ /** Canonical hash of `(cap, scope)` — what the confirm token is bound to. */
21
+ scopeHash: string;
22
+ }
23
+ /** A refused body: the response to send plus the SANITIZED reason to audit. */
24
+ export type GrantRequestBody = {
25
+ ok: true;
26
+ value: GrantRequest;
27
+ } | {
28
+ ok: false;
29
+ response: Response;
30
+ reason: string;
31
+ };
32
+ /** §6.2 body: `{cap, scope?, ttl_sec?, uses_left?, note?}` + the frozen 400s. */
33
+ export declare function parseGrantRequest(c: Context, body: JsonObject, env: NodeJS.ProcessEnv): GrantRequestBody;
34
+ /**
35
+ * The cap must be known AND offered here: `unsandboxed` only exists behind
36
+ * `CELESTEA_GRANTS_ALLOW_UNSANDBOXED=1` (§2.2), so without it the cap is simply
37
+ * not valid — the frozen `invalid cap '<x>'` error, never a special case.
38
+ */
39
+ export declare function offeredCap(raw: string, env: NodeJS.ProcessEnv): GrantCap | null;
40
+ /** `ttl_sec`: positive integer, 0 = no expiry, capped per cap (§2.3). */
41
+ export declare function readTtl(c: Context, body: JsonObject, cap: GrantCap): number | Response;
42
+ /** One GET entry: the stored record plus the read-time `expired` flag (§6.1). */
43
+ export declare function entryJson(record: GrantRecord, now: number): JsonObject;
44
+ /** The `effective` snapshot of §6.1 (snake_case; the UI shows it verbatim). */
45
+ export declare function effectiveJson(grants: EffectiveGrants): JsonObject;