@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request parsing / response shaping for the grant endpoints (W516 §6).
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the routes so both stay inside the architecture budget: the
|
|
5
|
+
* error strings here are the FROZEN contract strings of §6.2, and a rejected
|
|
6
|
+
* scope value is never echoed (§5.4 — it may be a credential).
|
|
7
|
+
*/
|
|
8
|
+
import { unsandboxedAvailable } from "../runtime/engine-grants.js";
|
|
9
|
+
import { DEFAULT_TTL_SEC, isOfferedGrantCap, MAX_TTL_SEC, canonicalScopeHash, knownSecretsOf, looksLikeCredential, validateScope, } from "../store/grants.js";
|
|
10
|
+
import { failJson, numField, objectField, strField } from "./common.js";
|
|
11
|
+
/** Who the grant is attributed to. Server-side constant: never model text. */
|
|
12
|
+
export const GRANT_ACTOR = "ui:operator";
|
|
13
|
+
/** §6.2 body: `{cap, scope?, ttl_sec?, uses_left?, note?}` + the frozen 400s. */
|
|
14
|
+
export function parseGrantRequest(c, body, env) {
|
|
15
|
+
const known = knownSecretsOf(env);
|
|
16
|
+
const cap = strField(c, body, "cap");
|
|
17
|
+
if (!cap.ok)
|
|
18
|
+
return { ok: false, response: cap.response, reason: "field 'cap' must be a string" };
|
|
19
|
+
const raw = cap.value ?? "";
|
|
20
|
+
const chosen = offeredCap(raw, env);
|
|
21
|
+
if (chosen === null) {
|
|
22
|
+
const shown = looksLikeCredential(raw, known) ? "<redacted>" : raw;
|
|
23
|
+
return { ok: false, response: failJson(c, 400, `invalid cap '${shown}'`), reason: `invalid cap '${shown}'` };
|
|
24
|
+
}
|
|
25
|
+
const ttl = readTtl(c, body, chosen);
|
|
26
|
+
if (typeof ttl !== "number")
|
|
27
|
+
return { ok: false, response: ttl, reason: "ttl_sec out of range" };
|
|
28
|
+
const scope = readScope(c, body, chosen, known);
|
|
29
|
+
if (!scope.ok)
|
|
30
|
+
return { ok: false, response: scope.response, reason: scope.reason };
|
|
31
|
+
const uses = readUses(c, body, chosen);
|
|
32
|
+
if (typeof uses === "string")
|
|
33
|
+
return { ok: false, response: failJson(c, 400, uses), reason: uses };
|
|
34
|
+
const note = strField(c, body, "note");
|
|
35
|
+
if (!note.ok)
|
|
36
|
+
return { ok: false, response: note.response, reason: "field 'note' must be a string" };
|
|
37
|
+
// §5.4 + scenario 15: a note is user text, but a credential shape is refused
|
|
38
|
+
// outright (the frozen 400 string) and is never echoed back anywhere.
|
|
39
|
+
if (note.value !== undefined && looksLikeCredential(note.value, known)) {
|
|
40
|
+
return { ok: false, response: failJson(c, 400, "value looks like a credential"), reason: "value looks like a credential" };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
value: {
|
|
45
|
+
cap: chosen,
|
|
46
|
+
scope: scope.value,
|
|
47
|
+
ttlSec: ttl,
|
|
48
|
+
usesLeft: uses,
|
|
49
|
+
note: note.value ?? "",
|
|
50
|
+
scopeHash: canonicalScopeHash(chosen, scope.value),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The cap must be known AND offered here: `unsandboxed` only exists behind
|
|
56
|
+
* `CELESTEA_GRANTS_ALLOW_UNSANDBOXED=1` (§2.2), so without it the cap is simply
|
|
57
|
+
* not valid — the frozen `invalid cap '<x>'` error, never a special case.
|
|
58
|
+
*/
|
|
59
|
+
export function offeredCap(raw, env) {
|
|
60
|
+
// W819-8: a cap that exists in the file format but has no consumption point
|
|
61
|
+
// (RESERVED_GRANT_CAPS) is simply not offered - the frozen 400.
|
|
62
|
+
if (!isOfferedGrantCap(raw))
|
|
63
|
+
return null;
|
|
64
|
+
if (raw === "unsandboxed" && !unsandboxedAvailable(env))
|
|
65
|
+
return null;
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
/** `ttl_sec`: positive integer, 0 = no expiry, capped per cap (§2.3). */
|
|
69
|
+
export function readTtl(c, body, cap) {
|
|
70
|
+
const field = numField(c, body, "ttl_sec");
|
|
71
|
+
if (!field.ok)
|
|
72
|
+
return field.response;
|
|
73
|
+
const ttl = field.value ?? DEFAULT_TTL_SEC;
|
|
74
|
+
if (!Number.isInteger(ttl) || ttl < 0)
|
|
75
|
+
return failJson(c, 400, "ttl_sec must be a non-negative integer (0 = no expiry)");
|
|
76
|
+
if (ttl > MAX_TTL_SEC[cap])
|
|
77
|
+
return failJson(c, 400, `ttl_sec exceeds the maximum for cap '${cap}' (${MAX_TTL_SEC[cap]})`);
|
|
78
|
+
return ttl;
|
|
79
|
+
}
|
|
80
|
+
function readScope(c, body, cap, known) {
|
|
81
|
+
const field = objectField(c, body, "scope");
|
|
82
|
+
if (!field.ok)
|
|
83
|
+
return { ok: false, response: field.response, reason: "field 'scope' must be an object" };
|
|
84
|
+
const scope = validateScope(cap, field.value, known);
|
|
85
|
+
if (!scope.ok) {
|
|
86
|
+
const reason = `invalid scope for cap '${cap}': ${scope.error}`;
|
|
87
|
+
return { ok: false, response: failJson(c, 400, reason), reason };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, value: scope.scope };
|
|
90
|
+
}
|
|
91
|
+
/** `uses_left`: positive integer or null; `unsandboxed` is forced to 1 (§2.3). */
|
|
92
|
+
function readUses(c, body, cap) {
|
|
93
|
+
if (cap === "unsandboxed")
|
|
94
|
+
return 1;
|
|
95
|
+
const field = numField(c, body, "uses_left");
|
|
96
|
+
if (!field.ok)
|
|
97
|
+
return "uses_left must be a positive integer";
|
|
98
|
+
const value = field.value;
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
if (!Number.isInteger(value) || value < 1)
|
|
102
|
+
return "uses_left must be a positive integer";
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
/** One GET entry: the stored record plus the read-time `expired` flag (§6.1). */
|
|
106
|
+
export function entryJson(record, now) {
|
|
107
|
+
const expired = record.expires_at !== null && now >= record.expires_at;
|
|
108
|
+
return {
|
|
109
|
+
id: record.id,
|
|
110
|
+
cap: record.cap,
|
|
111
|
+
scope: record.scope,
|
|
112
|
+
granted_at: record.granted_at,
|
|
113
|
+
granted_by: record.granted_by,
|
|
114
|
+
expires_at: record.expires_at,
|
|
115
|
+
uses_left: record.uses_left,
|
|
116
|
+
note: record.note,
|
|
117
|
+
expired,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The `effective` snapshot of §6.1 (snake_case; the UI shows it verbatim). */
|
|
121
|
+
export function effectiveJson(grants) {
|
|
122
|
+
return {
|
|
123
|
+
network: grants.network,
|
|
124
|
+
read_roots: [...grants.readRoots],
|
|
125
|
+
write_roots: [...grants.writeRoots],
|
|
126
|
+
net_hosts: [...grants.netHosts],
|
|
127
|
+
tool_extra: [...grants.toolExtra],
|
|
128
|
+
unsandboxed: grants.unsandboxed,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four grant endpoints (W516 §6): read the session's grants, grant one, a
|
|
3
|
+
* revoke, and the one-shot confirm token that makes a grant a HUMAN action.
|
|
4
|
+
*
|
|
5
|
+
* Threat model in one line: the session's own tools can read/write its
|
|
6
|
+
* directory and can reach `127.0.0.1`, so a grant may not be a plain POST. A
|
|
7
|
+
* POST needs `X-Celestea-Grant-Confirm`, that token only comes from the
|
|
8
|
+
* same-origin-only token endpoint, and it is bound to `(session, cap,
|
|
9
|
+
* scope_hash)`, lives 60s and burns on first use (§5.5).
|
|
10
|
+
*
|
|
11
|
+
* Every grant / revoke / refusal is audited twice (§4.4): the local
|
|
12
|
+
* append-only `grants-audit.jsonl` is authoritative, the platform channel is
|
|
13
|
+
* best-effort and its failures are recorded locally as `platform_audit_failed`.
|
|
14
|
+
*/
|
|
15
|
+
import type { Hono } from "hono";
|
|
16
|
+
import type { RouteTable } from "../routes.js";
|
|
17
|
+
import { type EffectiveGrants } from "../runtime/engine-grants.js";
|
|
18
|
+
import { type GrantsServices } from "../store/grants-service.js";
|
|
19
|
+
import { type Deps } from "./common.js";
|
|
20
|
+
export declare function registerGrants(app: Hono, deps: Deps, table: RouteTable): string[];
|
|
21
|
+
export type { EffectiveGrants, GrantsServices };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four grant endpoints (W516 §6): read the session's grants, grant one, a
|
|
3
|
+
* revoke, and the one-shot confirm token that makes a grant a HUMAN action.
|
|
4
|
+
*
|
|
5
|
+
* Threat model in one line: the session's own tools can read/write its
|
|
6
|
+
* directory and can reach `127.0.0.1`, so a grant may not be a plain POST. A
|
|
7
|
+
* POST needs `X-Celestea-Grant-Confirm`, that token only comes from the
|
|
8
|
+
* same-origin-only token endpoint, and it is bound to `(session, cap,
|
|
9
|
+
* scope_hash)`, lives 60s and burns on first use (§5.5).
|
|
10
|
+
*
|
|
11
|
+
* Every grant / revoke / refusal is audited twice (§4.4): the local
|
|
12
|
+
* append-only `grants-audit.jsonl` is authoritative, the platform channel is
|
|
13
|
+
* best-effort and its failures are recorded locally as `platform_audit_failed`.
|
|
14
|
+
*/
|
|
15
|
+
import { effectiveGrantsOf, netHostsEffective, unsandboxedAvailable } from "../runtime/engine-grants.js";
|
|
16
|
+
import { isOfferedGrantCap, MAX_TTL_SEC, emptyGrantsFile, newGrantId, readGrantsFile, writeGrantsFile } from "../store/grants.js";
|
|
17
|
+
import { CONFIRM_HEADER, SEC_FETCH_MODE, SEC_FETCH_SITE, ORIGIN_HEADER } from "../store/grants-tokens.js";
|
|
18
|
+
import { nowSec } from "../store/grants-service.js";
|
|
19
|
+
import { errText } from "../store/result.js";
|
|
20
|
+
import { entryJson, effectiveJson, parseGrantRequest, GRANT_ACTOR } from "./grants-shape.js";
|
|
21
|
+
import { failJson, readJsonBody, strField, storeFail } from "./common.js";
|
|
22
|
+
const NOT_SAME_ORIGIN_TOO = "grant confirmation is not available over this transport";
|
|
23
|
+
const CONFIRM_REQUIRED = "grant confirmation required";
|
|
24
|
+
/**
|
|
25
|
+
* W757: the readable half of `net_hosts_effective: false`. `net_hosts` entries
|
|
26
|
+
* are UNIONed into the allow side and only ever count while the env policy is
|
|
27
|
+
* active, so on a deployment that sets neither `CELESTEA_HTTP_ALLOW` nor
|
|
28
|
+
* `CELESTEA_HTTP_DENY` such a grant changes nothing — and must not be read as
|
|
29
|
+
* "this session is limited to those sites".
|
|
30
|
+
*/
|
|
31
|
+
const NET_HOSTS_INEFFECTIVE = "net_hosts_ineffective: 当前部署未设置站点策略(CELESTEA_HTTP_ALLOW / CELESTEA_HTTP_DENY 均未设置),列出的站点不会改变可访问范围 —— 该授权不生效";
|
|
32
|
+
/**
|
|
33
|
+
* W819-8: the readable half of "tool_extra is reserved". The cap has no tool
|
|
34
|
+
* exposure point yet, so a stored entry is echoed but changes nothing; it is
|
|
35
|
+
* also no longer offered (only revocable).
|
|
36
|
+
*/
|
|
37
|
+
const TOOL_EXTRA_INEFFECTIVE = "tool_extra_ineffective: tool_extra 预留给未来的 browser/net 工具,当前没有任何工具暴露面消费它 —— 该授权不生效(已停止新授,可撤销)";
|
|
38
|
+
/** GET /api/sessions/{id}/grants (§6.1). */
|
|
39
|
+
function registerList(app, deps, table) {
|
|
40
|
+
const route = table.get("get_session_grants");
|
|
41
|
+
app.on(route.method, route.honoPath, (c) => {
|
|
42
|
+
const resolved = deps.sessions.require(c.req.param("id") ?? "");
|
|
43
|
+
if (!resolved.ok)
|
|
44
|
+
return storeFail(c, resolved);
|
|
45
|
+
const seconds = nowSec(deps.grants);
|
|
46
|
+
const read = readGrantsFile(resolved.value.dir, resolved.value.id);
|
|
47
|
+
const effective = effectiveGrantsOf(resolved.value.dir, resolved.value.id, deps.grants.env, seconds);
|
|
48
|
+
const grants = (read.file?.grants ?? []).map((grant) => entryJson(grant, seconds));
|
|
49
|
+
// W757 (§6.1): report the DEPLOYMENT fact as well as the readable warning —
|
|
50
|
+
// the verdict comes from the very policy the engine mounts its tools with.
|
|
51
|
+
const netHosts = netHostsEffective(deps.grants.env, effective.grants);
|
|
52
|
+
const warnings = [
|
|
53
|
+
...effective.warnings,
|
|
54
|
+
...(netHosts ? [] : [NET_HOSTS_INEFFECTIVE]),
|
|
55
|
+
...(effective.grants.toolExtra.length > 0 ? [TOOL_EXTRA_INEFFECTIVE] : []),
|
|
56
|
+
];
|
|
57
|
+
return c.json({
|
|
58
|
+
ok: true,
|
|
59
|
+
session: resolved.value.id,
|
|
60
|
+
grants,
|
|
61
|
+
effective: effectiveJson(effective.grants),
|
|
62
|
+
max_ttl_sec: MAX_TTL_SEC,
|
|
63
|
+
unsandboxed_available: unsandboxedAvailable(deps.grants.env),
|
|
64
|
+
net_hosts_effective: netHosts,
|
|
65
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
return route.id;
|
|
69
|
+
}
|
|
70
|
+
/** POST /api/sessions/{id}/grants (§6.2) — replace-by-cap, one cap one set. */
|
|
71
|
+
function registerCreate(app, deps, table) {
|
|
72
|
+
const route = table.get("post_session_grants");
|
|
73
|
+
app.on(route.method, route.honoPath, async (c) => {
|
|
74
|
+
const resolved = deps.sessions.require(c.req.param("id") ?? "");
|
|
75
|
+
if (!resolved.ok)
|
|
76
|
+
return storeFail(c, resolved);
|
|
77
|
+
const sessionId = resolved.value.id;
|
|
78
|
+
const services = deps.grants;
|
|
79
|
+
const limit = services.limits.allow(sessionId);
|
|
80
|
+
if (!limit.ok)
|
|
81
|
+
return failJson(c, limit.status, limitMessage(limit), { retry_after: limit.retryAfterSec });
|
|
82
|
+
const refusal = { deps, sessionId, reason: "" };
|
|
83
|
+
const read = await readJsonBody(c);
|
|
84
|
+
if (!read.ok)
|
|
85
|
+
return denied({ ...refusal, reason: "readable JSON body required" }, read.response);
|
|
86
|
+
const request = parseGrantRequest(c, read.body, services.env);
|
|
87
|
+
if (!request.ok)
|
|
88
|
+
return denied({ ...refusal, reason: request.reason }, request.response);
|
|
89
|
+
refusal.cap = request.value.cap;
|
|
90
|
+
const token = c.req.header(CONFIRM_HEADER) ?? "";
|
|
91
|
+
if (token === "")
|
|
92
|
+
return denied({ ...refusal, reason: CONFIRM_REQUIRED }, failJson(c, 403, CONFIRM_REQUIRED));
|
|
93
|
+
const verdict = services.tokens.consume(sessionId, request.value.cap, request.value.scopeHash, token);
|
|
94
|
+
if (verdict === "invalid")
|
|
95
|
+
return denied({ ...refusal, reason: CONFIRM_REQUIRED }, failJson(c, 403, CONFIRM_REQUIRED));
|
|
96
|
+
if (verdict === "used") {
|
|
97
|
+
return denied({ ...refusal, reason: "confirmation token already used" }, failJson(c, 409, "confirmation token already used"));
|
|
98
|
+
}
|
|
99
|
+
return persistGrant(c, deps, resolved.value.dir, sessionId, request.value);
|
|
100
|
+
});
|
|
101
|
+
return route.id;
|
|
102
|
+
}
|
|
103
|
+
/** §6.2 `429` / `409` bodies carry the remaining seconds. */
|
|
104
|
+
function limitMessage(limit) {
|
|
105
|
+
if (limit.status === 429)
|
|
106
|
+
return `too many grant requests; retry in ${limit.retryAfterSec}s`;
|
|
107
|
+
return `a grant request was just denied; retry in ${limit.retryAfterSec}s`;
|
|
108
|
+
}
|
|
109
|
+
/** Store the grant, bump the session's epoch, audit, answer (§6.2). */
|
|
110
|
+
function persistGrant(c, deps, dir, sessionId, request) {
|
|
111
|
+
const services = deps.grants;
|
|
112
|
+
const seconds = nowSec(services);
|
|
113
|
+
const existing = readGrantsFile(dir, sessionId);
|
|
114
|
+
if (existing.exists && existing.file === undefined) {
|
|
115
|
+
services.audit.write({ session: sessionId, event: "grants_unreadable", reason: existing.error ?? "unreadable" });
|
|
116
|
+
}
|
|
117
|
+
const base = existing.file ?? emptyGrantsFile(sessionId, seconds);
|
|
118
|
+
const record = {
|
|
119
|
+
id: newGrantId(),
|
|
120
|
+
cap: request.cap,
|
|
121
|
+
scope: request.scope,
|
|
122
|
+
granted_at: seconds,
|
|
123
|
+
granted_by: GRANT_ACTOR,
|
|
124
|
+
expires_at: request.ttlSec === 0 ? null : seconds + request.ttlSec,
|
|
125
|
+
uses_left: request.usesLeft,
|
|
126
|
+
note: request.note,
|
|
127
|
+
};
|
|
128
|
+
const file = { version: 1, session: sessionId, updated_at: seconds, grants: upsert(base, record) };
|
|
129
|
+
try {
|
|
130
|
+
writeGrantsFile(dir, file, { env: services.env, now: seconds });
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return failJson(c, 500, `cannot persist grants: ${errText(e)}`);
|
|
134
|
+
}
|
|
135
|
+
deps.runtime.invalidateSession?.(sessionId);
|
|
136
|
+
const effective = effectiveGrantsOf(dir, sessionId, services.env, seconds);
|
|
137
|
+
services.audit.write({
|
|
138
|
+
session: sessionId,
|
|
139
|
+
event: "grant",
|
|
140
|
+
grant_id: record.id,
|
|
141
|
+
cap: record.cap,
|
|
142
|
+
scope: record.scope,
|
|
143
|
+
actor: GRANT_ACTOR,
|
|
144
|
+
expires_at: record.expires_at,
|
|
145
|
+
uses_left: record.uses_left,
|
|
146
|
+
effective_after: effectiveJson(effective.grants),
|
|
147
|
+
});
|
|
148
|
+
services.limits.recordSuccess(sessionId);
|
|
149
|
+
return c.json({ ok: true, grant: entryJson(record, seconds), effective: effectiveJson(effective.grants) });
|
|
150
|
+
}
|
|
151
|
+
/** §6.2: one cap holds exactly one live entry — replace, never stack. */
|
|
152
|
+
function upsert(file, record) {
|
|
153
|
+
return [...file.grants.filter((grant) => grant.cap !== record.cap), record];
|
|
154
|
+
}
|
|
155
|
+
/** DELETE /api/sessions/{id}/grants (§6.3) — no token: revoking is safe. */
|
|
156
|
+
function registerRevoke(app, deps, table) {
|
|
157
|
+
const route = table.get("delete_session_grants");
|
|
158
|
+
app.on(route.method, route.honoPath, async (c) => {
|
|
159
|
+
const resolved = deps.sessions.require(c.req.param("id") ?? "");
|
|
160
|
+
if (!resolved.ok)
|
|
161
|
+
return storeFail(c, resolved);
|
|
162
|
+
const read = await readJsonBody(c, false);
|
|
163
|
+
if (!read.ok)
|
|
164
|
+
return read.response;
|
|
165
|
+
const cap = strField(c, read.body, "cap");
|
|
166
|
+
if (!cap.ok)
|
|
167
|
+
return cap.response;
|
|
168
|
+
const grantId = strField(c, read.body, "grant_id");
|
|
169
|
+
if (!grantId.ok)
|
|
170
|
+
return grantId.response;
|
|
171
|
+
return revoke(c, deps, { dir: resolved.value.dir, sessionId: resolved.value.id, cap: cap.value, grantId: grantId.value });
|
|
172
|
+
});
|
|
173
|
+
return route.id;
|
|
174
|
+
}
|
|
175
|
+
/** Remove the matching entries (all of them when nothing is named). */
|
|
176
|
+
function revoke(c, deps, target) {
|
|
177
|
+
const { dir, sessionId, cap, grantId } = target;
|
|
178
|
+
const services = deps.grants;
|
|
179
|
+
const seconds = nowSec(services);
|
|
180
|
+
const read = readGrantsFile(dir, sessionId);
|
|
181
|
+
if (read.exists && read.file === undefined) {
|
|
182
|
+
services.audit.write({ session: sessionId, event: "grants_unreadable", reason: read.error ?? "unreadable" });
|
|
183
|
+
}
|
|
184
|
+
const entries = read.file?.grants ?? [];
|
|
185
|
+
const matches = (grant) => (cap === undefined || grant.cap === cap) && (grantId === undefined || grant.id === grantId);
|
|
186
|
+
const revoked = entries.filter(matches);
|
|
187
|
+
const file = { version: 1, session: sessionId, updated_at: seconds, grants: entries.filter((g) => !matches(g)) };
|
|
188
|
+
if (revoked.length > 0) {
|
|
189
|
+
try {
|
|
190
|
+
writeGrantsFile(dir, file, { env: services.env, now: seconds });
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
return failJson(c, 500, `cannot persist grants: ${errText(e)}`);
|
|
194
|
+
}
|
|
195
|
+
deps.runtime.invalidateSession?.(sessionId);
|
|
196
|
+
}
|
|
197
|
+
const effective = effectiveGrantsOf(dir, sessionId, services.env, seconds);
|
|
198
|
+
for (const grant of revoked) {
|
|
199
|
+
services.audit.write({ session: sessionId, event: "revoke", grant_id: grant.id, cap: grant.cap, actor: GRANT_ACTOR, effective_after: effectiveJson(effective.grants) });
|
|
200
|
+
}
|
|
201
|
+
return c.json({ ok: true, revoked: revoked.map((grant) => grant.id), effective: effectiveJson(effective.grants) });
|
|
202
|
+
}
|
|
203
|
+
/** GET /api/sessions/{id}/grants/confirm-token (§6.4) — same-origin only. */
|
|
204
|
+
function registerConfirmToken(app, deps, table) {
|
|
205
|
+
const route = table.get("get_session_grants_confirm_token");
|
|
206
|
+
app.on(route.method, route.honoPath, (c) => {
|
|
207
|
+
const resolved = deps.sessions.require(c.req.param("id") ?? "");
|
|
208
|
+
if (!resolved.ok)
|
|
209
|
+
return storeFail(c, resolved);
|
|
210
|
+
if (!hasSameOriginEvidence(c))
|
|
211
|
+
return failJson(c, 403, NOT_SAME_ORIGIN_TOO);
|
|
212
|
+
const cap = c.req.query("cap") ?? "";
|
|
213
|
+
const allowed = isOfferedGrantCap(cap);
|
|
214
|
+
if (!allowed || (cap === "unsandboxed" && !unsandboxedAvailable(deps.grants.env)))
|
|
215
|
+
return failJson(c, 400, `invalid cap '${cap}'`);
|
|
216
|
+
const hash = c.req.query("scope_hash") ?? "";
|
|
217
|
+
if (!/^[0-9a-f]{64}$/.test(hash))
|
|
218
|
+
return failJson(c, 400, "scope_hash must be a 64-char sha256 hex string");
|
|
219
|
+
const issued = deps.grants.tokens.issue(resolved.value.id, cap, hash);
|
|
220
|
+
return c.json({ ok: true, token: issued.token, expires_at: issued.expiresAt });
|
|
221
|
+
});
|
|
222
|
+
return route.id;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* §5.5.2: a browser navigation/XHR from our own origin, and nothing a session
|
|
226
|
+
* tool can forge. `Sec-Fetch-Site: same-origin` is the strong evidence; the
|
|
227
|
+
* CORS fallback additionally requires an Origin that matches Host.
|
|
228
|
+
*/
|
|
229
|
+
function hasSameOriginEvidence(c) {
|
|
230
|
+
if ((c.req.header(SEC_FETCH_SITE) ?? "").toLowerCase() === "same-origin")
|
|
231
|
+
return true;
|
|
232
|
+
if ((c.req.header(SEC_FETCH_MODE) ?? "").toLowerCase() !== "cors")
|
|
233
|
+
return false;
|
|
234
|
+
const origin = c.req.header(ORIGIN_HEADER) ?? "";
|
|
235
|
+
const host = c.req.header("host") ?? "";
|
|
236
|
+
if (origin === "")
|
|
237
|
+
return true;
|
|
238
|
+
try {
|
|
239
|
+
return new URL(origin).host === host;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A refused grant counts toward the 3-strikes cooldown (§5.5.5) and is audited
|
|
247
|
+
* (§4.4). The audited reason is the SAME sanitized text the client got: a
|
|
248
|
+
* rejected value never reaches the audit file either (§5.4).
|
|
249
|
+
*/
|
|
250
|
+
function denied(ctx, response) {
|
|
251
|
+
ctx.deps.grants.limits.recordDenial(ctx.sessionId);
|
|
252
|
+
ctx.deps.grants.audit.write({
|
|
253
|
+
session: ctx.sessionId,
|
|
254
|
+
event: "deny",
|
|
255
|
+
...(ctx.cap === undefined ? {} : { cap: ctx.cap }),
|
|
256
|
+
reason: ctx.reason,
|
|
257
|
+
});
|
|
258
|
+
return response;
|
|
259
|
+
}
|
|
260
|
+
export function registerGrants(app, deps, table) {
|
|
261
|
+
return [
|
|
262
|
+
registerList(app, deps, table),
|
|
263
|
+
registerCreate(app, deps, table),
|
|
264
|
+
registerRevoke(app, deps, table),
|
|
265
|
+
registerConfirmToken(app, deps, table),
|
|
266
|
+
];
|
|
267
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health / status / tools — `src/main.rs:860-869`, `src/api.rs:40-83`.
|
|
3
|
+
*
|
|
4
|
+
* All three are always 200 with no error branch. `bind` is the ACTUAL
|
|
5
|
+
* listening address (`host:port`): `startStudioServer` writes it back once the
|
|
6
|
+
* socket is up, so it follows `--bind`/`--port` (and reports the real port for
|
|
7
|
+
* `--port 0`) instead of repeating `DEFAULT_BIND`. When the app is composed
|
|
8
|
+
* without a server (tests), it stays `DEFAULT_BIND`.
|
|
9
|
+
*
|
|
10
|
+
* W513: `GET /api/status` reads ONE session's trackers — `?session=<id>`, or the
|
|
11
|
+
* active session when the query is absent — and reports that session's `busy`
|
|
12
|
+
* slot alongside the (unchanged) 7 statusline fields.
|
|
13
|
+
*
|
|
14
|
+
* W729: `/api/status` adds `mode` (the queried session's mode) and
|
|
15
|
+
* `/api/health` advertises `capabilities.session_mode = true` (P0: the mode is
|
|
16
|
+
* fixed at creation, so the capability is a read-only announcement).
|
|
17
|
+
*
|
|
18
|
+
* W516: `/api/health` advertises `capabilities.grants = true` (the frontend
|
|
19
|
+
* hides the permission panel when it is not exactly `true`, so the retired
|
|
20
|
+
* backend cannot show a panel that does nothing), and `/api/status` adds
|
|
21
|
+
* `grants_active` — the CAP NAMES in force for that session, never the paths
|
|
22
|
+
* (an operator can see which session is widened without leaking a filesystem
|
|
23
|
+
* layout into a status poll).
|
|
24
|
+
*
|
|
25
|
+
* W791 (P1): `GET /api/tools` takes `?session=` (absent = the focused session)
|
|
26
|
+
* and `/api/health` advertises `capabilities.session_mode_tools = true`.
|
|
27
|
+
*
|
|
28
|
+
* W785 (E-P1, capability 3 ②): `/api/status` adds `cost` — the session's
|
|
29
|
+
* engine-side cost estimate read from the append-only usage ledger (§3.2.4). The
|
|
30
|
+
* key is a PURE ADDITION and is present ONLY when the adapter has a ledger, so a
|
|
31
|
+
* host with the ledger switched off answers exactly the pre-W785 body and an old
|
|
32
|
+
* client never receives a `null` it would have to interpret.
|
|
33
|
+
*/
|
|
34
|
+
import type { Hono } from "hono";
|
|
35
|
+
import type { RouteTable } from "../routes.js";
|
|
36
|
+
import { type Deps } from "./common.js";
|
|
37
|
+
export declare function registerHealth(app: Hono, deps: Deps, table: RouteTable): string[];
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health / status / tools — `src/main.rs:860-869`, `src/api.rs:40-83`.
|
|
3
|
+
*
|
|
4
|
+
* All three are always 200 with no error branch. `bind` is the ACTUAL
|
|
5
|
+
* listening address (`host:port`): `startStudioServer` writes it back once the
|
|
6
|
+
* socket is up, so it follows `--bind`/`--port` (and reports the real port for
|
|
7
|
+
* `--port 0`) instead of repeating `DEFAULT_BIND`. When the app is composed
|
|
8
|
+
* without a server (tests), it stays `DEFAULT_BIND`.
|
|
9
|
+
*
|
|
10
|
+
* W513: `GET /api/status` reads ONE session's trackers — `?session=<id>`, or the
|
|
11
|
+
* active session when the query is absent — and reports that session's `busy`
|
|
12
|
+
* slot alongside the (unchanged) 7 statusline fields.
|
|
13
|
+
*
|
|
14
|
+
* W729: `/api/status` adds `mode` (the queried session's mode) and
|
|
15
|
+
* `/api/health` advertises `capabilities.session_mode = true` (P0: the mode is
|
|
16
|
+
* fixed at creation, so the capability is a read-only announcement).
|
|
17
|
+
*
|
|
18
|
+
* W516: `/api/health` advertises `capabilities.grants = true` (the frontend
|
|
19
|
+
* hides the permission panel when it is not exactly `true`, so the retired
|
|
20
|
+
* backend cannot show a panel that does nothing), and `/api/status` adds
|
|
21
|
+
* `grants_active` — the CAP NAMES in force for that session, never the paths
|
|
22
|
+
* (an operator can see which session is widened without leaking a filesystem
|
|
23
|
+
* layout into a status poll).
|
|
24
|
+
*
|
|
25
|
+
* W791 (P1): `GET /api/tools` takes `?session=` (absent = the focused session)
|
|
26
|
+
* and `/api/health` advertises `capabilities.session_mode_tools = true`.
|
|
27
|
+
*
|
|
28
|
+
* W785 (E-P1, capability 3 ②): `/api/status` adds `cost` — the session's
|
|
29
|
+
* engine-side cost estimate read from the append-only usage ledger (§3.2.4). The
|
|
30
|
+
* key is a PURE ADDITION and is present ONLY when the adapter has a ledger, so a
|
|
31
|
+
* host with the ledger switched off answers exactly the pre-W785 body and an old
|
|
32
|
+
* client never receives a `null` it would have to interpret.
|
|
33
|
+
*/
|
|
34
|
+
import { resolveStudioVersion } from "../version.js";
|
|
35
|
+
import { emptyRecoveryView } from "../runtime/recovery-view.js";
|
|
36
|
+
import { activeSession, modeOfSession, sessionModelCovered } from "./common.js";
|
|
37
|
+
import { baseUrlOf } from "./config-shape.js";
|
|
38
|
+
import { effectiveGrantsOf, grantsActiveCaps } from "../runtime/engine-grants.js";
|
|
39
|
+
import { nowSec } from "../store/grants-service.js";
|
|
40
|
+
export function registerHealth(app, deps, table) {
|
|
41
|
+
const health = table.get("get_health");
|
|
42
|
+
app.on(health.method, health.honoPath, async (c) => {
|
|
43
|
+
// W887 + H: git-derived in a checkout, the package version once installed.
|
|
44
|
+
const version = await resolveStudioVersion();
|
|
45
|
+
return c.json({
|
|
46
|
+
ok: true,
|
|
47
|
+
name: deps.config.name,
|
|
48
|
+
model: deps.runtime.profile().model,
|
|
49
|
+
base_url: baseUrlOf(deps),
|
|
50
|
+
bind: deps.config.bind,
|
|
51
|
+
// W887: the SAME derived version the frontend shows (PURE ADDITION; a client
|
|
52
|
+
// that does not see the key degrades to no version line).
|
|
53
|
+
version,
|
|
54
|
+
// W725: `context: true` gates the context-ring entry point; a client
|
|
55
|
+
// that does not see exactly `true` degrades to no context viewer.
|
|
56
|
+
// W729: `session_mode: true` gates the (P1) mode selector; a client that
|
|
57
|
+
// does not see exactly `true` must not offer to set a session mode.
|
|
58
|
+
// W791 (P1, U8): `session_mode_tools: true` additionally promises that the
|
|
59
|
+
// mode is OBSERVABLE in the tool face (`GET /api/tools?session=`) and that
|
|
60
|
+
// the mode switch endpoint exists — a legacy-backend client that sees only
|
|
61
|
+
// `session_mode` must not call `POST /api/sessions/{id}/mode` (TS-only).
|
|
62
|
+
// W804 (multimodal P0 section 7.1): PURE ADDITION — a client that does not
|
|
63
|
+
// see exactly true degrades to "no attachment entry points".
|
|
64
|
+
capabilities: { grants: true, context: true, session_mode: true, session_mode_tools: true, multimodal: true },
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
const status = table.get("get_status");
|
|
68
|
+
app.on(status.method, status.honoPath, (c) => {
|
|
69
|
+
const asked = c.req.query("session");
|
|
70
|
+
const session = asked === undefined || asked === "" ? activeSession(deps) : asked;
|
|
71
|
+
const line = deps.runtime.statusline(session);
|
|
72
|
+
return c.json({
|
|
73
|
+
...line,
|
|
74
|
+
session,
|
|
75
|
+
// W729: the mode of the QUERIED session (absent = standard, K8).
|
|
76
|
+
mode: modeOfSession(deps, session),
|
|
77
|
+
// W870: whether `model` above is THIS session's own override rather than
|
|
78
|
+
// the global default (the picker's 「本会话已固定模型」 line reads it).
|
|
79
|
+
// A PURE ADDITION: a client that does not see the key shows no such line.
|
|
80
|
+
model_covered: sessionModelCovered(deps, session),
|
|
81
|
+
busy: deps.runtime.isBusy(session),
|
|
82
|
+
grants_active: activeGrantCaps(deps, session),
|
|
83
|
+
...costField(deps, session),
|
|
84
|
+
// E §1.3 P1 ② (W787): the session's checkpoint view. A PURE ADDITION, always
|
|
85
|
+
// present (an adapter without checkpointing answers the empty block), so a
|
|
86
|
+
// client can rely on the key existing without inventing a default.
|
|
87
|
+
recovery: recoveryBlockOf(deps, session),
|
|
88
|
+
// E §4.2.3 #4 (W785): `model` stays the CONFIGURED value; these two are
|
|
89
|
+
// the only place a downgrade shows. Pure additions, always present.
|
|
90
|
+
effective_model: fallbackView(deps, session)?.effective_model ?? line.model,
|
|
91
|
+
fallback: fallbackView(deps, session) ?? {
|
|
92
|
+
active: false,
|
|
93
|
+
chain: [],
|
|
94
|
+
effective_model: null,
|
|
95
|
+
last_reason: null,
|
|
96
|
+
targets: [],
|
|
97
|
+
problems: [],
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
// W791 (P1, §5.2 #5 / S2 / M9): `?session=` answers for THAT session's own
|
|
102
|
+
// generation — the very registry its Context provides, so an `execution`
|
|
103
|
+
// session reports its folded face. Absent/blank = the FOCUSED session (the
|
|
104
|
+
// active one); with nothing active it is the detached default generation,
|
|
105
|
+
// which is the pre-P1 answer byte for byte.
|
|
106
|
+
const tools = table.get("get_tools");
|
|
107
|
+
app.on(tools.method, tools.honoPath, (c) => {
|
|
108
|
+
const asked = c.req.query("session");
|
|
109
|
+
const focus = asked === undefined || asked === "" ? activeSession(deps) : asked;
|
|
110
|
+
if (focus === null)
|
|
111
|
+
return c.json({ tools: deps.runtime.tools() });
|
|
112
|
+
return c.json({ tools: deps.runtime.sessionTools?.(focus) ?? deps.runtime.tools() });
|
|
113
|
+
});
|
|
114
|
+
return [health.id, status.id, tools.id];
|
|
115
|
+
}
|
|
116
|
+
/** E §4.2.3 #4 (W785): the fallback view of this session (null = not armed). */
|
|
117
|
+
function fallbackView(deps, session) {
|
|
118
|
+
return deps.runtime.fallbackView?.(session) ?? null;
|
|
119
|
+
}
|
|
120
|
+
/** E §1.3 P1 ②: the `recovery` block of the queried session (never a new endpoint). */
|
|
121
|
+
function recoveryBlockOf(deps, session) {
|
|
122
|
+
return { ...(deps.runtime.recoveryView?.(session) ?? emptyRecoveryView(session)) };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* W785: the optional `cost` key (see the header). `{}` = no ledger / no estimate,
|
|
126
|
+
* which keeps the field truly optional instead of a `cost: null` placeholder.
|
|
127
|
+
*/
|
|
128
|
+
function costField(deps, session) {
|
|
129
|
+
const cost = deps.runtime.costBlock?.(session);
|
|
130
|
+
return cost === undefined || cost === null ? {} : { cost };
|
|
131
|
+
}
|
|
132
|
+
/** Cap names in force for the session (never paths) — §5.7. */
|
|
133
|
+
function activeGrantCaps(deps, session) {
|
|
134
|
+
const resolved = session === null ? null : deps.sessions.resolve(session);
|
|
135
|
+
if (resolved === null || !resolved.ok)
|
|
136
|
+
return [];
|
|
137
|
+
const dir = resolved.value.dir;
|
|
138
|
+
return grantsActiveCaps(effectiveGrantsOf(dir, resolved.value.id, deps.grants.env, nowSec(deps.grants)).grants);
|
|
139
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler registry — the ONLY place that knows every endpoint group.
|
|
3
|
+
*
|
|
4
|
+
* Each `registerXxx` returns the contract ids it bound, and `app.ts` asserts
|
|
5
|
+
* the union equals the frozen 47. A route can therefore never be silently
|
|
6
|
+
* dropped: adding an endpoint to `contracts/endpoints.json` without a handler
|
|
7
|
+
* fails at startup with the missing id.
|
|
8
|
+
*
|
|
9
|
+
* Module map:
|
|
10
|
+
* common.ts error/body/field helpers shared by every handler
|
|
11
|
+
* config-shape.ts the /api/config body assembled from live stores
|
|
12
|
+
* health.ts GET /api/health | /api/status | /api/tools
|
|
13
|
+
* dialog.ts GET /api/events (SSE) | POST /api/turn | /api/cancel | /api/clear
|
|
14
|
+
* config.ts GET+POST /api/config
|
|
15
|
+
* sessions.ts GET+POST /api/sessions | {id}/messages | {id}/activate | {id}/context
|
|
16
|
+
* context-shape.ts the context snapshot body + the 20k-per-entry wire guard
|
|
17
|
+
* session-move.ts {id}/rename | {id}/branch | {id}/compact | archive | unarchive | batch-*
|
|
18
|
+
* workspaces.ts /api/workspaces (+rename/delete/batch-delete)
|
|
19
|
+
* fs.ts GET /api/fs/browse | GET /api/fs/list
|
|
20
|
+
* exec.ts G2: POST /api/exec (immediate shell, permission-gated)
|
|
21
|
+
* providers.ts /api/providers (+delete/test/models fetch/default)
|
|
22
|
+
* prompts.ts /api/prompts (+delete/default)
|
|
23
|
+
* worker.ts /api/worker/spawn | send | status
|
|
24
|
+
* grants.ts GET+POST+DELETE /api/sessions/{id}/grants | grants/confirm-token
|
|
25
|
+
* questions.ts W783: GET /api/questions | POST /api/questions/{id}/answer
|
|
26
|
+
* usage.ts W785: GET /api/usage/ledger (the ledger's aggregate view)
|
|
27
|
+
* permissions.ts W9: /api/permissions/presets (+{id}) | /api/sessions/{id}/permission
|
|
28
|
+
* session-tools.ts W860: GET+PUT /api/sessions/{id}/tools (the session's disabled list)
|
|
29
|
+
* session-model.ts W870: PUT /api/sessions/{id}/model (the session-level model switch)
|
|
30
|
+
* plugins.ts W860: GET /api/plugins (the host startup plugin inventory)
|
|
31
|
+
* auth.ts W767: GET /login | POST /auth/login | GET /auth/check
|
|
32
|
+
*
|
|
33
|
+
* W725: the context endpoint (44th) lives in sessions.ts; its shaping is in
|
|
34
|
+
* context-shape.ts.
|
|
35
|
+
*/
|
|
36
|
+
import type { Hono } from "hono";
|
|
37
|
+
import type { RouteTable } from "../routes.js";
|
|
38
|
+
import type { Deps } from "./common.js";
|
|
39
|
+
export declare function registerHandlers(app: Hono, deps: Deps, table: RouteTable): string[];
|
|
40
|
+
export type { Deps } from "./common.js";
|