@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.
- package/LICENSE +21 -0
- package/dist/app.d.ts +111 -0
- package/dist/app.js +219 -0
- package/dist/auth/api-token.d.ts +76 -0
- package/dist/auth/api-token.js +172 -0
- package/dist/auth/htpasswd.d.ts +22 -0
- package/dist/auth/htpasswd.js +61 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.js +14 -0
- package/dist/auth/page.d.ts +19 -0
- package/dist/auth/page.js +73 -0
- package/dist/auth/rate-limit.d.ts +27 -0
- package/dist/auth/rate-limit.js +47 -0
- package/dist/auth/token.d.ts +49 -0
- package/dist/auth/token.js +112 -0
- package/dist/config.d.ts +78 -0
- package/dist/config.js +72 -0
- package/dist/deployment.d.ts +92 -0
- package/dist/deployment.js +154 -0
- package/dist/fake-runtime-adapter.d.ts +37 -0
- package/dist/fake-runtime-adapter.js +276 -0
- package/dist/handlers/auth.d.ts +24 -0
- package/dist/handlers/auth.js +155 -0
- package/dist/handlers/common.d.ts +76 -0
- package/dist/handlers/common.js +127 -0
- package/dist/handlers/config-shape.d.ts +76 -0
- package/dist/handlers/config-shape.js +227 -0
- package/dist/handlers/config.d.ts +26 -0
- package/dist/handlers/config.js +161 -0
- package/dist/handlers/context-shape.d.ts +33 -0
- package/dist/handlers/context-shape.js +59 -0
- package/dist/handlers/dialog.d.ts +22 -0
- package/dist/handlers/dialog.js +292 -0
- package/dist/handlers/exec.d.ts +22 -0
- package/dist/handlers/exec.js +131 -0
- package/dist/handlers/fs-read.d.ts +63 -0
- package/dist/handlers/fs-read.js +159 -0
- package/dist/handlers/fs.d.ts +57 -0
- package/dist/handlers/fs.js +153 -0
- package/dist/handlers/grants-shape.d.ts +45 -0
- package/dist/handlers/grants-shape.js +130 -0
- package/dist/handlers/grants.d.ts +21 -0
- package/dist/handlers/grants.js +267 -0
- package/dist/handlers/health.d.ts +37 -0
- package/dist/handlers/health.js +139 -0
- package/dist/handlers/index.d.ts +40 -0
- package/dist/handlers/index.js +85 -0
- package/dist/handlers/permissions.d.ts +9 -0
- package/dist/handlers/permissions.js +183 -0
- package/dist/handlers/plugins.d.ts +18 -0
- package/dist/handlers/plugins.js +23 -0
- package/dist/handlers/prompts.d.ts +12 -0
- package/dist/handlers/prompts.js +120 -0
- package/dist/handlers/providers.d.ts +12 -0
- package/dist/handlers/providers.js +178 -0
- package/dist/handlers/questions.d.ts +23 -0
- package/dist/handlers/questions.js +115 -0
- package/dist/handlers/session-model.d.ts +38 -0
- package/dist/handlers/session-model.js +105 -0
- package/dist/handlers/session-move.d.ts +22 -0
- package/dist/handlers/session-move.js +188 -0
- package/dist/handlers/session-tools.d.ts +19 -0
- package/dist/handlers/session-tools.js +75 -0
- package/dist/handlers/sessions.d.ts +32 -0
- package/dist/handlers/sessions.js +242 -0
- package/dist/handlers/usage.d.ts +26 -0
- package/dist/handlers/usage.js +77 -0
- package/dist/handlers/worker.d.ts +12 -0
- package/dist/handlers/worker.js +82 -0
- package/dist/handlers/workspaces.d.ts +12 -0
- package/dist/handlers/workspaces.js +88 -0
- package/dist/harness.test-util.d.ts +101 -0
- package/dist/harness.test-util.js +176 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/main.d.ts +13 -0
- package/dist/main.js +35 -0
- package/dist/plugins.d.ts +105 -0
- package/dist/plugins.js +97 -0
- package/dist/question-registry.d.ts +124 -0
- package/dist/question-registry.js +212 -0
- package/dist/question-rows.d.ts +32 -0
- package/dist/question-rows.js +43 -0
- package/dist/replay/compare.d.ts +47 -0
- package/dist/replay/compare.js +89 -0
- package/dist/replay/e2e-replay.d.ts +46 -0
- package/dist/replay/e2e-replay.js +91 -0
- package/dist/replay/expect-compact.d.ts +31 -0
- package/dist/replay/expect-compact.js +92 -0
- package/dist/replay/fixtures.d.ts +52 -0
- package/dist/replay/fixtures.js +53 -0
- package/dist/replay/host.d.ts +32 -0
- package/dist/replay/host.js +62 -0
- package/dist/replay/index.d.ts +16 -0
- package/dist/replay/index.js +16 -0
- package/dist/replay/probes.d.ts +55 -0
- package/dist/replay/probes.js +225 -0
- package/dist/replay/report.d.ts +13 -0
- package/dist/replay/report.js +103 -0
- package/dist/replay/session-e2e.d.ts +44 -0
- package/dist/replay/session-e2e.js +91 -0
- package/dist/routes.d.ts +47 -0
- package/dist/routes.js +59 -0
- package/dist/runtime/attachments-llm.d.ts +13 -0
- package/dist/runtime/attachments-llm.js +37 -0
- package/dist/runtime/boot-recovery.d.ts +32 -0
- package/dist/runtime/boot-recovery.js +71 -0
- package/dist/runtime/context-snapshot.d.ts +59 -0
- package/dist/runtime/context-snapshot.js +113 -0
- package/dist/runtime/engine-grants.d.ts +121 -0
- package/dist/runtime/engine-grants.js +338 -0
- package/dist/runtime/engine-permissions.d.ts +29 -0
- package/dist/runtime/engine-permissions.js +92 -0
- package/dist/runtime/engine-plugins.d.ts +189 -0
- package/dist/runtime/engine-plugins.js +292 -0
- package/dist/runtime/engine-profile.d.ts +28 -0
- package/dist/runtime/engine-profile.js +88 -0
- package/dist/runtime/engine-session.d.ts +9 -0
- package/dist/runtime/engine-session.js +9 -0
- package/dist/runtime/fallback-contract.d.ts +32 -0
- package/dist/runtime/fallback-contract.js +10 -0
- package/dist/runtime/fallback-host.d.ts +134 -0
- package/dist/runtime/fallback-host.js +299 -0
- package/dist/runtime/host-autowake.d.ts +79 -0
- package/dist/runtime/host-autowake.js +86 -0
- package/dist/runtime/image-downgrade.d.ts +49 -0
- package/dist/runtime/image-downgrade.js +90 -0
- package/dist/runtime/inbox-message.d.ts +11 -0
- package/dist/runtime/inbox-message.js +20 -0
- package/dist/runtime/index.d.ts +17 -0
- package/dist/runtime/index.js +17 -0
- package/dist/runtime/ledger-view.d.ts +40 -0
- package/dist/runtime/ledger-view.js +56 -0
- package/dist/runtime/llm-assembly.d.ts +66 -0
- package/dist/runtime/llm-assembly.js +114 -0
- package/dist/runtime/offline-llm.d.ts +71 -0
- package/dist/runtime/offline-llm.js +132 -0
- package/dist/runtime/provider-target.d.ts +10 -0
- package/dist/runtime/provider-target.js +10 -0
- package/dist/runtime/question-host.d.ts +33 -0
- package/dist/runtime/question-host.js +41 -0
- package/dist/runtime/question-view.d.ts +48 -0
- package/dist/runtime/question-view.js +76 -0
- package/dist/runtime/real-runtime-adapter.d.ts +95 -0
- package/dist/runtime/real-runtime-adapter.js +553 -0
- package/dist/runtime/recovery-audit.d.ts +81 -0
- package/dist/runtime/recovery-audit.js +95 -0
- package/dist/runtime/recovery-view.d.ts +36 -0
- package/dist/runtime/recovery-view.js +63 -0
- package/dist/runtime/session-compose.d.ts +235 -0
- package/dist/runtime/session-compose.js +350 -0
- package/dist/runtime/session-grants.d.ts +36 -0
- package/dist/runtime/session-grants.js +69 -0
- package/dist/runtime/session-lifecycle.d.ts +40 -0
- package/dist/runtime/session-lifecycle.js +86 -0
- package/dist/runtime/session-publisher.d.ts +40 -0
- package/dist/runtime/session-publisher.js +48 -0
- package/dist/runtime/session-release.d.ts +53 -0
- package/dist/runtime/session-release.js +80 -0
- package/dist/runtime/test-util.d.ts +78 -0
- package/dist/runtime/test-util.js +162 -0
- package/dist/runtime/watchdog-view.d.ts +33 -0
- package/dist/runtime/watchdog-view.js +45 -0
- package/dist/runtime/worker-bridge.d.ts +76 -0
- package/dist/runtime/worker-bridge.js +156 -0
- package/dist/runtime/worker-live.d.ts +22 -0
- package/dist/runtime/worker-live.js +25 -0
- package/dist/runtime/worker-recovery.d.ts +35 -0
- package/dist/runtime/worker-recovery.js +64 -0
- package/dist/runtime/worker-table.d.ts +58 -0
- package/dist/runtime/worker-table.js +70 -0
- package/dist/runtime-adapter.d.ts +421 -0
- package/dist/runtime-adapter.js +76 -0
- package/dist/serial-queue.d.ts +16 -0
- package/dist/serial-queue.js +22 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +119 -0
- package/dist/settings.d.ts +23 -0
- package/dist/settings.js +31 -0
- package/dist/sse.d.ts +67 -0
- package/dist/sse.js +186 -0
- package/dist/static.d.ts +20 -0
- package/dist/static.js +120 -0
- package/dist/store/builtin-sections.d.ts +52 -0
- package/dist/store/builtin-sections.js +118 -0
- package/dist/store/celestea-home.d.ts +12 -0
- package/dist/store/celestea-home.js +11 -0
- package/dist/store/fs-json.d.ts +51 -0
- package/dist/store/fs-json.js +119 -0
- package/dist/store/grants-audit.d.ts +84 -0
- package/dist/store/grants-audit.js +109 -0
- package/dist/store/grants-service.d.ts +44 -0
- package/dist/store/grants-service.js +41 -0
- package/dist/store/grants-tokens.d.ts +62 -0
- package/dist/store/grants-tokens.js +105 -0
- package/dist/store/grants.d.ts +113 -0
- package/dist/store/grants.js +238 -0
- package/dist/store/index.d.ts +36 -0
- package/dist/store/index.js +36 -0
- package/dist/store/mode.d.ts +35 -0
- package/dist/store/mode.js +41 -0
- package/dist/store/permissions.d.ts +61 -0
- package/dist/store/permissions.js +139 -0
- package/dist/store/prompts-compose.d.ts +56 -0
- package/dist/store/prompts-compose.js +62 -0
- package/dist/store/prompts-template.d.ts +26 -0
- package/dist/store/prompts-template.js +99 -0
- package/dist/store/prompts.d.ts +142 -0
- package/dist/store/prompts.js +273 -0
- package/dist/store/provider-probe.d.ts +81 -0
- package/dist/store/provider-probe.js +154 -0
- package/dist/store/providers.d.ts +97 -0
- package/dist/store/providers.js +219 -0
- package/dist/store/result.d.ts +29 -0
- package/dist/store/result.js +30 -0
- package/dist/store/session-id.d.ts +119 -0
- package/dist/store/session-id.js +220 -0
- package/dist/store/session-meta.d.ts +41 -0
- package/dist/store/session-meta.js +66 -0
- package/dist/store/session-ops.d.ts +109 -0
- package/dist/store/session-ops.js +264 -0
- package/dist/store/session-tools.d.ts +42 -0
- package/dist/store/session-tools.js +69 -0
- package/dist/store/sessions.d.ts +162 -0
- package/dist/store/sessions.js +307 -0
- package/dist/store/validate.d.ts +13 -0
- package/dist/store/validate.js +38 -0
- package/dist/store/workspaces.d.ts +67 -0
- package/dist/store/workspaces.js +228 -0
- package/dist/user-questions.d.ts +58 -0
- package/dist/user-questions.js +157 -0
- package/dist/version.d.ts +14 -0
- package/dist/version.js +52 -0
- package/package.json +38 -0
- package/webdist/assets/index-Bngs7gUm.js +98 -0
- package/webdist/assets/index-Bu6ci_rN.css +1 -0
- package/webdist/assets/katex-CCbS2qAY.js +257 -0
- package/webdist/assets/mhchem-CnUN8HwY.js +1 -0
- package/webdist/build-meta.json +7 -0
- package/webdist/index.html +261 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mcd0LUO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono application factory — P4.
|
|
3
|
+
*
|
|
4
|
+
* Wiring order is contract order:
|
|
5
|
+
* 1. compose the studio context (store plugins + bus + injected runtime);
|
|
6
|
+
* 2. register all 39 contract endpoints and assert full coverage;
|
|
7
|
+
* 3. `/api/*` fallback = 404 JSON (an unknown API path must NEVER fall
|
|
8
|
+
* through to the static/SPA handler);
|
|
9
|
+
* 4. static files + SPA fallback from the read-only Vite build.
|
|
10
|
+
*
|
|
11
|
+
* The engine is injected: `opts.runtime` is a `RuntimeAdapter` (or a factory
|
|
12
|
+
* over the composed stores, which is what the REAL adapter needs to resolve
|
|
13
|
+
* session directories AND the provider target). With nothing injected the app
|
|
14
|
+
* mounts the real runtime (`runtime/`) wired to the real LLM, so the default
|
|
15
|
+
* deployment is the engine over a live provider, not a fake; the P4 fake stays
|
|
16
|
+
* available to tests through `harness.test-util.ts`.
|
|
17
|
+
*
|
|
18
|
+
* W743 (W732 A1): the engine assembly itself lives in `createStudioEngine()`
|
|
19
|
+
* below — the ONE factory — and `defaultRuntime()` is just the production
|
|
20
|
+
* binding of its injected values. The real-engine test harness
|
|
21
|
+
* (`runtime/test-util.ts`) calls the same function, so "the tests run the real
|
|
22
|
+
* engine" now also means "the tests run the real grants boundary and the real
|
|
23
|
+
* usage ledger".
|
|
24
|
+
*/
|
|
25
|
+
import { Hono } from "hono";
|
|
26
|
+
import type { Llm } from "@celestea/core";
|
|
27
|
+
import { type Profile } from "@celestea/runtime";
|
|
28
|
+
import { type RegisteredRoute } from "./routes.js";
|
|
29
|
+
import { type StudioConfig } from "./config.js";
|
|
30
|
+
import { type EngineFactory, type StudioServices } from "./plugins.js";
|
|
31
|
+
import type { EngineProfile, RuntimeAdapter } from "./runtime-adapter.js";
|
|
32
|
+
import type { StoreServices } from "./plugins.js";
|
|
33
|
+
import type { DisclosureOptions } from "./runtime/engine-plugins.js";
|
|
34
|
+
export interface StudioAppOptions {
|
|
35
|
+
cwd?: string;
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
config?: StudioConfig;
|
|
38
|
+
/** Engine seam (or a factory over the stores); defaults to the REAL runtime. */
|
|
39
|
+
runtime?: RuntimeAdapter | EngineFactory;
|
|
40
|
+
/** Deterministic clock for session dir suffixes / trash stamps. */
|
|
41
|
+
now?: () => number;
|
|
42
|
+
}
|
|
43
|
+
export interface StudioApp {
|
|
44
|
+
app: Hono;
|
|
45
|
+
routes: RegisteredRoute[];
|
|
46
|
+
services: StudioServices;
|
|
47
|
+
/** Contract ids bound by handlers (39 on success). */
|
|
48
|
+
endpointIds: string[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Everything ONE engine build needs, resolved from the composed stores.
|
|
52
|
+
*
|
|
53
|
+
* W743 (closes W732 A1): this is the SINGLE engine assembly. The production app
|
|
54
|
+
* and the real-engine test harness both call it, so the two can no longer drift
|
|
55
|
+
* apart — the only thing a caller injects is PATHS, ENV and the LLM/profile
|
|
56
|
+
* seam; grants, the usage ledger, the worker receipt dir, session resolution and
|
|
57
|
+
* the three per-session profile hooks are assembled here exactly once.
|
|
58
|
+
*
|
|
59
|
+
* Note on placement: the factory cannot live in `packages/runtime` because it
|
|
60
|
+
* builds the HOST's adapter (`real-runtime-adapter.ts`, an L3 module) and needs
|
|
61
|
+
* the composed stores — `packages/*` must never depend on `apps/*`
|
|
62
|
+
* (ARCHITECTURE.md §1, K1/K2). It therefore stays in the L3 composition root and
|
|
63
|
+
* every other assembly (tests included) reuses THIS function.
|
|
64
|
+
*
|
|
65
|
+
* The dependency is a FUNCTION of `stores` because the engine factory runs
|
|
66
|
+
* INSIDE `composeStudio`: the test harness only learns its throwaway data dir
|
|
67
|
+
* once the workspace it registered is mounted.
|
|
68
|
+
*/
|
|
69
|
+
export interface StudioEngineInput {
|
|
70
|
+
/** The data file the host ACTUALLY composed (`<data dir>/workspaces.json`). */
|
|
71
|
+
workspacesFile: string;
|
|
72
|
+
/** Process environment (provider keys, tool roots, resource caps, grants). */
|
|
73
|
+
env: NodeJS.ProcessEnv;
|
|
74
|
+
/** Startup engine profile. */
|
|
75
|
+
profile: EngineProfile;
|
|
76
|
+
/** Provider row the profile came from, recorded as the ledger's `provider`. */
|
|
77
|
+
providerLabel: string | null;
|
|
78
|
+
/** Late-bound host services (per-session prompt assembly; see [HostRef]). */
|
|
79
|
+
host: HostRef;
|
|
80
|
+
/**
|
|
81
|
+
* W806 (P0): explicit dynamic-disclosure activation for the composed
|
|
82
|
+
* sessions. Absent = the static mode baseline (the default face).
|
|
83
|
+
*/
|
|
84
|
+
disclosure?: DisclosureOptions;
|
|
85
|
+
/**
|
|
86
|
+
* LLM seam override. Absent = the live provider assembled from the profile
|
|
87
|
+
* (production); the real-engine tests inject the deterministic OFFLINE engine.
|
|
88
|
+
*/
|
|
89
|
+
llm?: (profile: Profile) => Llm;
|
|
90
|
+
}
|
|
91
|
+
/** Resolves the injected values of one engine build from the composed stores. */
|
|
92
|
+
export type StudioEngineDeps = (stores: StoreServices) => StudioEngineInput;
|
|
93
|
+
/**
|
|
94
|
+
* The ONE engine factory. The factory form is what makes the production path
|
|
95
|
+
* possible — providers.json is composed before the engine, so the startup
|
|
96
|
+
* profile is resolved from the operator's provider registry — and it is also
|
|
97
|
+
* what the test harness reuses. Worker receipts land under
|
|
98
|
+
* `<data dir>/worker-results`.
|
|
99
|
+
*/
|
|
100
|
+
export declare function createStudioEngine(deps: StudioEngineDeps): EngineFactory;
|
|
101
|
+
/**
|
|
102
|
+
* W729: the host services exist only AFTER `composeStudio` ran, but the engine
|
|
103
|
+
* factory runs INSIDE it (the adapter is built while the stores are mounted).
|
|
104
|
+
* The per-session prompt hook is therefore late-bound through this ref; it is
|
|
105
|
+
* only ever called while composing a NAMED session, which happens on the first
|
|
106
|
+
* turn / activate — long after startup filled the ref in.
|
|
107
|
+
*/
|
|
108
|
+
export interface HostRef {
|
|
109
|
+
services: StudioServices | null;
|
|
110
|
+
}
|
|
111
|
+
export declare function createStudioApp(opts?: StudioAppOptions): StudioApp;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono application factory — P4.
|
|
3
|
+
*
|
|
4
|
+
* Wiring order is contract order:
|
|
5
|
+
* 1. compose the studio context (store plugins + bus + injected runtime);
|
|
6
|
+
* 2. register all 39 contract endpoints and assert full coverage;
|
|
7
|
+
* 3. `/api/*` fallback = 404 JSON (an unknown API path must NEVER fall
|
|
8
|
+
* through to the static/SPA handler);
|
|
9
|
+
* 4. static files + SPA fallback from the read-only Vite build.
|
|
10
|
+
*
|
|
11
|
+
* The engine is injected: `opts.runtime` is a `RuntimeAdapter` (or a factory
|
|
12
|
+
* over the composed stores, which is what the REAL adapter needs to resolve
|
|
13
|
+
* session directories AND the provider target). With nothing injected the app
|
|
14
|
+
* mounts the real runtime (`runtime/`) wired to the real LLM, so the default
|
|
15
|
+
* deployment is the engine over a live provider, not a fake; the P4 fake stays
|
|
16
|
+
* available to tests through `harness.test-util.ts`.
|
|
17
|
+
*
|
|
18
|
+
* W743 (W732 A1): the engine assembly itself lives in `createStudioEngine()`
|
|
19
|
+
* below — the ONE factory — and `defaultRuntime()` is just the production
|
|
20
|
+
* binding of its injected values. The real-engine test harness
|
|
21
|
+
* (`runtime/test-util.ts`) calls the same function, so "the tests run the real
|
|
22
|
+
* engine" now also means "the tests run the real grants boundary and the real
|
|
23
|
+
* usage ledger".
|
|
24
|
+
*/
|
|
25
|
+
import { Hono } from "hono";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { createUsageLedgerFile } from "@celestea/runtime";
|
|
28
|
+
import { API_ENDPOINT_COUNT, routeTable } from "./routes.js";
|
|
29
|
+
import { loadStudioConfig } from "./config.js";
|
|
30
|
+
import { apiTokenMiddleware, registerTokenBootstrap } from "./auth/api-token.js";
|
|
31
|
+
import { composeStudio } from "./plugins.js";
|
|
32
|
+
import { registerHandlers } from "./handlers/index.js";
|
|
33
|
+
import { assembleSystemPromptFor } from "./handlers/config-shape.js";
|
|
34
|
+
import { registerStatic } from "./static.js";
|
|
35
|
+
import { DEFAULT_SESSION_MODE } from "./store/mode.js";
|
|
36
|
+
import { sessionWorkspaceOf } from "./store/sessions.js";
|
|
37
|
+
import { readSessionMeta } from "./store/session-meta.js";
|
|
38
|
+
import { createSessionGrants } from "./runtime/session-grants.js";
|
|
39
|
+
import { grantsEnv } from "./store/grants-service.js";
|
|
40
|
+
import { createRealRuntimeAdapter, startupEngineProfile } from "./runtime/index.js";
|
|
41
|
+
import { recoverActiveSessionOnBoot } from "./runtime/boot-recovery.js";
|
|
42
|
+
import { RecoveryAuditWriter } from "./runtime/recovery-audit.js";
|
|
43
|
+
import { observeWorkerTableOnBoot } from "./runtime/worker-recovery.js";
|
|
44
|
+
import { workerTablePath } from "./runtime/worker-table.js";
|
|
45
|
+
/**
|
|
46
|
+
* The ONE engine factory. The factory form is what makes the production path
|
|
47
|
+
* possible — providers.json is composed before the engine, so the startup
|
|
48
|
+
* profile is resolved from the operator's provider registry — and it is also
|
|
49
|
+
* what the test harness reuses. Worker receipts land under
|
|
50
|
+
* `<data dir>/worker-results`.
|
|
51
|
+
*/
|
|
52
|
+
export function createStudioEngine(deps) {
|
|
53
|
+
return (stores) => {
|
|
54
|
+
const input = deps(stores);
|
|
55
|
+
const dataDir = dirname(input.workspacesFile);
|
|
56
|
+
const resultsDir = join(dataDir, "worker-results");
|
|
57
|
+
return createRealRuntimeAdapter({
|
|
58
|
+
// E §2.3 P0 ①: the studio's OWN table — never the DSH fleet's (R2-1/B6).
|
|
59
|
+
workerRegistryPath: workerTablePath({ env: input.env, dataDir }),
|
|
60
|
+
// W787 (§5.2③): the audit channel of a degraded session log. The BOOT
|
|
61
|
+
// observer builds its own writer on the same append-only file (the two
|
|
62
|
+
// lines are written at different moments and never interleave a record).
|
|
63
|
+
recoveryAudit: new RecoveryAuditWriter({ dataDir, env: input.env }),
|
|
64
|
+
profile: input.profile,
|
|
65
|
+
env: input.env,
|
|
66
|
+
resultsDir,
|
|
67
|
+
// W516: every instance reads its session's grants at compose time. The env
|
|
68
|
+
// is pinned to the workspaces file the host ACTUALLY composed, so the
|
|
69
|
+
// fail-closed root rules resolve the same data dir (grants-service.ts).
|
|
70
|
+
grants: createSessionGrants({ dataDir, env: grantsEnv(input.env, input.workspacesFile) }),
|
|
71
|
+
// W728 §3 P0: ONE append-only usage ledger per process (`<data dir>`),
|
|
72
|
+
// shared by every session instance; `CELESTEA_USAGE_LEDGER=off` disables.
|
|
73
|
+
ledgerFile: createUsageLedgerFile({ dataDir, env: input.env }),
|
|
74
|
+
// E §4 P1 (W785): `fallbacks.json` + `fallbacks-audit.jsonl` live next to
|
|
75
|
+
// the ledger, so the fallback wiring needs the same data dir.
|
|
76
|
+
dataDir,
|
|
77
|
+
providerLabel: input.providerLabel,
|
|
78
|
+
...(input.disclosure === undefined ? {} : { disclosure: input.disclosure }),
|
|
79
|
+
...(input.llm === undefined ? {} : { llm: input.llm }),
|
|
80
|
+
resolveSession: (id) => {
|
|
81
|
+
const resolved = stores.sessions.resolve(id);
|
|
82
|
+
// W768: the workspace travels with the session target, so the tools'
|
|
83
|
+
// cwd/root and the prompt's `{{workspace}}` come from THIS resolution.
|
|
84
|
+
return resolved.ok
|
|
85
|
+
? { sessionId: id, dir: resolved.value.dir, workspace: sessionWorkspaceOf(resolved.value) }
|
|
86
|
+
: null;
|
|
87
|
+
},
|
|
88
|
+
// W513: the session-level model override is applied to that session's own
|
|
89
|
+
// instance (it no longer rewrites a global engine profile).
|
|
90
|
+
sessionModel: (id) => sessionMetaAt(stores, id)?.model ?? null,
|
|
91
|
+
// W729 (§5.1 #4, R3): the session's mode is fixed at creation and the
|
|
92
|
+
// PROMPT assembly is therefore per instance, not per process. Both hooks
|
|
93
|
+
// read `session.json` of the session being composed, so a standard and an
|
|
94
|
+
// execution session in the same process get their own system prompt.
|
|
95
|
+
sessionMode: (id) => sessionMetaAt(stores, id)?.mode ?? null,
|
|
96
|
+
sessionSystemPrompt: (id) => sessionPromptAt(input.host, id),
|
|
97
|
+
// W804: the OPTIMISTIC per-model modality gate. null = the model is not
|
|
98
|
+
// configured anywhere => allow image input (the section 7.6 downgrade is
|
|
99
|
+
// the fallback for a wrong guess); a configured row without "image" is the
|
|
100
|
+
// only thing that disables the read_image gate.
|
|
101
|
+
modelInputModalities: (modelId) => {
|
|
102
|
+
for (const provider of stores.providers.rows()) {
|
|
103
|
+
const model = provider.models.find((m) => m.id === modelId);
|
|
104
|
+
if (model !== undefined)
|
|
105
|
+
return model.input_modalities ?? null;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** The production engine: [createStudioEngine] over the REAL provider registry. */
|
|
113
|
+
function defaultRuntime(config, env, host) {
|
|
114
|
+
return createStudioEngine((stores) => {
|
|
115
|
+
const startup = startupEngineProfile(stores.providers, env, config.apiKeyEnv);
|
|
116
|
+
return {
|
|
117
|
+
workspacesFile: config.paths.workspacesFile,
|
|
118
|
+
env,
|
|
119
|
+
profile: startup.profile,
|
|
120
|
+
providerLabel: startup.target.provider_id,
|
|
121
|
+
host,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** `session.json` of one session (null when the id does not resolve). */
|
|
126
|
+
function sessionMetaAt(stores, id) {
|
|
127
|
+
const resolved = stores.sessions.resolve(id);
|
|
128
|
+
return resolved.ok ? readSessionMeta(resolved.value.dir) : null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* W729/K8: every named session's prompt is assembled for THAT session. A session
|
|
132
|
+
* without `session.json.mode` gets the DEFAULT (standard) mode's text — the
|
|
133
|
+
* pre-W729 behaviour for the mode dimension — and its **own** identity variables.
|
|
134
|
+
*
|
|
135
|
+
* W768 (amends K8's "keeps the primed base prompt"): the primed base prompt was
|
|
136
|
+
* assembled ONCE at startup against whichever session was active then, so every
|
|
137
|
+
* other session received a prompt naming a workspace it is not in. With the
|
|
138
|
+
* tools now running in the session's own workspace, inheriting another session's
|
|
139
|
+
* prompt is precisely the "prompt says A, `pwd` says B" bug — so the assembly is
|
|
140
|
+
* per session for ALL sessions, and the mode gate no longer decides it.
|
|
141
|
+
*/
|
|
142
|
+
function sessionPromptAt(host, id) {
|
|
143
|
+
if (host.services === null)
|
|
144
|
+
return null;
|
|
145
|
+
return assembleSystemPromptFor(host.services, id);
|
|
146
|
+
}
|
|
147
|
+
/** Every contract endpoint must be bound exactly once, or startup fails. */
|
|
148
|
+
function assertCoverage(routes, ids) {
|
|
149
|
+
const bound = new Set(ids);
|
|
150
|
+
const missing = routes.filter((r) => !bound.has(r.id)).map((r) => r.id);
|
|
151
|
+
if (missing.length > 0)
|
|
152
|
+
throw new Error(`unbound contract endpoints: ${missing.join(", ")}`);
|
|
153
|
+
if (ids.length !== API_ENDPOINT_COUNT) {
|
|
154
|
+
throw new Error(`expected ${API_ENDPOINT_COUNT} contract endpoints, got ${ids.length}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Hand the engine the BASE system prompt the HOST assembles (prompt registry +
|
|
159
|
+
* settings override). The retired backend did this inside `build_gen`; here the engine is
|
|
160
|
+
* primed once at startup and the next composed generation picks it up.
|
|
161
|
+
*
|
|
162
|
+
* W729: this primes the DEFAULT (detached) generation only. A named session's
|
|
163
|
+
* prompt is assembled per instance through the composer's `sessionSystemPrompt`
|
|
164
|
+
* hook, so priming can never leak one session's mode into another's instance.
|
|
165
|
+
*/
|
|
166
|
+
function primeEnginePrompt(services, env) {
|
|
167
|
+
// The BASE (detached) generation is always the DEFAULT mode, whatever mode the
|
|
168
|
+
// session left active in workspaces.json happens to declare.
|
|
169
|
+
//
|
|
170
|
+
// W782: `env` is threaded through so the primed prompt states the unit name
|
|
171
|
+
// this process's own environment declares, not a compile-time guess.
|
|
172
|
+
services.runtime.primeSystemPrompt?.(assembleSystemPromptFor(services, null, DEFAULT_SESSION_MODE, env));
|
|
173
|
+
}
|
|
174
|
+
export function createStudioApp(opts = {}) {
|
|
175
|
+
const env = opts.env ?? process.env;
|
|
176
|
+
const config = opts.config ?? loadStudioConfig({ cwd: opts.cwd, env });
|
|
177
|
+
// Filled in right after composition; the engine reads it lazily (see HostRef).
|
|
178
|
+
const host = { services: null };
|
|
179
|
+
const runtime = opts.runtime ?? defaultRuntime(config, env, host);
|
|
180
|
+
const services = composeStudio({ config, runtime, env, now: opts.now });
|
|
181
|
+
host.services = services;
|
|
182
|
+
const table = routeTable();
|
|
183
|
+
const app = new Hono();
|
|
184
|
+
// H-security: a configured token gates every /api/* request except
|
|
185
|
+
// /api/health. No token = the historical nginx-delegated path, unchanged
|
|
186
|
+
// (the listener is loopback; a non-loopback bind is refused in server.ts).
|
|
187
|
+
if (config.authToken !== null) {
|
|
188
|
+
app.use("/api/*", apiTokenMiddleware(config.authToken));
|
|
189
|
+
// The browser never sends an Authorization header; this one-shot bootstrap
|
|
190
|
+
// sets the HttpOnly cookie the middleware also accepts. Registered BEFORE
|
|
191
|
+
// registerStatic's catch-all.
|
|
192
|
+
registerTokenBootstrap(app, config.authToken);
|
|
193
|
+
}
|
|
194
|
+
primeEnginePrompt(services, env);
|
|
195
|
+
// W787 (§5.2③): the ONE boot audit channel — the checkpoint repair and the
|
|
196
|
+
// worker-table observation both report into `<data dir>/recovery-audit.jsonl`.
|
|
197
|
+
const bootAudit = new RecoveryAuditWriter({ dataDir: dirname(config.paths.workspacesFile), env });
|
|
198
|
+
// E §1.3 P0 ③: close the turn the previous process died inside — BEFORE any
|
|
199
|
+
// instance of the active session is composed, because composing one replays the
|
|
200
|
+
// log and takes its turn counter from it. A clean log, a missing checkpoint or
|
|
201
|
+
// an unresolvable active session are all no-ops (fail-safe).
|
|
202
|
+
recoverActiveSessionOnBoot({ workspaces: services.workspaces, sessions: services.sessions, audit: bootAudit });
|
|
203
|
+
// E §2.3 P0 ③: OBSERVE the persisted worker table (dead owner / missing host
|
|
204
|
+
// session) and record it. Never re-dispatch: that is P2, behind an explicit
|
|
205
|
+
// switch, and it needs the tool side-effect table first (§5.1).
|
|
206
|
+
const dataDir = dirname(config.paths.workspacesFile);
|
|
207
|
+
observeWorkerTableOnBoot({
|
|
208
|
+
path: workerTablePath({ env, dataDir }),
|
|
209
|
+
knownHost: (sid) => services.sessions.resolve(sid).ok,
|
|
210
|
+
resultsDir: join(dataDir, "worker-results"),
|
|
211
|
+
audit: bootAudit,
|
|
212
|
+
});
|
|
213
|
+
const endpointIds = registerHandlers(app, services, table);
|
|
214
|
+
assertCoverage(table.routes, endpointIds);
|
|
215
|
+
// Unknown API paths are 404 JSON, never the SPA (frozen static contract).
|
|
216
|
+
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
|
217
|
+
registerStatic(app, config.paths.staticRoot);
|
|
218
|
+
return { app, routes: table.routes, services, endpointIds };
|
|
219
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H-security — the minimal self-contained API token (fail-closed).
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `handlers/auth.ts` delegates the browser gate to nginx
|
|
5
|
+
* (`auth_request` -> `/auth/check`), and the backend is deliberately reachable
|
|
6
|
+
* from localhost with no cookie. That is safe ONLY while the listener is on the
|
|
7
|
+
* loopback. The CLI's `--bind` can move it to `0.0.0.0`, where the SAME backend
|
|
8
|
+
* exposes `POST /api/exec` (arbitrary shell as this user), `GET /api/fs/list`
|
|
9
|
+
* (any directory) and every agent endpoint to anyone who can reach the port.
|
|
10
|
+
*
|
|
11
|
+
* Rules, all "never silently allow":
|
|
12
|
+
* 1. a NON-loopback bind is REFUSED unless a token is configured;
|
|
13
|
+
* 2. when a token IS configured, every `/api/*` request except `/api/health`
|
|
14
|
+
* must present it — as `Authorization: Bearer`, `x-celestea-token`, or the
|
|
15
|
+
* cookie `GET /auth/token?token=…` sets — else 401.
|
|
16
|
+
*
|
|
17
|
+
* The browser problem (why the cookie exists): a browser never sends an
|
|
18
|
+
* `Authorization` header, so a token-only gate leaves the UI unusable (every
|
|
19
|
+
* fetch 401). `/auth/token` is the standard one-shot bootstrap: a correct token
|
|
20
|
+
* sets an HttpOnly cookie and 302s to `/`; the middleware then accepts the
|
|
21
|
+
* cookie. The cookie value is an HMAC of the token, never the plaintext token,
|
|
22
|
+
* so the raw secret is not stored client-side.
|
|
23
|
+
*
|
|
24
|
+
* The loopback path is untouched (no token -> no middleware, no bootstrap), so
|
|
25
|
+
* the existing nginx deployment and every existing test keep working.
|
|
26
|
+
*/
|
|
27
|
+
import type { Hono, MiddlewareHandler } from "hono";
|
|
28
|
+
/** Env var carrying the token (the `--token` flag overrides it). */
|
|
29
|
+
export declare const ENV_AUTH_TOKEN = "CELESTEA_AUTH_TOKEN";
|
|
30
|
+
/** The token header (in addition to `Authorization: Bearer <token>`). */
|
|
31
|
+
export declare const AUTH_TOKEN_HEADER = "x-celestea-token";
|
|
32
|
+
/** The one API path that stays public (liveness probes, the UI's boot check). */
|
|
33
|
+
export declare const AUTH_EXEMPT_PATH = "/api/health";
|
|
34
|
+
/** The browser bootstrap path (NOT under /api, so the token gate never blocks it). */
|
|
35
|
+
export declare const AUTH_TOKEN_PATH = "/auth/token";
|
|
36
|
+
/** The HttpOnly cookie the bootstrap sets; distinct from the login cookie. */
|
|
37
|
+
export declare const AUTH_TOKEN_COOKIE = "celestea_api_token";
|
|
38
|
+
/** 30 days, the same lifetime the login cookie uses. */
|
|
39
|
+
export declare const AUTH_TOKEN_COOKIE_MAX_AGE: number;
|
|
40
|
+
/** true only for an address that cannot be reached from another host. */
|
|
41
|
+
export declare function isLoopbackBind(bind: string): boolean;
|
|
42
|
+
/** The configured token, or null when unset/blank. */
|
|
43
|
+
export declare function readAuthToken(env?: NodeJS.ProcessEnv): string | null;
|
|
44
|
+
/** The token a request presents, from either accepted header (or null). */
|
|
45
|
+
export declare function presentedToken(authHeader: string | undefined, tokenHeader: string | undefined): string | null;
|
|
46
|
+
/** Constant-time string comparison (a length mismatch is a plain false). */
|
|
47
|
+
export declare function tokensMatch(presented: string, expected: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The cookie value for a token. An HMAC keyed by the token, so the cookie the
|
|
50
|
+
* browser stores is NOT the raw secret (a leaked cookie does not reveal the
|
|
51
|
+
* token, and the token never appears in a page or a cookie jar verbatim).
|
|
52
|
+
*/
|
|
53
|
+
export declare function tokenCookieValue(token: string): string;
|
|
54
|
+
/** true when the presented cookie value is this token's cookie value. */
|
|
55
|
+
export declare function cookieMatches(presented: string | null | undefined, token: string): boolean;
|
|
56
|
+
/** The refusal when a non-loopback bind has no token. */
|
|
57
|
+
export declare class InsecureBindError extends Error {
|
|
58
|
+
readonly name = "InsecureBindError";
|
|
59
|
+
constructor(bind: string);
|
|
60
|
+
}
|
|
61
|
+
/** Refuse a non-loopback bind that has no token (throws [InsecureBindError]). */
|
|
62
|
+
export declare function assertBindIsSafe(bind: string, token: string | null): void;
|
|
63
|
+
/**
|
|
64
|
+
* The token middleware: every `/api/*` request except `/api/health` must
|
|
65
|
+
* present the token as a header OR the bootstrap cookie, else 401. `/auth/*`
|
|
66
|
+
* and the static UI are not under `/api/*` and stay reachable.
|
|
67
|
+
*/
|
|
68
|
+
export declare function apiTokenMiddleware(token: string): MiddlewareHandler;
|
|
69
|
+
/** The `Set-Cookie` value: HttpOnly + SameSite=Strict + Path=/ (+Secure over TLS). */
|
|
70
|
+
export declare function apiTokenCookie(token: string, secure: boolean): string;
|
|
71
|
+
/**
|
|
72
|
+
* `GET /auth/token?token=<secret>`: correct -> set the cookie and 302 to `/`;
|
|
73
|
+
* wrong/missing -> 401 with NO cookie. Not a contract endpoint (a security-layer
|
|
74
|
+
* route, registered outside the frozen route table).
|
|
75
|
+
*/
|
|
76
|
+
export declare function registerTokenBootstrap(app: Hono, token: string): void;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H-security — the minimal self-contained API token (fail-closed).
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `handlers/auth.ts` delegates the browser gate to nginx
|
|
5
|
+
* (`auth_request` -> `/auth/check`), and the backend is deliberately reachable
|
|
6
|
+
* from localhost with no cookie. That is safe ONLY while the listener is on the
|
|
7
|
+
* loopback. The CLI's `--bind` can move it to `0.0.0.0`, where the SAME backend
|
|
8
|
+
* exposes `POST /api/exec` (arbitrary shell as this user), `GET /api/fs/list`
|
|
9
|
+
* (any directory) and every agent endpoint to anyone who can reach the port.
|
|
10
|
+
*
|
|
11
|
+
* Rules, all "never silently allow":
|
|
12
|
+
* 1. a NON-loopback bind is REFUSED unless a token is configured;
|
|
13
|
+
* 2. when a token IS configured, every `/api/*` request except `/api/health`
|
|
14
|
+
* must present it — as `Authorization: Bearer`, `x-celestea-token`, or the
|
|
15
|
+
* cookie `GET /auth/token?token=…` sets — else 401.
|
|
16
|
+
*
|
|
17
|
+
* The browser problem (why the cookie exists): a browser never sends an
|
|
18
|
+
* `Authorization` header, so a token-only gate leaves the UI unusable (every
|
|
19
|
+
* fetch 401). `/auth/token` is the standard one-shot bootstrap: a correct token
|
|
20
|
+
* sets an HttpOnly cookie and 302s to `/`; the middleware then accepts the
|
|
21
|
+
* cookie. The cookie value is an HMAC of the token, never the plaintext token,
|
|
22
|
+
* so the raw secret is not stored client-side.
|
|
23
|
+
*
|
|
24
|
+
* The loopback path is untouched (no token -> no middleware, no bootstrap), so
|
|
25
|
+
* the existing nginx deployment and every existing test keep working.
|
|
26
|
+
*/
|
|
27
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
28
|
+
import { cookieValue } from "./token.js";
|
|
29
|
+
/** Env var carrying the token (the `--token` flag overrides it). */
|
|
30
|
+
export const ENV_AUTH_TOKEN = "CELESTEA_AUTH_TOKEN";
|
|
31
|
+
/** The token header (in addition to `Authorization: Bearer <token>`). */
|
|
32
|
+
export const AUTH_TOKEN_HEADER = "x-celestea-token";
|
|
33
|
+
/** The one API path that stays public (liveness probes, the UI's boot check). */
|
|
34
|
+
export const AUTH_EXEMPT_PATH = "/api/health";
|
|
35
|
+
/** The browser bootstrap path (NOT under /api, so the token gate never blocks it). */
|
|
36
|
+
export const AUTH_TOKEN_PATH = "/auth/token";
|
|
37
|
+
/** The HttpOnly cookie the bootstrap sets; distinct from the login cookie. */
|
|
38
|
+
export const AUTH_TOKEN_COOKIE = "celestea_api_token";
|
|
39
|
+
/** 30 days, the same lifetime the login cookie uses. */
|
|
40
|
+
export const AUTH_TOKEN_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
|
41
|
+
/** The host part of a `host` or `host:port` bind string (IPv6 aware). */
|
|
42
|
+
function hostOf(bind) {
|
|
43
|
+
const raw = bind.trim().toLowerCase();
|
|
44
|
+
if (raw.startsWith("[")) {
|
|
45
|
+
const end = raw.indexOf("]");
|
|
46
|
+
return end === -1 ? raw : raw.slice(1, end);
|
|
47
|
+
}
|
|
48
|
+
const first = raw.indexOf(":");
|
|
49
|
+
// Exactly one colon = "host:port"; two or more = a bare IPv6 literal.
|
|
50
|
+
if (first !== -1 && raw.indexOf(":", first + 1) === -1)
|
|
51
|
+
return raw.slice(0, first);
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
/** true only for an address that cannot be reached from another host. */
|
|
55
|
+
export function isLoopbackBind(bind) {
|
|
56
|
+
const host = hostOf(bind);
|
|
57
|
+
if (host === "localhost" || host === "::1" || host === "127.0.0.1")
|
|
58
|
+
return true;
|
|
59
|
+
if (host.startsWith("127."))
|
|
60
|
+
return true;
|
|
61
|
+
// IPv4-mapped IPv6 loopback (::ffff:127.0.0.1).
|
|
62
|
+
if (host.startsWith("::ffff:127."))
|
|
63
|
+
return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
/** The configured token, or null when unset/blank. */
|
|
67
|
+
export function readAuthToken(env = process.env) {
|
|
68
|
+
const raw = env[ENV_AUTH_TOKEN];
|
|
69
|
+
if (typeof raw !== "string")
|
|
70
|
+
return null;
|
|
71
|
+
const trimmed = raw.trim();
|
|
72
|
+
return trimmed === "" ? null : trimmed;
|
|
73
|
+
}
|
|
74
|
+
/** The token a request presents, from either accepted header (or null). */
|
|
75
|
+
export function presentedToken(authHeader, tokenHeader) {
|
|
76
|
+
if (typeof tokenHeader === "string" && tokenHeader.trim() !== "")
|
|
77
|
+
return tokenHeader.trim();
|
|
78
|
+
if (typeof authHeader === "string") {
|
|
79
|
+
const match = /^Bearer\s+(.+)$/i.exec(authHeader.trim());
|
|
80
|
+
const value = match?.[1];
|
|
81
|
+
if (value !== undefined && value.trim() !== "")
|
|
82
|
+
return value.trim();
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/** Constant-time string comparison (a length mismatch is a plain false). */
|
|
87
|
+
export function tokensMatch(presented, expected) {
|
|
88
|
+
const a = Buffer.from(presented, "utf8");
|
|
89
|
+
const b = Buffer.from(expected, "utf8");
|
|
90
|
+
if (a.length !== b.length)
|
|
91
|
+
return false;
|
|
92
|
+
return timingSafeEqual(a, b);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The cookie value for a token. An HMAC keyed by the token, so the cookie the
|
|
96
|
+
* browser stores is NOT the raw secret (a leaked cookie does not reveal the
|
|
97
|
+
* token, and the token never appears in a page or a cookie jar verbatim).
|
|
98
|
+
*/
|
|
99
|
+
export function tokenCookieValue(token) {
|
|
100
|
+
return createHmac("sha256", token).update("celestea-api-token-v1").digest("hex");
|
|
101
|
+
}
|
|
102
|
+
/** true when the presented cookie value is this token's cookie value. */
|
|
103
|
+
export function cookieMatches(presented, token) {
|
|
104
|
+
if (typeof presented !== "string" || presented === "")
|
|
105
|
+
return false;
|
|
106
|
+
return tokensMatch(presented, tokenCookieValue(token));
|
|
107
|
+
}
|
|
108
|
+
/** The refusal when a non-loopback bind has no token. */
|
|
109
|
+
export class InsecureBindError extends Error {
|
|
110
|
+
name = "InsecureBindError";
|
|
111
|
+
constructor(bind) {
|
|
112
|
+
super(`refusing to bind ${bind} without authentication: that would expose ` +
|
|
113
|
+
`POST /api/exec (arbitrary shell as this user), GET /api/fs/list (any directory) ` +
|
|
114
|
+
`and every agent endpoint to anyone who can reach the port.\n` +
|
|
115
|
+
` Fix it one of two ways:\n` +
|
|
116
|
+
` 1. keep it local: --bind 127.0.0.1 (the default), or\n` +
|
|
117
|
+
` 2. set a token: --token <secret> (or ${ENV_AUTH_TOKEN}=<secret>) and send it as ` +
|
|
118
|
+
`'Authorization: Bearer <secret>' (or '${AUTH_TOKEN_HEADER}: <secret>') on every /api/* request.\n` +
|
|
119
|
+
` For a browser, visit /auth/token?token=<secret> once to set the session cookie.\n` +
|
|
120
|
+
` For the nginx login gate, put nginx in front and bind 127.0.0.1.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Refuse a non-loopback bind that has no token (throws [InsecureBindError]). */
|
|
124
|
+
export function assertBindIsSafe(bind, token) {
|
|
125
|
+
if (isLoopbackBind(bind) || token !== null)
|
|
126
|
+
return;
|
|
127
|
+
throw new InsecureBindError(bind);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The token middleware: every `/api/*` request except `/api/health` must
|
|
131
|
+
* present the token as a header OR the bootstrap cookie, else 401. `/auth/*`
|
|
132
|
+
* and the static UI are not under `/api/*` and stay reachable.
|
|
133
|
+
*/
|
|
134
|
+
export function apiTokenMiddleware(token) {
|
|
135
|
+
return async (c, next) => {
|
|
136
|
+
if (c.req.path === AUTH_EXEMPT_PATH)
|
|
137
|
+
return next();
|
|
138
|
+
const presented = presentedToken(c.req.header("authorization"), c.req.header(AUTH_TOKEN_HEADER));
|
|
139
|
+
if (presented !== null && tokensMatch(presented, token))
|
|
140
|
+
return next();
|
|
141
|
+
if (cookieMatches(cookieValue(c.req.header("cookie"), AUTH_TOKEN_COOKIE), token))
|
|
142
|
+
return next();
|
|
143
|
+
return c.json({ ok: false, error: "unauthorized" }, 401);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The `Set-Cookie` value: HttpOnly + SameSite=Strict + Path=/ (+Secure over TLS). */
|
|
147
|
+
export function apiTokenCookie(token, secure) {
|
|
148
|
+
const attrs = [`${AUTH_TOKEN_COOKIE}=${tokenCookieValue(token)}`, "Path=/", `Max-Age=${AUTH_TOKEN_COOKIE_MAX_AGE}`, "HttpOnly", "SameSite=Strict"];
|
|
149
|
+
if (secure)
|
|
150
|
+
attrs.push("Secure");
|
|
151
|
+
return attrs.join("; ");
|
|
152
|
+
}
|
|
153
|
+
/** `Secure` is set only when the request really arrived over TLS (nginx). */
|
|
154
|
+
function isSecureRequest(c) {
|
|
155
|
+
const first = (c.req.header("x-forwarded-proto") ?? "").toLowerCase().split(",")[0];
|
|
156
|
+
return first !== undefined && first.trim() === "https";
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* `GET /auth/token?token=<secret>`: correct -> set the cookie and 302 to `/`;
|
|
160
|
+
* wrong/missing -> 401 with NO cookie. Not a contract endpoint (a security-layer
|
|
161
|
+
* route, registered outside the frozen route table).
|
|
162
|
+
*/
|
|
163
|
+
export function registerTokenBootstrap(app, token) {
|
|
164
|
+
app.get(AUTH_TOKEN_PATH, (c) => {
|
|
165
|
+
const presented = c.req.query("token");
|
|
166
|
+
if (presented === undefined || !tokensMatch(presented, token)) {
|
|
167
|
+
return c.json({ ok: false, error: "unauthorized" }, 401);
|
|
168
|
+
}
|
|
169
|
+
c.header("set-cookie", apiTokenCookie(token, isSecureRequest(c)));
|
|
170
|
+
return c.redirect("/", 302);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W767 — password verification against Studio's OWN password file.
|
|
3
|
+
*
|
|
4
|
+
* `htpasswd -vbi` is used deliberately: `-b` takes the password on **stdin**
|
|
5
|
+
* (never in argv, so it cannot show up in `ps`/audit logs), `-v` verifies, `-i`
|
|
6
|
+
* reads stdin. The file is read-only input — Studio never writes it, and it is
|
|
7
|
+
* the only credential source this module knows about.
|
|
8
|
+
*
|
|
9
|
+
* The username is restricted to a conservative charset: it travels in argv, and
|
|
10
|
+
* a leading `-` or a newline must never be able to steer the helper.
|
|
11
|
+
*/
|
|
12
|
+
/** The verifier binary (net/httpd-tools `htpasswd`). */
|
|
13
|
+
export declare const HTPASSWD_BIN = "htpasswd";
|
|
14
|
+
/** Accepted username shape (argv-safe, 1..64 chars). */
|
|
15
|
+
export declare const HTPASSWD_USER_RE: RegExp;
|
|
16
|
+
/**
|
|
17
|
+
* `ok` = the password verified; `denied` = wrong password or unknown user (the
|
|
18
|
+
* two are deliberately indistinguishable); `error` = the helper or the file is
|
|
19
|
+
* unusable (an operator problem, never reported as a wrong password).
|
|
20
|
+
*/
|
|
21
|
+
export type PasswordVerdict = "ok" | "denied" | "error";
|
|
22
|
+
export declare function verifyPassword(file: string, user: string, password: string): PasswordVerdict;
|