@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,155 @@
1
+ /**
2
+ * W767 — the three endpoints of Studio's OWN login-cookie gate.
3
+ *
4
+ * GET /login the self-contained login page (always reachable)
5
+ * POST /auth/login verify against Studio's password file, then Set-Cookie
6
+ * GET /auth/check 200/401 for nginx `auth_request` (the actual gate)
7
+ *
8
+ * nginx asks `/auth/check` for every other path and rewrites a 401 into a
9
+ * redirect to `/login`; these three are the only paths exempted there. The
10
+ * backend therefore stays usable from localhost with no cookie (the engine's own
11
+ * API is unchanged) while the public host is gated by the cookie.
12
+ *
13
+ * Hard rules kept here:
14
+ * - the password is verified through `htpasswd -vbi` (stdin, never argv);
15
+ * - a SUCCESSFUL login answers 200 with `Set-Cookie` AND the navigation in the
16
+ * same response (never a 302 — some mobile clients drop the cookie on a
17
+ * redirect);
18
+ * - the cookie value never reaches a log line, and a wrong password and an
19
+ * unknown user are indistinguishable (401 both ways).
20
+ */
21
+ import { failJson } from "./common.js";
22
+ import { AUTH_COOKIE, AUTH_MAX_FAILURES, AUTH_WINDOW_MS, authCookie, cookieValue, createFailureLimiter, loadAuthSecret, loginPage, LOGIN_OK_PAGE, mintToken, verifyPassword, verifyToken, } from "../auth/index.js";
23
+ const HTML = "text/html; charset=utf-8";
24
+ const NO_STORE = "no-store";
25
+ export function registerAuth(app, deps, table) {
26
+ const gate = createGate(deps);
27
+ const page = table.get("get_login");
28
+ const login = table.get("post_auth_login");
29
+ const check = table.get("get_auth_check");
30
+ app.on(page.method, page.honoPath, () => pageResponse(loginPage(), 200));
31
+ app.on(check.method, check.honoPath, (c) => {
32
+ const user = cookieUser(c, gate);
33
+ return user === null
34
+ ? jsonResponse({ ok: false, error: "unauthorized" }, 401)
35
+ : jsonResponse({ ok: true, user }, 200);
36
+ });
37
+ app.on(login.method, login.honoPath, (c) => loginResponse(c, gate));
38
+ return [page.id, login.id, check.id];
39
+ }
40
+ function createGate(deps) {
41
+ const limiter = createFailureLimiter({ now: Date.now, windowMs: AUTH_WINDOW_MS, maxFailures: AUTH_MAX_FAILURES });
42
+ const htpasswdFile = deps.config.paths.authHtpasswdFile;
43
+ let secret = null;
44
+ return {
45
+ limiter,
46
+ htpasswdFile,
47
+ secret: () => (secret ??= loadAuthSecret(deps.config.paths.authSecretFile)),
48
+ };
49
+ }
50
+ /** The user of a valid cookie, or null (missing / tampered / expired / unknown). */
51
+ function cookieUser(c, gate) {
52
+ const raw = cookieValue(c.req.header("cookie"), AUTH_COOKIE);
53
+ if (raw === null)
54
+ return null;
55
+ const verdict = verifyToken(raw, gate.secret(), Math.floor(Date.now() / 1000));
56
+ return verdict.ok ? verdict.user : null;
57
+ }
58
+ /** One failed attempt is counted against BOTH the username and the client IP. */
59
+ function attemptKeys(c, user) {
60
+ return [`u:${user.toLowerCase()}`, `ip:${clientIp(c)}`];
61
+ }
62
+ async function loginResponse(c, gate) {
63
+ const wantsJson = prefersJson(c);
64
+ const creds = await credentialsOf(c);
65
+ if (creds === null)
66
+ return denied(c, wantsJson, 401, "invalid username or password", "用户名或密码不正确");
67
+ const keys = attemptKeys(c, creds.user);
68
+ if (keys.some((key) => gate.limiter.blocked(key))) {
69
+ return wantsJson
70
+ ? failJson(c, 429, "too many failed login attempts", { retry_after: AUTH_WINDOW_MS / 1000 })
71
+ : pageResponse(loginPage("尝试次数过多,请稍后再试"), 429);
72
+ }
73
+ const verdict = verifyPassword(gate.htpasswdFile, creds.user, creds.pass);
74
+ if (verdict !== "ok") {
75
+ for (const key of keys)
76
+ gate.limiter.fail(key);
77
+ if (verdict === "error") {
78
+ warn(`password check failed to run (file=${gate.htpasswdFile}) — login denied`);
79
+ return denied(c, wantsJson, 500, "credential store unavailable", "凭据校验服务不可用,请联系管理员");
80
+ }
81
+ return denied(c, wantsJson, 401, "invalid username or password", "用户名或密码不正确");
82
+ }
83
+ for (const key of keys)
84
+ gate.limiter.clear(key);
85
+ const token = mintToken(creds.user, gate.secret(), Math.floor(Date.now() / 1000));
86
+ return wantsJson
87
+ ? jsonResponse({ ok: true, user: creds.user }, 200, { "set-cookie": authCookie(token) })
88
+ : pageResponse(LOGIN_OK_PAGE, 200, { "set-cookie": authCookie(token) });
89
+ }
90
+ function denied(c, wantsJson, status, error, pageError) {
91
+ return wantsJson ? failJson(c, status, error) : pageResponse(loginPage(pageError), status);
92
+ }
93
+ /** `{username, password}` out of a form post OR a JSON body; null when absent. */
94
+ async function credentialsOf(c) {
95
+ let raw;
96
+ try {
97
+ raw = await c.req.text();
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (raw.trim() === "")
103
+ return null;
104
+ const type = (c.req.header("content-type") ?? "").toLowerCase();
105
+ if (type.includes("json")) {
106
+ try {
107
+ return pickCredentials(JSON.parse(raw));
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ return pickCredentials(Object.fromEntries(new URLSearchParams(raw)));
114
+ }
115
+ function pickCredentials(body) {
116
+ const user = body["username"];
117
+ const pass = body["password"];
118
+ if (typeof user !== "string" || typeof pass !== "string")
119
+ return null;
120
+ return user.trim() === "" || pass === "" ? null : { user: user.trim(), pass };
121
+ }
122
+ /** JSON clients (curl/API) get JSON; a browser form post gets a page back. */
123
+ function prefersJson(c) {
124
+ const type = (c.req.header("content-type") ?? "").toLowerCase();
125
+ const accept = (c.req.header("accept") ?? "").toLowerCase();
126
+ return type.includes("json") || accept.includes("application/json");
127
+ }
128
+ /** The client IP nginx reports, else the (local) caller is one bucket. */
129
+ function clientIp(c) {
130
+ const real = c.req.header("x-real-ip");
131
+ if (real !== undefined && real.trim() !== "")
132
+ return real.trim();
133
+ const forwarded = c.req.header("x-forwarded-for");
134
+ if (forwarded !== undefined) {
135
+ const first = forwarded.split(",")[0]?.trim();
136
+ if (first !== undefined && first !== "")
137
+ return first;
138
+ }
139
+ return "local";
140
+ }
141
+ function pageResponse(body, status, extra = {}) {
142
+ return new Response(body, {
143
+ status,
144
+ headers: { "content-type": HTML, "cache-control": NO_STORE, ...extra },
145
+ });
146
+ }
147
+ function jsonResponse(body, status, extra = {}) {
148
+ return new Response(JSON.stringify(body), {
149
+ status,
150
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": NO_STORE, ...extra },
151
+ });
152
+ }
153
+ function warn(message) {
154
+ process.stderr.write(`studio auth: ${message}\n`);
155
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared HTTP helpers for the Studio handlers.
3
+ *
4
+ * Error bodies follow the frozen convention: `{"ok":false,"error":"<verbatim
5
+ * source string>"}` (`contracts/endpoints.json` §conventions). Two handlers in
6
+ * the retired backend return only `{"error":…}` (POST /api/turn's empty-input case
7
+ * and the static/API 404); those keep their special shape and say so locally.
8
+ */
9
+ import type { Context } from "hono";
10
+ import { CapacityError } from "../runtime-adapter.js";
11
+ import type { StudioServices } from "../plugins.js";
12
+ import type { StoreResult } from "../store/result.js";
13
+ /** Handler dependencies: the composed studio services. */
14
+ export type Deps = StudioServices;
15
+ export type JsonObject = Record<string, unknown>;
16
+ /** `{ok:false,error}` with the contract status (plus optional extra fields). */
17
+ export declare function failJson(c: Context, status: number, error: string, extra?: JsonObject): Response;
18
+ /**
19
+ * W513 capacity response: 503 + `Retry-After` (the engine refused to create
20
+ * another live session or another concurrent turn, and waiting helps).
21
+ */
22
+ export declare function capacityJson(c: Context, error: CapacityError): Response;
23
+ /** Turn a store failure straight into its contract response. */
24
+ export declare function storeFail(c: Context, failure: Extract<StoreResult<never>, {
25
+ ok: false;
26
+ }>): Response;
27
+ /** `{error}`-only body (POST /api/turn empty input, static/API 404). */
28
+ export declare function errorOnly(c: Context, status: number, error: string): Response;
29
+ export type BodyRead = {
30
+ ok: true;
31
+ body: JsonObject;
32
+ } | {
33
+ ok: false;
34
+ response: Response;
35
+ };
36
+ /**
37
+ * Read a JSON object body. The retired backend's rejections are mirrored:
38
+ * missing body -> 415, unparsable -> 400, non-object -> 422.
39
+ */
40
+ export declare function readJsonBody(c: Context, required?: boolean): Promise<BodyRead>;
41
+ export type FieldRead<T> = {
42
+ ok: true;
43
+ value: T | undefined;
44
+ } | {
45
+ ok: false;
46
+ response: Response;
47
+ };
48
+ /** Optional string field: absent/null -> undefined, wrong type -> 422. */
49
+ export declare function strField(c: Context, body: JsonObject, name: string): FieldRead<string>;
50
+ /** Optional number field (JSON numbers only; no numeric strings). */
51
+ export declare function numField(c: Context, body: JsonObject, name: string): FieldRead<number>;
52
+ /** Optional string[] field. */
53
+ export declare function strArrayField(c: Context, body: JsonObject, name: string): FieldRead<string[]>;
54
+ /** Optional free-form JSON object field. */
55
+ export declare function objectField(c: Context, body: JsonObject, name: string): FieldRead<JsonObject>;
56
+ /** The active session id, or null (used by /api/status, /api/clear, prompts). */
57
+ export declare function activeSession(deps: Deps): string | null;
58
+ /**
59
+ * W729: the mode of one session (`session.json.mode`; absent/unknown = the
60
+ * default `standard`, so a session created before this feature reads exactly as
61
+ * it always behaved). An unresolvable id also reads as the default — the
62
+ * endpoint that asked owns the 404.
63
+ */
64
+ export declare function modeOfSession(deps: Deps, session: string | null): string;
65
+ /**
66
+ * W870: does ONE session carry its OWN `session.json.model` override?
67
+ *
68
+ * `/api/status.model` is the session instance's profile model (global base +
69
+ * this override, see `runtime/session-compose.ts` `profileFor`), so a client
70
+ * cannot tell "the global default happens to equal this session's model" from
71
+ * "this session is pinned" by looking at `model` alone. The statusline's model
72
+ * picker needs exactly that distinction to say 「本会话已固定模型」 instead of
73
+ * silently switching something the user cannot see. An unresolvable id (and the
74
+ * detached/absent session) answers false — the endpoint that asked owns the 404.
75
+ */
76
+ export declare function sessionModelCovered(deps: Deps, session: string | null): boolean;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared HTTP helpers for the Studio handlers.
3
+ *
4
+ * Error bodies follow the frozen convention: `{"ok":false,"error":"<verbatim
5
+ * source string>"}` (`contracts/endpoints.json` §conventions). Two handlers in
6
+ * the retired backend return only `{"error":…}` (POST /api/turn's empty-input case
7
+ * and the static/API 404); those keep their special shape and say so locally.
8
+ */
9
+ import { CapacityError } from "../runtime-adapter.js";
10
+ import { effectiveMode } from "../store/mode.js";
11
+ import { readSessionMeta } from "../store/session-meta.js";
12
+ /** `{ok:false,error}` with the contract status (plus optional extra fields). */
13
+ export function failJson(c, status, error, extra) {
14
+ return c.json(extra === undefined ? { ok: false, error } : { ok: false, error, ...extra }, status);
15
+ }
16
+ /**
17
+ * W513 capacity response: 503 + `Retry-After` (the engine refused to create
18
+ * another live session or another concurrent turn, and waiting helps).
19
+ */
20
+ export function capacityJson(c, error) {
21
+ const response = failJson(c, 503, error.message);
22
+ response.headers.set("retry-after", String(error.retryAfterSeconds));
23
+ return response;
24
+ }
25
+ /** Turn a store failure straight into its contract response. */
26
+ export function storeFail(c, failure) {
27
+ return failJson(c, failure.status, failure.error, failure.extra);
28
+ }
29
+ /** `{error}`-only body (POST /api/turn empty input, static/API 404). */
30
+ export function errorOnly(c, status, error) {
31
+ return c.json({ error }, status);
32
+ }
33
+ /**
34
+ * Read a JSON object body. The retired backend's rejections are mirrored:
35
+ * missing body -> 415, unparsable -> 400, non-object -> 422.
36
+ */
37
+ export async function readJsonBody(c, required = true) {
38
+ const raw = await c.req.text();
39
+ if (raw.trim() === "") {
40
+ if (required)
41
+ return { ok: false, response: failJson(c, 415, "request body required") };
42
+ return { ok: true, body: {} };
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ return { ok: false, response: failJson(c, 400, "invalid JSON body") };
50
+ }
51
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
52
+ return { ok: false, response: failJson(c, 422, "expected a JSON object body") };
53
+ }
54
+ return { ok: true, body: parsed };
55
+ }
56
+ /** Optional string field: absent/null -> undefined, wrong type -> 422. */
57
+ export function strField(c, body, name) {
58
+ const v = body[name];
59
+ if (v === undefined || v === null)
60
+ return { ok: true, value: undefined };
61
+ if (typeof v !== "string")
62
+ return { ok: false, response: failJson(c, 422, `field '${name}' must be a string`) };
63
+ return { ok: true, value: v };
64
+ }
65
+ /** Optional number field (JSON numbers only; no numeric strings). */
66
+ export function numField(c, body, name) {
67
+ const v = body[name];
68
+ if (v === undefined || v === null)
69
+ return { ok: true, value: undefined };
70
+ if (typeof v !== "number" || !Number.isFinite(v))
71
+ return { ok: false, response: failJson(c, 422, `field '${name}' must be a number`) };
72
+ return { ok: true, value: v };
73
+ }
74
+ /** Optional string[] field. */
75
+ export function strArrayField(c, body, name) {
76
+ const v = body[name];
77
+ if (v === undefined || v === null)
78
+ return { ok: true, value: undefined };
79
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
80
+ return { ok: false, response: failJson(c, 422, `field '${name}' must be an array of strings`) };
81
+ }
82
+ return { ok: true, value: v };
83
+ }
84
+ /** Optional free-form JSON object field. */
85
+ export function objectField(c, body, name) {
86
+ const v = body[name];
87
+ if (v === undefined || v === null)
88
+ return { ok: true, value: undefined };
89
+ if (typeof v !== "object" || Array.isArray(v))
90
+ return { ok: false, response: failJson(c, 422, `field '${name}' must be an object`) };
91
+ return { ok: true, value: v };
92
+ }
93
+ /** The active session id, or null (used by /api/status, /api/clear, prompts). */
94
+ export function activeSession(deps) {
95
+ return deps.workspaces.activeSession();
96
+ }
97
+ /**
98
+ * W729: the mode of one session (`session.json.mode`; absent/unknown = the
99
+ * default `standard`, so a session created before this feature reads exactly as
100
+ * it always behaved). An unresolvable id also reads as the default — the
101
+ * endpoint that asked owns the 404.
102
+ */
103
+ export function modeOfSession(deps, session) {
104
+ if (session === null || session === "")
105
+ return effectiveMode(null);
106
+ const resolved = deps.sessions.resolve(session);
107
+ return effectiveMode(resolved.ok ? readSessionMeta(resolved.value.dir)?.mode : null);
108
+ }
109
+ /**
110
+ * W870: does ONE session carry its OWN `session.json.model` override?
111
+ *
112
+ * `/api/status.model` is the session instance's profile model (global base +
113
+ * this override, see `runtime/session-compose.ts` `profileFor`), so a client
114
+ * cannot tell "the global default happens to equal this session's model" from
115
+ * "this session is pinned" by looking at `model` alone. The statusline's model
116
+ * picker needs exactly that distinction to say 「本会话已固定模型」 instead of
117
+ * silently switching something the user cannot see. An unresolvable id (and the
118
+ * detached/absent session) answers false — the endpoint that asked owns the 404.
119
+ */
120
+ export function sessionModelCovered(deps, session) {
121
+ if (session === null || session === "")
122
+ return false;
123
+ const resolved = deps.sessions.resolve(session);
124
+ if (!resolved.ok)
125
+ return false;
126
+ return (readSessionMeta(resolved.value.dir)?.model ?? "") !== "";
127
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `/api/config` response shape, assembled from live stores.
3
+ *
4
+ * `available.models` is rebuilt on EVERY read from the providers store
5
+ * (`src/api.rs:61-68`): one row per (provider, model) pair — de-dup is per
6
+ * provider since W750 — carrying the display name (`provider`), the stable id
7
+ * (`provider_id`), whether that exact pair is the composed one (`active`) and
8
+ * `reasoning` = the model row declares at least one effort. `system_prompt` is
9
+ * either the host override (POST /api/config) or the registry assembly
10
+ * (`build_gen`).
11
+ *
12
+ * S1 (W729): `system_prompt` is the FOCUSED (active) session's assembly. The
13
+ * engine's own per-session prompt comes from the same function with an explicit
14
+ * session id, injected into the composer by `app.ts` — one assembly path, two
15
+ * callers, so the UI and the engine can never disagree about a session's mode.
16
+ */
17
+ import { type SessionMode } from "../store/mode.js";
18
+ import type { PromptScope } from "../store/prompts.js";
19
+ import { type ResolvedSession } from "../store/sessions.js";
20
+ import type { Deps, JsonObject } from "./common.js";
21
+ export declare const EFFORTS: readonly string[];
22
+ export interface AvailableModel {
23
+ id: string;
24
+ name: string;
25
+ /** Provider DISPLAY name (grouping header); `provider_id` is the stable id. */
26
+ provider: string;
27
+ /** W750: the provider's stable id — what a switch must send back. */
28
+ provider_id: string;
29
+ /** W750: this exact (provider, model) pair is the one the engine routes to. */
30
+ active: boolean;
31
+ reasoning: boolean;
32
+ }
33
+ /** Resolve the active session's directory (null when nothing is active). */
34
+ export declare function activeResolved(deps: Deps): ResolvedSession | null;
35
+ /** Prompt scope of the active session's workspace (global when unknown). */
36
+ export declare function activeScope(deps: Deps): PromptScope;
37
+ /** Prompt scope of one session (global when the session cannot be resolved). */
38
+ export declare function scopeOf(deps: Deps, resolved: ResolvedSession | null): PromptScope;
39
+ /** The session's directory, or null when the id does not resolve here. */
40
+ export declare function resolveIfKnown(deps: Deps, sessionId: string | null): ResolvedSession | null;
41
+ /** `prompt` binding of the active session, if it has one. */
42
+ export declare function activePromptBinding(deps: Deps): string | null;
43
+ /**
44
+ * The model picker's catalogue, rebuilt from the live providers store.
45
+ *
46
+ * W750 (bug fix): de-duplication is PER PROVIDER, never global. The same model
47
+ * id under two providers is two different choices — the provider is what decides
48
+ * the endpoint the request goes to — so a global `seen` set silently swallowed
49
+ * every provider after the first one that shared an id (production: provider
50
+ * 「基元」 vanished because it also lists `deepseek-flash`). Identical ids
51
+ * repeated INSIDE one provider are still dropped (a store typo, not a choice).
52
+ *
53
+ * `active` marks the single (provider, model) pair the engine would use right
54
+ * now: same model id AND same endpoint as the composed profile. When nothing
55
+ * matches the endpoint — a custom base_url override, or a non
56
+ * `chat_completions` provider whose switch never rewrites the endpoint — the
57
+ * model id alone is enough ONLY if it is unambiguous; an ambiguous id is left
58
+ * unmarked rather than marked wrong.
59
+ */
60
+ export declare function availableModels(deps: Deps): AvailableModel[];
61
+ /**
62
+ * Registry-assembled (or overridden) system prompt.
63
+ *
64
+ * W729 (§5.1 #4/#5, S1/S2): with an explicit `sessionId` the WHOLE assembly is
65
+ * resolved against THAT session — its workspace scope, its bound prompt, its
66
+ * `session.json` model, its own tool face and its own mode. That is what makes
67
+ * "one mode per session" true for a BACKGROUND session and not only for the focused one
68
+ * (R3); `null` keeps the historical reading (the ACTIVE session's scope, the
69
+ * process model, the default generation's tools), which is what the startup
70
+ * priming and `GET /api/config` use.
71
+ */
72
+ export declare function assembleSystemPromptFor(deps: Deps, sessionId?: string | null, mode?: SessionMode, env?: NodeJS.ProcessEnv): string;
73
+ /** Effective base_url: host override wins over the engine profile. */
74
+ export declare function baseUrlOf(deps: Deps): string;
75
+ /** GET /api/config body (also the POST /api/config response). */
76
+ export declare function configView(deps: Deps): JsonObject;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The `/api/config` response shape, assembled from live stores.
3
+ *
4
+ * `available.models` is rebuilt on EVERY read from the providers store
5
+ * (`src/api.rs:61-68`): one row per (provider, model) pair — de-dup is per
6
+ * provider since W750 — carrying the display name (`provider`), the stable id
7
+ * (`provider_id`), whether that exact pair is the composed one (`active`) and
8
+ * `reasoning` = the model row declares at least one effort. `system_prompt` is
9
+ * either the host override (POST /api/config) or the registry assembly
10
+ * (`build_gen`).
11
+ *
12
+ * S1 (W729): `system_prompt` is the FOCUSED (active) session's assembly. The
13
+ * engine's own per-session prompt comes from the same function with an explicit
14
+ * session id, injected into the composer by `app.ts` — one assembly path, two
15
+ * callers, so the UI and the engine can never disagree about a session's mode.
16
+ */
17
+ import { deploymentFacts } from "../deployment.js";
18
+ import { DEFAULT_SESSION_MODE } from "../store/mode.js";
19
+ import { assembleSystemPrompt, resolveActivePrompt, toPromptVars } from "../store/prompts-compose.js";
20
+ import { readSessionMeta } from "../store/session-meta.js";
21
+ import { sessionWorkspaceOf } from "../store/sessions.js";
22
+ import { activeSession } from "./common.js";
23
+ export const EFFORTS = ["low", "high", "max"];
24
+ /** Resolve the active session's directory (null when nothing is active). */
25
+ export function activeResolved(deps) {
26
+ const id = activeSession(deps);
27
+ if (id === null)
28
+ return null;
29
+ const res = deps.sessions.resolve(id);
30
+ return res.ok ? res.value : null;
31
+ }
32
+ /** Prompt scope of the active session's workspace (global when unknown). */
33
+ export function activeScope(deps) {
34
+ return scopeOf(deps, activeResolved(deps));
35
+ }
36
+ /** Prompt scope of one session (global when the session cannot be resolved). */
37
+ export function scopeOf(deps, resolved) {
38
+ if (resolved === null)
39
+ return deps.prompts.scopeGlobal();
40
+ return deps.prompts.scopeWorkspace(resolved.workspace, resolved.wsPath);
41
+ }
42
+ /** The session's directory, or null when the id does not resolve here. */
43
+ export function resolveIfKnown(deps, sessionId) {
44
+ if (sessionId === null || sessionId === "")
45
+ return null;
46
+ const res = deps.sessions.resolve(sessionId);
47
+ return res.ok ? res.value : null;
48
+ }
49
+ /** `prompt` binding of the active session, if it has one. */
50
+ export function activePromptBinding(deps) {
51
+ const resolved = activeResolved(deps);
52
+ if (resolved === null)
53
+ return null;
54
+ return readSessionMeta(resolved.dir)?.prompt ?? null;
55
+ }
56
+ /**
57
+ * Provider display name that lists `model`, else "" (static rows).
58
+ *
59
+ * W750: the same model id can live under several providers, so the provider the
60
+ * engine actually routes to — the one whose `base_url` is the active one — wins;
61
+ * only when no provider matches the active endpoint does the first lister win
62
+ * (the historical reading, kept for custom endpoints).
63
+ */
64
+ function providerOf(deps, model) {
65
+ const rows = deps.providers.rows();
66
+ const listed = rows.filter((p) => p.models.some((m) => m.id === model));
67
+ if (listed.length === 0)
68
+ return "";
69
+ const activeBase = trimSlash(baseUrlOf(deps));
70
+ const exact = listed.find((p) => trimSlash(p.base_url) === activeBase);
71
+ return (exact ?? listed[0]).name;
72
+ }
73
+ /** Trailing-slash-insensitive compare (providers.json and the profile differ). */
74
+ function trimSlash(url) {
75
+ return url.replace(/\/+$/, "");
76
+ }
77
+ /**
78
+ * The model picker's catalogue, rebuilt from the live providers store.
79
+ *
80
+ * W750 (bug fix): de-duplication is PER PROVIDER, never global. The same model
81
+ * id under two providers is two different choices — the provider is what decides
82
+ * the endpoint the request goes to — so a global `seen` set silently swallowed
83
+ * every provider after the first one that shared an id (production: provider
84
+ * 「基元」 vanished because it also lists `deepseek-flash`). Identical ids
85
+ * repeated INSIDE one provider are still dropped (a store typo, not a choice).
86
+ *
87
+ * `active` marks the single (provider, model) pair the engine would use right
88
+ * now: same model id AND same endpoint as the composed profile. When nothing
89
+ * matches the endpoint — a custom base_url override, or a non
90
+ * `chat_completions` provider whose switch never rewrites the endpoint — the
91
+ * model id alone is enough ONLY if it is unambiguous; an ambiguous id is left
92
+ * unmarked rather than marked wrong.
93
+ */
94
+ export function availableModels(deps) {
95
+ const activeModel = deps.runtime.profile().model;
96
+ const activeBase = trimSlash(baseUrlOf(deps));
97
+ const out = [];
98
+ for (const p of deps.providers.rows()) {
99
+ const seen = new Set();
100
+ const base = trimSlash(p.base_url);
101
+ for (const m of p.models) {
102
+ if (seen.has(m.id))
103
+ continue;
104
+ seen.add(m.id);
105
+ out.push({
106
+ id: m.id,
107
+ name: m.name === "" ? m.id : m.name,
108
+ provider: p.name,
109
+ provider_id: p.id,
110
+ active: m.id === activeModel && base === activeBase,
111
+ reasoning: m.reasoning_efforts.length > 0,
112
+ });
113
+ }
114
+ }
115
+ if (!out.some((e) => e.active)) {
116
+ const same = out.filter((e) => e.id === activeModel);
117
+ if (same.length === 1)
118
+ same[0].active = true;
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * W782: the `{{studio_*}}` values, resolved from the live deployment. Kept as a
124
+ * named helper so the derivation lives in ONE place and a test can pin it.
125
+ */
126
+ function deploymentVars(deps, env) {
127
+ const facts = deploymentFacts(deps.config, env);
128
+ return {
129
+ studio_repo: facts.repo,
130
+ studio_frontend_dir: facts.frontendDir,
131
+ studio_static_root: facts.staticRoot,
132
+ studio_service: facts.service,
133
+ studio_bind: facts.bind,
134
+ studio_site: facts.publicSite,
135
+ };
136
+ }
137
+ /**
138
+ * Registry-assembled (or overridden) system prompt.
139
+ *
140
+ * W729 (§5.1 #4/#5, S1/S2): with an explicit `sessionId` the WHOLE assembly is
141
+ * resolved against THAT session — its workspace scope, its bound prompt, its
142
+ * `session.json` model, its own tool face and its own mode. That is what makes
143
+ * "one mode per session" true for a BACKGROUND session and not only for the focused one
144
+ * (R3); `null` keeps the historical reading (the ACTIVE session's scope, the
145
+ * process model, the default generation's tools), which is what the startup
146
+ * priming and `GET /api/config` use.
147
+ */
148
+ export function assembleSystemPromptFor(deps, sessionId = null, mode, env = process.env) {
149
+ const override = deps.settings.systemPromptOverride();
150
+ if (override !== null)
151
+ return override;
152
+ const profile = deps.runtime.profile();
153
+ const scoped = sessionId === null ? null : resolveIfKnown(deps, sessionId);
154
+ const resolved = sessionId === null ? activeResolved(deps) : scoped;
155
+ const meta = resolved === null ? null : readSessionMeta(resolved.dir);
156
+ // The session's own model wins for a scoped assembly; the process model stays
157
+ // the source for the legacy (null) reading, so a no-mode session's prompt is
158
+ // byte-for-byte what it was before W729 (K8).
159
+ // TS 6.0 (TS2871, new syntactic nullish check): the inner `?? null` was
160
+ // redundant anyway — `meta?.model` is `string | undefined` and the outer `??`
161
+ // already covers both nullish cases, so the result type and behaviour are
162
+ // unchanged while the `?? null ?? x` pattern the new check rejects is gone.
163
+ const model = (scoped === null ? null : meta?.model) ?? profile.model;
164
+ const scope = scopeOf(deps, resolved);
165
+ // W768: the prompt's workspace NAME and ROOT PATH come from the ONE projector
166
+ // the composer also uses for the sandbox cwd (`sessionWorkspaceOf`) — a prompt
167
+ // naming one workspace while the shell starts in another is impossible now.
168
+ const workspace = sessionWorkspaceOf(resolved);
169
+ const vars = toPromptVars({
170
+ model,
171
+ provider: providerOf(deps, model),
172
+ base_url: baseUrlOf(deps),
173
+ workspace: workspace?.name ?? "",
174
+ workspace_dir: workspace?.path ?? "",
175
+ session: resolved?.id ?? "",
176
+ // W791 (P1, S2/M9): the tool face follows the RESOLVED session — for the
177
+ // legacy (null) reading that is the FOCUSED session, for a scoped one the
178
+ // session itself. A no-mode session's face is the full registry, so the
179
+ // rendered value of every pre-P1 session is unchanged.
180
+ tools: toolsOf(deps, resolved?.id ?? null).map((t) => t.name).join(", "),
181
+ context_window: profile.context_window,
182
+ max_output_tokens: profile.max_output_tokens,
183
+ date: new Date().toISOString().slice(0, 10),
184
+ // W782: the deployment facts the `environment` section renders. Every one is
185
+ // derived here (this process's own checkout + the operator's config), so the
186
+ // template states where the RUNNING service actually is — not where some
187
+ // hand-edited string once said it was.
188
+ ...deploymentVars(deps, env),
189
+ });
190
+ const binding = scoped === null ? activePromptBinding(deps) : (meta?.prompt ?? null);
191
+ // `mode` (explicit) wins over the session's own: the BASE generation is primed
192
+ // with the DEFAULT mode on purpose, so one execution session can never leak
193
+ // its variant into the prompt every session without a mode inherits (R3).
194
+ return assembleSystemPrompt(deps.prompts, scope, resolveActivePrompt(deps.prompts, scope, binding), vars, mode ?? meta?.mode ?? DEFAULT_SESSION_MODE);
195
+ }
196
+ /**
197
+ * The tool face the `{{tools}}` variable renders: the session's own generation
198
+ * when it has one, else the default generation. W791 (P1): in `execution` mode
199
+ * the session's generation PROVIDES the folded face, so the rendered list is the
200
+ * same set `GET /api/tools?session=X` answers (M9) — one source, two readers.
201
+ */
202
+ function toolsOf(deps, sessionId) {
203
+ const sessionTools = deps.runtime.sessionTools;
204
+ if (sessionId === null || sessionTools === undefined)
205
+ return deps.runtime.tools();
206
+ return sessionTools.call(deps.runtime, sessionId);
207
+ }
208
+ /** Effective base_url: host override wins over the engine profile. */
209
+ export function baseUrlOf(deps) {
210
+ return deps.settings.baseUrlOverride() ?? deps.runtime.profile().base_url;
211
+ }
212
+ /** GET /api/config body (also the POST /api/config response). */
213
+ export function configView(deps) {
214
+ const profile = deps.runtime.profile();
215
+ return {
216
+ model: profile.model,
217
+ base_url: baseUrlOf(deps),
218
+ max_steps: profile.max_steps,
219
+ max_parallel_tool_calls: profile.max_parallel_tool_calls,
220
+ reasoning_effort: profile.reasoning_effort,
221
+ max_output_tokens: profile.max_output_tokens,
222
+ context_window: profile.context_window,
223
+ system_prompt: assembleSystemPromptFor(deps),
224
+ api_key_env: deps.config.apiKeyEnv,
225
+ available: { models: availableModels(deps), efforts: [...EFFORTS] },
226
+ };
227
+ }