@unifedev/thread-pages 0.3.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
@@ -0,0 +1,277 @@
1
+ import { defaultTreeAdapter, parse as parseHtml, serialize as serializeHtml, type DefaultTreeAdapterTypes } from "parse5";
2
+ import { LIMITS } from "../domain/limits.ts";
3
+ import { isSafeRelativePath } from "./layout.ts";
4
+
5
+ /**
6
+ * Resolving a page's own files into its entry document. spec R1.2, R1.3, R4.25–R4.27
7
+ *
8
+ * A page frame is sandboxed, so it has an opaque origin, so every subresource
9
+ * it requests is cross-site and carries no host credential. A bb served over
10
+ * loopback needs none and the file arrives; a bb reached through an
11
+ * authenticated origin refuses it. The page renders, its own stylesheet and
12
+ * data do not, nothing errors, and the author cannot see any of this from the
13
+ * machine that wrote the page.
14
+ *
15
+ * So the entry document — the one artifact whose request is issued by trusted
16
+ * chrome and therefore always authorised — carries the page's own files with
17
+ * it. Each relative reference is rewritten to a `data:` URL, which the
18
+ * document's CSP already permits. Rewriting the attribute rather than moving
19
+ * the bytes into the element keeps every other attribute meaningful (`defer`,
20
+ * `type="module"`, `media`, `loading`) and avoids the escaping traps that
21
+ * inlining raw text into `<script>` and `<style>` carries.
22
+ *
23
+ * THIS IS A WORKAROUND AND IT SHOULD BE DELETED. It exists only because the
24
+ * host cannot authorise a sandboxed document's own subresource requests. Once
25
+ * it can — see docs/B1-OWN-FILES.md — a page's files should be served as
26
+ * files again, and this module and its wiring should go.
27
+ */
28
+
29
+ type HtmlElement = DefaultTreeAdapterTypes.Element;
30
+ type HtmlNode = DefaultTreeAdapterTypes.ChildNode;
31
+
32
+ export interface OwnFile {
33
+ readonly bytes: Uint8Array;
34
+ readonly mimeType?: string | undefined;
35
+ }
36
+
37
+ export type OwnFileReader = (relativePath: string) => Promise<OwnFile | null>;
38
+
39
+ export interface ResolvedFile {
40
+ readonly path: string;
41
+ readonly bytes: number;
42
+ }
43
+
44
+ export interface SkippedFile {
45
+ readonly path: string;
46
+ readonly reason: "missing" | "too-large" | "budget" | "unsafe-path";
47
+ }
48
+
49
+ export interface ResolveOutcome {
50
+ readonly html: string;
51
+ readonly resolved: readonly ResolvedFile[];
52
+ readonly skipped: readonly SkippedFile[];
53
+ }
54
+
55
+ /** Attributes that may name one of the page's own files, per element. */
56
+ const CARRIERS: ReadonlyArray<{ tag: string; attr: string; test?: (element: HtmlElement) => boolean }> = [
57
+ { tag: "link", attr: "href", test: (element) => relOf(element).some((rel) => rel === "stylesheet" || rel === "icon" || rel === "shortcut" || rel === "apple-touch-icon" || rel === "preload") },
58
+ { tag: "script", attr: "src" },
59
+ { tag: "img", attr: "src" },
60
+ { tag: "source", attr: "src" },
61
+ { tag: "audio", attr: "src" },
62
+ { tag: "video", attr: "src" },
63
+ { tag: "video", attr: "poster" },
64
+ { tag: "track", attr: "src" },
65
+ ];
66
+
67
+ function relOf(element: HtmlElement): string[] {
68
+ const rel = attributeOf(element, "rel") ?? "";
69
+ return rel.toLowerCase().split(/\s+/).filter(Boolean);
70
+ }
71
+
72
+ function attributeOf(element: HtmlElement, name: string): string | null {
73
+ return element.attrs.find((attr) => attr.name === name)?.value ?? null;
74
+ }
75
+
76
+ function setAttribute(element: HtmlElement, name: string, value: string): void {
77
+ const existing = element.attrs.find((attr) => attr.name === name);
78
+ if (existing) existing.value = value;
79
+ else element.attrs.push({ name, value });
80
+ }
81
+
82
+ /**
83
+ * True for a reference that names a file inside this page's own root. Anything
84
+ * absolute, protocol-relative, already a URL, or a bare fragment belongs to
85
+ * somebody else and is left exactly as the author wrote it.
86
+ */
87
+ export function isOwnFileReference(value: string): boolean {
88
+ const trimmed = value.trim();
89
+ if (trimmed.length === 0) return false;
90
+ if (trimmed.startsWith("#") || trimmed.startsWith("/") || trimmed.startsWith("//")) return false;
91
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return false;
92
+ return true;
93
+ }
94
+
95
+ /** The reference's path, with any query or fragment removed and percent-escapes resolved. */
96
+ function pathOf(reference: string): string | null {
97
+ const withoutHash = reference.trim().split("#")[0] ?? "";
98
+ const withoutQuery = withoutHash.split("?")[0] ?? "";
99
+ if (withoutQuery.length === 0) return null;
100
+ try {
101
+ return decodeURIComponent(withoutQuery);
102
+ } catch {
103
+ return withoutQuery;
104
+ }
105
+ }
106
+
107
+ function mimeFor(path: string, declared: string | undefined): string {
108
+ if (declared && declared.length > 0) return declared;
109
+ const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
110
+ return EXTENSION_TYPES[extension] ?? "application/octet-stream";
111
+ }
112
+
113
+ const EXTENSION_TYPES: Readonly<Record<string, string>> = Object.freeze({
114
+ css: "text/css",
115
+ js: "text/javascript",
116
+ mjs: "text/javascript",
117
+ json: "application/json",
118
+ svg: "image/svg+xml",
119
+ png: "image/png",
120
+ jpg: "image/jpeg",
121
+ jpeg: "image/jpeg",
122
+ gif: "image/gif",
123
+ webp: "image/webp",
124
+ avif: "image/avif",
125
+ ico: "image/x-icon",
126
+ woff: "font/woff",
127
+ woff2: "font/woff2",
128
+ ttf: "font/ttf",
129
+ otf: "font/otf",
130
+ mp4: "video/mp4",
131
+ webm: "video/webm",
132
+ mp3: "audio/mpeg",
133
+ wav: "audio/wav",
134
+ vtt: "text/vtt",
135
+ });
136
+
137
+ function dataUrl(bytes: Uint8Array, mimeType: string): string {
138
+ return `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`;
139
+ }
140
+
141
+ /** `url(...)` references inside an inlined stylesheet, which would otherwise resolve nowhere. */
142
+ const CSS_URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
143
+
144
+ /**
145
+ * Rewrites every reference to one of the page's own files into a `data:` URL,
146
+ * one level deep inside stylesheets as well. Anything that cannot be resolved
147
+ * is left untouched and reported, so the page degrades to exactly the
148
+ * behaviour it has without this pass rather than to a broken document.
149
+ */
150
+ export async function resolveOwnFiles(html: string, read: OwnFileReader): Promise<ResolveOutcome> {
151
+ const document = parseHtml(html);
152
+ const resolved: ResolvedFile[] = [];
153
+ const skipped: SkippedFile[] = [];
154
+ const seen = new Map<string, string | null>();
155
+ let budget = LIMITS.inlineTotalBytes;
156
+
157
+ async function urlFor(path: string, depth: number): Promise<string | null> {
158
+ const memo = seen.get(path);
159
+ if (memo !== undefined) return memo;
160
+ const answer = await load(path, depth);
161
+ seen.set(path, answer);
162
+ return answer;
163
+ }
164
+
165
+ async function load(path: string, depth: number): Promise<string | null> {
166
+ if (!isSafeRelativePath(path)) {
167
+ skipped.push({ path, reason: "unsafe-path" });
168
+ return null;
169
+ }
170
+ const file = await read(path).catch(() => null);
171
+ if (!file) {
172
+ skipped.push({ path, reason: "missing" });
173
+ return null;
174
+ }
175
+ if (file.bytes.byteLength > LIMITS.inlineFileBytes) {
176
+ skipped.push({ path, reason: "too-large" });
177
+ return null;
178
+ }
179
+ if (file.bytes.byteLength > budget) {
180
+ skipped.push({ path, reason: "budget" });
181
+ return null;
182
+ }
183
+ budget -= file.bytes.byteLength;
184
+ const mimeType = mimeFor(path, file.mimeType);
185
+ const bytes = mimeType === "text/css" && depth < LIMITS.inlineCssDepth ? Buffer.from(await resolveCss(Buffer.from(file.bytes).toString("utf8"), path, depth), "utf8") : file.bytes;
186
+ resolved.push({ path, bytes: file.bytes.byteLength });
187
+ return dataUrl(bytes, mimeType);
188
+ }
189
+
190
+ /** A stylesheet becomes a `data:` URL, so its own relative `url()`s must be resolved first. */
191
+ async function resolveCss(css: string, from: string, depth: number): Promise<string> {
192
+ const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/") + 1) : "";
193
+ const replacements = new Map<string, string>();
194
+ for (const match of css.matchAll(CSS_URL)) {
195
+ const reference = match[2] ?? "";
196
+ if (!isOwnFileReference(reference) || replacements.has(reference)) continue;
197
+ const path = pathOf(reference);
198
+ if (!path) continue;
199
+ const url = await urlFor(normalise(base + path), depth + 1);
200
+ if (url) replacements.set(reference, url);
201
+ }
202
+ if (replacements.size === 0) return css;
203
+ return css.replace(CSS_URL, (whole, quote: string, reference: string) => {
204
+ const url = replacements.get(reference);
205
+ return url ? `url(${quote}${url}${quote})` : whole;
206
+ });
207
+ }
208
+
209
+ const elements: HtmlElement[] = [];
210
+ const walk = (node: HtmlNode | DefaultTreeAdapterTypes.Document): void => {
211
+ if (defaultTreeAdapter.isElementNode(node as HtmlNode)) elements.push(node as HtmlElement);
212
+ for (const child of (node as { childNodes?: HtmlNode[] }).childNodes ?? []) walk(child);
213
+ };
214
+ walk(document);
215
+
216
+ let changed = false;
217
+ for (const element of elements) {
218
+ for (const carrier of CARRIERS) {
219
+ if (element.tagName !== carrier.tag) continue;
220
+ if (carrier.test && !carrier.test(element)) continue;
221
+ const reference = attributeOf(element, carrier.attr);
222
+ if (reference === null || !isOwnFileReference(reference)) continue;
223
+ const path = pathOf(reference);
224
+ if (!path) continue;
225
+ const url = await urlFor(normalise(path), 0);
226
+ if (!url) continue;
227
+ setAttribute(element, carrier.attr, url);
228
+ changed = true;
229
+ }
230
+ // srcset carries several candidates; all or nothing keeps the parsing simple.
231
+ if (element.tagName === "img" || element.tagName === "source") {
232
+ const srcset = attributeOf(element, "srcset");
233
+ if (srcset !== null) {
234
+ const rewritten = await resolveSrcset(srcset, urlFor);
235
+ if (rewritten !== null) {
236
+ setAttribute(element, "srcset", rewritten);
237
+ changed = true;
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ return { html: changed ? serializeHtml(document) : html, resolved, skipped };
244
+ }
245
+
246
+ async function resolveSrcset(srcset: string, urlFor: (path: string, depth: number) => Promise<string | null>): Promise<string | null> {
247
+ const candidates = srcset.split(",").map((entry) => entry.trim()).filter(Boolean);
248
+ const rewritten: string[] = [];
249
+ let changed = false;
250
+ for (const candidate of candidates) {
251
+ const [reference, ...descriptor] = candidate.split(/\s+/);
252
+ if (!reference || !isOwnFileReference(reference)) {
253
+ rewritten.push(candidate);
254
+ continue;
255
+ }
256
+ const path = pathOf(reference);
257
+ const url = path ? await urlFor(normalise(path), 0) : null;
258
+ if (!url) {
259
+ rewritten.push(candidate);
260
+ continue;
261
+ }
262
+ changed = true;
263
+ rewritten.push([url, ...descriptor].join(" "));
264
+ }
265
+ return changed ? rewritten.join(", ") : null;
266
+ }
267
+
268
+ /** Collapses `a/./b` and `a/b/../c`; a path that climbs out fails `isSafeRelativePath` later. */
269
+ function normalise(path: string): string {
270
+ const out: string[] = [];
271
+ for (const segment of path.split("/")) {
272
+ if (segment === "" || segment === ".") continue;
273
+ if (segment === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
274
+ else out.push(segment);
275
+ }
276
+ return out.join("/");
277
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Where a page lives. spec 01 §Storage, RW-3
3
+ *
4
+ * The page root is the session's storage directory itself. The entry document
5
+ * is `index.html`; reader uploads land in `uploads/` under host-generated
6
+ * names. Everything else in the root is the page's own site.
7
+ */
8
+ export const ENTRY_FILE = "index.html";
9
+ export const UPLOAD_DIR = "uploads";
10
+ /** The prototype's entry file, recognised only to tell an agent about it. */
11
+ export const LEGACY_ENTRY_FILE = "thread-page.html";
12
+
13
+ const UPLOAD_NAME = /^[0-9]{8}-[0-9]{6}-[a-f0-9]{6}-[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
14
+
15
+ export function joinPath(root: string, ...segments: string[]): string {
16
+ const base = root.replace(/[\\/]+$/, "");
17
+ return [base, ...segments].join("/");
18
+ }
19
+
20
+ export function entryPath(root: string): string {
21
+ return joinPath(root, ENTRY_FILE);
22
+ }
23
+
24
+ export function legacyEntryPath(root: string): string {
25
+ return joinPath(root, LEGACY_ENTRY_FILE);
26
+ }
27
+
28
+ export function uploadPath(root: string, name: string): string {
29
+ if (!isSafeUploadName(name)) throw new Error("Unsafe upload name");
30
+ return joinPath(root, UPLOAD_DIR, name);
31
+ }
32
+
33
+ /** The reader's filename reduced to a safe suffix; the host chooses the rest. spec R4.21 */
34
+ export function sanitizeUploadSuffix(raw: string): string {
35
+ let decoded = raw;
36
+ try {
37
+ decoded = decodeURIComponent(raw);
38
+ } catch {
39
+ decoded = raw;
40
+ }
41
+ const base = decoded.split(/[\\/]/).pop() ?? "";
42
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^[._-]+/, "");
43
+ return cleaned.slice(0, 80) || "upload";
44
+ }
45
+
46
+ /** `YYYYMMDD-HHMMSS-<6 hex>-<suffix>`; never taken from the reader. spec R4.21 */
47
+ export function uploadFileName(originalName: string, now: number, randomHex: string): string {
48
+ const stamp = new Date(now).toISOString().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "-");
49
+ const name = `${stamp}-${randomHex.slice(0, 6)}-${sanitizeUploadSuffix(originalName)}`;
50
+ if (!isSafeUploadName(name)) throw new Error("Generated upload name is invalid");
51
+ return name;
52
+ }
53
+
54
+ export function isSafeUploadName(name: string): boolean {
55
+ return UPLOAD_NAME.test(name) && !name.includes("..");
56
+ }
57
+
58
+ /**
59
+ * A relative path inside the page root: no absolute paths, no `.`/`..`/empty
60
+ * segments, no backslashes, no NUL. spec R1.4
61
+ */
62
+ export function isSafeRelativePath(path: string): boolean {
63
+ if (path.length === 0 || path.length > 1024 || path.includes("\0") || path.includes("\\") || path.startsWith("/")) return false;
64
+ return path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
65
+ }
@@ -0,0 +1,170 @@
1
+ import { PageError, PUBLIC_MESSAGES, errorText } from "../domain/errors.ts";
2
+ import { isRevision } from "../domain/ids.ts";
3
+ import { LIMITS } from "../domain/limits.ts";
4
+ import { revisionOf } from "../domain/revision.ts";
5
+ import type { SessionHost } from "../host/contract.ts";
6
+ import type { ResolveOutcome } from "./inline.ts";
7
+ import { ENTRY_FILE } from "./layout.ts";
8
+
9
+ /**
10
+ * Loads a page's entry document, bounds it, computes its revision, and keeps
11
+ * a last-known-good copy so the page still opens read-only when its source
12
+ * host is unreachable. spec R1.7, R2.11, R2.27–R2.30
13
+ */
14
+ export interface LoadedPage {
15
+ readonly html: string;
16
+ readonly revision: string;
17
+ readonly updatedAtMs: number;
18
+ /** True when served from the offline copy. */
19
+ readonly stale: boolean;
20
+ /** Own files carried into the document, and those that could not be. */
21
+ readonly site: { readonly resolved: number; readonly skipped: readonly { path: string; reason: string }[] };
22
+ }
23
+
24
+ interface CachedPage {
25
+ readonly html: string;
26
+ readonly revision: string;
27
+ readonly updatedAtMs: number;
28
+ }
29
+
30
+ /**
31
+ * Resolves a page's own files into its entry document so it renders on an
32
+ * origin that will not authorise the sandbox's subresource requests.
33
+ * Temporary; see pages/inline.ts. spec R1.2, R4.25-R4.27
34
+ */
35
+ export type PageResolver = (session: string, html: string) => Promise<ResolveOutcome>;
36
+
37
+ export interface PageStore {
38
+ load(session: string): Promise<LoadedPage>;
39
+ /** Records a document the plugin just wrote, so the next load is warm. */
40
+ remember(session: string, html: string): Promise<CachedPage>;
41
+ /** The revision last seen for a session, without touching the host. */
42
+ knownRevision(session: string): string | null;
43
+ }
44
+
45
+ const KV_PREFIX = "cache:";
46
+
47
+ export function createPageStore(host: SessionHost, resolve?: PageResolver): PageStore {
48
+ const memory = new Map<string, CachedPage>();
49
+ let memoryBytes = 0;
50
+
51
+ function cost(page: CachedPage): number {
52
+ return Buffer.byteLength(page.html, "utf8") + 128;
53
+ }
54
+
55
+ function retain(session: string, page: CachedPage): void {
56
+ const previous = memory.get(session);
57
+ if (previous) {
58
+ memoryBytes -= cost(previous);
59
+ memory.delete(session);
60
+ }
61
+ memory.set(session, page);
62
+ memoryBytes += cost(page);
63
+ while (memory.size > LIMITS.offlineCacheEntries || memoryBytes > LIMITS.offlineCacheBytes) {
64
+ const oldest = memory.keys().next().value;
65
+ if (oldest === undefined) break;
66
+ const evicted = memory.get(oldest);
67
+ memory.delete(oldest);
68
+ if (evicted) memoryBytes -= cost(evicted);
69
+ }
70
+ }
71
+
72
+ async function persist(session: string, page: CachedPage, previousRevision: string | undefined): Promise<void> {
73
+ if (previousRevision === page.revision) return;
74
+ const key = KV_PREFIX + session;
75
+ const bytes = Buffer.byteLength(page.html, "utf8");
76
+ if (bytes > LIMITS.offlineCopyBytes) {
77
+ // Silently dropping this is how a page stops opening offline with no
78
+ // author ever learning why. Carrying a page's own files into the
79
+ // document makes it much easier to cross. spec R2.27-R2.31
80
+ host.log.warn(
81
+ `offline copy: ${session} is ${Math.round(bytes / 1024)} KiB, over the ${LIMITS.offlineCopyBytes / 1024} KiB limit — ` +
82
+ "the page will not open while its host is unreachable",
83
+ );
84
+ await host.kv.delete(key).catch((error: unknown) => host.log.warn(`offline copy: could not clear ${session}: ${errorText(error)}`));
85
+ return;
86
+ }
87
+ await host.kv.set(key, { html: page.html, revision: page.revision, updatedAtMs: page.updatedAtMs }).catch((error: unknown) => {
88
+ host.log.warn(`offline copy: could not store ${session}: ${errorText(error)}`);
89
+ });
90
+ }
91
+
92
+ async function cached(session: string): Promise<CachedPage | null> {
93
+ const resident = memory.get(session);
94
+ if (resident) return resident;
95
+ try {
96
+ const stored = await host.kv.get(KV_PREFIX + session);
97
+ if (!isCachedPage(stored)) return null;
98
+ retain(session, stored);
99
+ return stored;
100
+ } catch (error) {
101
+ host.log.warn(`offline copy: could not read ${session}: ${errorText(error)}`);
102
+ return null;
103
+ }
104
+ }
105
+
106
+ async function remember(session: string, html: string, updatedAtMs = Date.now()): Promise<CachedPage> {
107
+ const page: CachedPage = { html, revision: revisionOf(html), updatedAtMs };
108
+ const previous = memory.get(session)?.revision;
109
+ retain(session, page);
110
+ await persist(session, page, previous);
111
+ return page;
112
+ }
113
+
114
+ return {
115
+ async load(session) {
116
+ let content;
117
+ try {
118
+ const location = await host.sessions.storage(session);
119
+ content = await host.files.read(location, ENTRY_FILE);
120
+ } catch (error) {
121
+ const fallback = await cached(session);
122
+ if (fallback) return { ...fallback, stale: true, site: { resolved: 0, skipped: [] } };
123
+ throw PageError.is(error) ? error : new PageError("unavailable", PUBLIC_MESSAGES.unavailable, { cause: error });
124
+ }
125
+ if (!content) throw new PageError("no_page", PUBLIC_MESSAGES.noPage);
126
+ if (content.bytes.byteLength > LIMITS.entryDocumentBytes) {
127
+ throw new PageError("page_too_large", PUBLIC_MESSAGES.pageTooLarge);
128
+ }
129
+ const authored = Buffer.from(content.bytes).toString("utf8");
130
+ let html = authored;
131
+ let site: LoadedPage["site"] = { resolved: 0, skipped: [] };
132
+ if (resolve) {
133
+ try {
134
+ const outcome = await resolve(session, authored);
135
+ html = outcome.html;
136
+ site = { resolved: outcome.resolved.length, skipped: outcome.skipped };
137
+ for (const file of outcome.skipped) {
138
+ host.log.warn(`page ${session}: ${file.path} is referenced but was not carried into the document (${file.reason})`);
139
+ }
140
+ } catch (error) {
141
+ // A page that renders without its own files beats a page that does
142
+ // not render. Serve what the agent wrote.
143
+ host.log.warn(`page ${session}: could not resolve its own files: ${errorText(error)}`);
144
+ }
145
+ }
146
+ const page: CachedPage = { html, revision: revisionOf(html), updatedAtMs: content.modifiedAtMs ?? Date.now() };
147
+ const previous = memory.get(session)?.revision;
148
+ retain(session, page);
149
+ await persist(session, page, previous);
150
+ return { ...page, stale: false, site };
151
+ },
152
+ remember,
153
+ knownRevision(session) {
154
+ return memory.get(session)?.revision ?? null;
155
+ },
156
+ };
157
+ }
158
+
159
+ function isCachedPage(value: unknown): value is CachedPage {
160
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
161
+ const entry = value as Record<string, unknown>;
162
+ return (
163
+ typeof entry.html === "string" &&
164
+ Buffer.byteLength(entry.html, "utf8") <= LIMITS.offlineCopyBytes &&
165
+ isRevision(entry.revision) &&
166
+ revisionOf(entry.html) === entry.revision &&
167
+ typeof entry.updatedAtMs === "number" &&
168
+ Number.isFinite(entry.updatedAtMs)
169
+ );
170
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How a page's site reaches the reader. spec R1.2, R1.3; rewrite RW-1
3
+ *
4
+ * The document is always served by this plugin (it needs the kernel). Where
5
+ * its files come from depends on what the host's router can do:
6
+ *
7
+ * - `core-storage`: the host already serves the session's storage as a site
8
+ * at a stable path; the document gets one same-origin `<base>` so relative
9
+ * references resolve there. This is what bb 0.42.1 allows.
10
+ * - `plugin-prefix`: the plugin serves `/page/<id>/…` itself; the document
11
+ * URL is path-shaped and no base is needed. Available once the host's
12
+ * plugin router matches prefixes.
13
+ */
14
+ export interface SiteStrategy {
15
+ readonly name: "core-storage" | "plugin-prefix";
16
+ /** Origin-relative URL the shell loads into the iframe. */
17
+ documentUrl(session: string): string;
18
+ /** Same-origin `<base href>` to inject, or null when the document URL is path-shaped. */
19
+ baseHref(session: string): string | null;
20
+ }
21
+
22
+ export function createCoreStorageSite(routeBase: string, storageFilesBase: (session: string) => string): SiteStrategy {
23
+ return {
24
+ name: "core-storage",
25
+ documentUrl: (session) => `${routeBase}/document?session=${encodeURIComponent(session)}`,
26
+ baseHref: (session) => storageFilesBase(session),
27
+ };
28
+ }
29
+
30
+ export function createPluginPrefixSite(routeBase: string): SiteStrategy {
31
+ return {
32
+ name: "plugin-prefix",
33
+ documentUrl: (session) => `${routeBase}/page/${encodeURIComponent(session)}/`,
34
+ baseHref: () => null,
35
+ };
36
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { BbPluginApi } from "@get-bb/plugin-sdk";
2
+ import { registerCli } from "./agent/cli.ts";
3
+ import { buildGuide } from "./agent/guide.ts";
4
+ import { createBbHost } from "./bb/bb-host.ts";
5
+ import { defineSettings } from "./config/settings.ts";
6
+ import { capabilityRegistry } from "./domain/capabilities/index.ts";
7
+ import { createRateLimiter } from "./domain/rate-limit.ts";
8
+ import { createOutcomeMemory } from "./domain/submissions/idempotency.ts";
9
+ import type { SessionHost } from "./host/contract.ts";
10
+ import { resolveOwnFiles } from "./pages/inline.ts";
11
+ import { createPageStore } from "./pages/page-store.ts";
12
+ import { createCoreStorageSite, type SiteStrategy } from "./pages/site.ts";
13
+ import { createSelectionStore } from "./serving/bridge/selection-store.ts";
14
+ import type { ServingContext } from "./serving/context.ts";
15
+ import { registerRoutes } from "./serving/routes.ts";
16
+ import { loadSigningKey } from "./serving/signing-key.ts";
17
+
18
+ /**
19
+ * The composition root: the only file that knows every package. Builds the
20
+ * host adapter, the stores and the serving context, then registers routes,
21
+ * the CLI and the agent-instruction hook.
22
+ */
23
+ export interface PluginOptions {
24
+ /** Override the host (tests). */
25
+ host?: SessionHost;
26
+ /** Override the site strategy (tests, or a host with prefix routes). */
27
+ site?: (routeBase: string) => SiteStrategy;
28
+ now?: () => number;
29
+ }
30
+
31
+ export async function createPlugin(bb: BbPluginApi, options: PluginOptions = {}): Promise<ServingContext> {
32
+ const settings = await defineSettings(bb);
33
+ const host = options.host ?? createBbHost(bb);
34
+ const signingKey = await loadSigningKey(host);
35
+ const routeBase = `/api/v1/plugins/${bb.pluginId}/http`;
36
+ const site = options.site
37
+ ? options.site(routeBase)
38
+ : createCoreStorageSite(routeBase, (session) => `/api/v1/threads/${encodeURIComponent(session)}/thread-storage/files/`);
39
+
40
+ const serving: ServingContext = {
41
+ host,
42
+ // Strategy A cannot serve a sandboxed document's own files on an
43
+ // authenticated origin, so the document carries them. Delete this
44
+ // argument, and pages/inline.ts, once the host can authorise them.
45
+ pages: createPageStore(host, async (session, html) => {
46
+ const location = await host.sessions.storage(session);
47
+ return resolveOwnFiles(html, async (path) => {
48
+ const file = await host.files.read(location, path);
49
+ return file ? { bytes: file.bytes } : null;
50
+ });
51
+ }),
52
+ settings,
53
+ signingKey,
54
+ site,
55
+ routeBase,
56
+ registry: capabilityRegistry,
57
+ rate: createRateLimiter(),
58
+ submissions: createOutcomeMemory(),
59
+ replies: createOutcomeMemory(),
60
+ selections: createSelectionStore(),
61
+ hostSessionUrl: (session) => `/threads/${encodeURIComponent(session)}`,
62
+ now: options.now ?? (() => Date.now()),
63
+ };
64
+
65
+ const effectiveInstruction = (): string | null => {
66
+ const current = settings.current();
67
+ return current.agentInstructions && current.agentInstructionText.trim() ? current.agentInstructionText : null;
68
+ };
69
+
70
+ // The standing instruction: only eligible sessions, only when enabled.
71
+ // Visibility is not known here, so `init` rechecks eligibility at call time. spec R6.14
72
+ bb.agents.configure((context) => {
73
+ const instruction = effectiveInstruction();
74
+ const root = context.thread.parentThreadId === null && context.thread.sourceThreadId === null && context.origin.kind === null;
75
+ return instruction && root ? { tools: [], skills: [], instructions: instruction } : { tools: [], skills: [] };
76
+ });
77
+
78
+ registerRoutes(bb, serving);
79
+ registerCli(bb, { serving, guide: buildGuide(capabilityRegistry, site), effectiveInstruction });
80
+ return serving;
81
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Authored links work. spec R4.15
3
+ *
4
+ * The sandbox has no top-level navigation, so an `<a href>` to another site
5
+ * would silently do nothing (or replace the page inside the frame). The
6
+ * kernel routes http(s) destinations through `navigation.openExternal`,
7
+ * leaves same-document fragments and the page's own files to the browser,
8
+ * and swallows schemes the sandbox cannot honour.
9
+ */
10
+ export type AnchorDecision = { kind: "default" } | { kind: "external"; url: string; label: string } | { kind: "block" };
11
+
12
+ export function decideAnchor(anchor: HTMLAnchorElement, documentUrl: string, siteBase: string | null): AnchorDecision {
13
+ const raw = anchor.getAttribute("href");
14
+ if (raw === null) return { kind: "default" };
15
+ if (raw.startsWith("#")) return { kind: "default" };
16
+ let target: URL;
17
+ try {
18
+ target = new URL(raw, siteBase ?? documentUrl);
19
+ } catch {
20
+ return { kind: "block" };
21
+ }
22
+ if (target.protocol !== "http:" && target.protocol !== "https:") return { kind: "block" };
23
+ if (siteBase && target.href.startsWith(siteBase)) return { kind: "default" };
24
+ if (anchor.hasAttribute("download")) return { kind: "default" };
25
+ return { kind: "external", url: target.href, label: (anchor.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160) };
26
+ }
27
+
28
+ export function installAnchorInterception(doc: Document, open: (url: string, label: string) => void): void {
29
+ doc.addEventListener(
30
+ "click",
31
+ (event) => {
32
+ if (event.defaultPrevented || event.button !== 0) return;
33
+ const target = event.target as Element | null;
34
+ const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
35
+ if (!anchor) return;
36
+ const base = doc.querySelector("base")?.getAttribute("href") ?? null;
37
+ const siteBase = base ? new URL(base, doc.baseURI).href : null;
38
+ const decision = decideAnchor(anchor, doc.baseURI, siteBase);
39
+ if (decision.kind === "default") return;
40
+ event.preventDefault();
41
+ if (decision.kind === "external") open(decision.url, decision.label);
42
+ },
43
+ true,
44
+ );
45
+ }