@huynle/hub 0.8.7 → 0.9.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/CHANGELOG.md +152 -0
- package/README.md +75 -16
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1413 -523
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +59 -6
- package/packages/protocol/src/launch.ts +46 -6
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +22 -3
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
- package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-CcjZXsfF.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-LNMZCqgE.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-ycZ_aQZO.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// (Session media §3, item 4b) Local HTML previews: the page an assistant message names and the files it refers to,
|
|
2
|
+
// collected under the media path policy (media-policy.ts: the same open-first, realpath, dev/ino and /proc checks as
|
|
3
|
+
// for images) for the previewBundle op (packages/protocol/src/preview-bundle.ts).
|
|
4
|
+
//
|
|
5
|
+
// - The page must be a target of the message's text (markdownHtmlPaths: code spans and same-site path links ending in
|
|
6
|
+
// .html/.htm, read like image sources: the first 64 KiB up to a blank line, never in indented or fenced code).
|
|
7
|
+
// - References followed (relative ones only, resolved against the file that has them; query and fragment dropped):
|
|
8
|
+
// in HTML `src`/`href`/`srcset` of script, link rel=stylesheet, img, source, video and audio, CSS in <style> and
|
|
9
|
+
// style attributes; in CSS `url()` and `@import` (imported sheets are read in turn; each file once, so @import loops
|
|
10
|
+
// end). JS module imports are not followed. Remote and data: URLs and fragments are left alone (the host's CSP
|
|
11
|
+
// blocks remote ones); absolute paths (`/x`, `//host`) and files the policy refuses are skipped.
|
|
12
|
+
// - Caps (PREVIEW_BUNDLE_LIMITS): ≤ 200 files, ≤ 5 MiB each, ≤ 20 MiB in all. A page over 5 MiB is tooLarge; an asset
|
|
13
|
+
// over a cap is skipped with its reason, as is a file of a type the artifact store does not take (previewMediaType).
|
|
14
|
+
// At most 2 × the file cap references are looked up on disk per walk (the rest: "limit"), and the touched set is
|
|
15
|
+
// resolved once per walk (resolvedPolicy). A file is reported "missing" only where the policy would allow it
|
|
16
|
+
// (missingAllowed); outside it every failure reads "refused", so a preview never tells whether such a file exists.
|
|
17
|
+
// - Layout: files keep their places relative to each other. Bundle paths are relative to the deepest directory that
|
|
18
|
+
// holds them all, and must be artifact paths (letters, digits, `._-/`, ≤ 200): others are skipped ("name").
|
|
19
|
+
//
|
|
20
|
+
// The scanners are tolerant (no DOM) and linear-time: every step moves forward, and every search starts where the
|
|
21
|
+
// last one ended (test/spoke-preview-bundle.test.ts has the adversarial inputs).
|
|
22
|
+
import { createHash } from "node:crypto"
|
|
23
|
+
import { basename, dirname, relative, resolve, sep } from "node:path"
|
|
24
|
+
import { parseMarkdown } from "../../packages/protocol/src/markdown.ts"
|
|
25
|
+
import { htmlTargets, isBundlePath, PREVIEW_BUNDLE_LIMITS, previewMediaType, type PreviewBundleFile, type PreviewBundleResult, type PreviewBundleSkip, type PreviewSkipReason } from "../protocol"
|
|
26
|
+
import type { MediaMessages } from "./media"
|
|
27
|
+
import { missingAllowed, readAllowedPath, resolvedPolicy, scannedPart, sourcePath, touchedPaths, withoutIndentedCode, type PolicyContext } from "./media-policy"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The local HTML pages a message's text names (htmlTargets), read like its image sources: the first 64 KiB up to its
|
|
31
|
+
* last blank line, and only targets that are still there with indented code left out.
|
|
32
|
+
*/
|
|
33
|
+
export function markdownHtmlPaths(markdown: string): string[] {
|
|
34
|
+
const text = scannedPart(markdown)
|
|
35
|
+
if (!/\.htm/i.test(text)) return []
|
|
36
|
+
const outsideIndented = new Set(htmlTargets(parseMarkdown(withoutIndentedCode(text))))
|
|
37
|
+
return htmlTargets(parseMarkdown(text)).filter((t) => outsideIndented.has(t))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- scanners -------------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** A reference found in a file: `css` when the file it names is a style sheet (read for its own references). */
|
|
43
|
+
export interface Reference {
|
|
44
|
+
readonly url: string
|
|
45
|
+
readonly css: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NAMED: Readonly<Record<string, string>> = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: "\u00a0" }
|
|
49
|
+
|
|
50
|
+
/** HTML character references in an attribute value (named ones of NAMED, decimal and hex). */
|
|
51
|
+
function decodeEntities(value: string): string {
|
|
52
|
+
if (!value.includes("&")) return value
|
|
53
|
+
return value.replace(/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z]{2,6}));?/g, (whole, dec: string | undefined, hex: string | undefined, name: string | undefined) => {
|
|
54
|
+
if (name !== undefined) return Object.hasOwn(NAMED, name.toLowerCase()) ? NAMED[name.toLowerCase()]! : whole
|
|
55
|
+
const code = dec !== undefined ? Number(dec) : parseInt(hex!, 16)
|
|
56
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Elements whose text is never markup (raw text and escapable raw text): skipped to their end tag. */
|
|
61
|
+
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "noembed", "noframes", "iframe", "plaintext"])
|
|
62
|
+
const TAG_NAME = /[A-Za-z][A-Za-z0-9-]*/y
|
|
63
|
+
const SPACE = /[\s/]/
|
|
64
|
+
|
|
65
|
+
/** The references of an HTML document, in document order. */
|
|
66
|
+
export function htmlReferences(html: string): Reference[] {
|
|
67
|
+
const out: Reference[] = []
|
|
68
|
+
const n = html.length
|
|
69
|
+
let i = 0
|
|
70
|
+
while (i < n) {
|
|
71
|
+
const lt = html.indexOf("<", i)
|
|
72
|
+
if (lt < 0) break
|
|
73
|
+
if (html.startsWith("<!--", lt)) {
|
|
74
|
+
const end = html.indexOf("-->", lt + 4)
|
|
75
|
+
i = end < 0 ? n : end + 3
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const next = html[lt + 1]
|
|
79
|
+
if (next === "!" || next === "?" || next === "/") {
|
|
80
|
+
const end = html.indexOf(">", lt + 2)
|
|
81
|
+
i = end < 0 ? n : end + 1
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
TAG_NAME.lastIndex = lt + 1
|
|
85
|
+
const name = TAG_NAME.exec(html)
|
|
86
|
+
if (!name) {
|
|
87
|
+
i = lt + 1
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const tag = name[0].toLowerCase()
|
|
91
|
+
const { attrs, end } = attributes(html, lt + 1 + name[0].length)
|
|
92
|
+
i = end
|
|
93
|
+
if (attrs) referencesOf(tag, attrs, out)
|
|
94
|
+
if (RAW_TEXT.has(tag) && i < n) {
|
|
95
|
+
const close = new RegExp(`</${tag}[\\s/>]`, "gi")
|
|
96
|
+
close.lastIndex = i
|
|
97
|
+
const found = close.exec(html)
|
|
98
|
+
const body = html.slice(i, found ? found.index : n)
|
|
99
|
+
if (tag === "style") out.push(...cssReferences(body))
|
|
100
|
+
i = found ? found.index : n
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The attributes of a start tag from `at` (after its name) to its `>`: names lowercased, values decoded. A tag that
|
|
108
|
+
* never closes takes the rest of the text; a quoted value that never closes drops the tag (undefined).
|
|
109
|
+
*/
|
|
110
|
+
function attributes(html: string, at: number): { attrs: Map<string, string> | undefined; end: number } {
|
|
111
|
+
const attrs = new Map<string, string>()
|
|
112
|
+
const n = html.length
|
|
113
|
+
let i = at
|
|
114
|
+
while (i < n) {
|
|
115
|
+
const c = html[i]!
|
|
116
|
+
if (c === ">") return { attrs, end: i + 1 }
|
|
117
|
+
if (SPACE.test(c)) {
|
|
118
|
+
i++
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
const start = i
|
|
122
|
+
while (i < n && !/[\s/>=]/.test(html[i]!)) i++
|
|
123
|
+
// An "=" with no name before it is a character of the next name (the HTML tokenizer's rule).
|
|
124
|
+
if (i === start) i++
|
|
125
|
+
const key = html.slice(start, i).toLowerCase()
|
|
126
|
+
while (i < n && /\s/.test(html[i]!)) i++
|
|
127
|
+
if (html[i] !== "=") {
|
|
128
|
+
if (!attrs.has(key)) attrs.set(key, "")
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
i++
|
|
132
|
+
while (i < n && /\s/.test(html[i]!)) i++
|
|
133
|
+
let value: string
|
|
134
|
+
const q = html[i]
|
|
135
|
+
if (q === '"' || q === "'") {
|
|
136
|
+
const close = html.indexOf(q, i + 1)
|
|
137
|
+
if (close < 0) return { attrs: undefined, end: n }
|
|
138
|
+
value = html.slice(i + 1, close)
|
|
139
|
+
i = close + 1
|
|
140
|
+
} else {
|
|
141
|
+
const begin = i
|
|
142
|
+
while (i < n && !/[\s>]/.test(html[i]!)) i++
|
|
143
|
+
value = html.slice(begin, i)
|
|
144
|
+
}
|
|
145
|
+
if (!attrs.has(key)) attrs.set(key, decodeEntities(value))
|
|
146
|
+
}
|
|
147
|
+
return { attrs, end: n }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const SRC_TAGS = new Set(["script", "img", "source", "video", "audio"])
|
|
151
|
+
|
|
152
|
+
function referencesOf(tag: string, attrs: Map<string, string>, out: Reference[]): void {
|
|
153
|
+
const add = (url: string | undefined, css = false) => {
|
|
154
|
+
if (url) out.push({ url, css })
|
|
155
|
+
}
|
|
156
|
+
if (tag === "link") {
|
|
157
|
+
const rel = (attrs.get("rel") ?? "").toLowerCase().split(/\s+/)
|
|
158
|
+
if (rel.includes("stylesheet")) add(attrs.get("href")?.trim(), true)
|
|
159
|
+
} else if (SRC_TAGS.has(tag)) {
|
|
160
|
+
add(attrs.get("src")?.trim())
|
|
161
|
+
if (tag === "img" || tag === "source") for (const url of srcsetUrls(attrs.get("srcset") ?? "")) add(url)
|
|
162
|
+
}
|
|
163
|
+
const style = attrs.get("style")
|
|
164
|
+
if (style) out.push(...cssReferences(style))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The URLs of a srcset attribute (the HTML "parse a srcset attribute" steps, without descriptor checks): each
|
|
169
|
+
* candidate is a run of non-space characters (trailing commas dropped), then descriptors up to the next comma outside
|
|
170
|
+
* parentheses.
|
|
171
|
+
*/
|
|
172
|
+
export function srcsetUrls(value: string): string[] {
|
|
173
|
+
const out: string[] = []
|
|
174
|
+
const n = value.length
|
|
175
|
+
let i = 0
|
|
176
|
+
while (i < n) {
|
|
177
|
+
while (i < n && /[\s,]/.test(value[i]!)) i++
|
|
178
|
+
if (i >= n) break
|
|
179
|
+
const start = i
|
|
180
|
+
while (i < n && !/\s/.test(value[i]!)) i++
|
|
181
|
+
let url = value.slice(start, i)
|
|
182
|
+
if (url.endsWith(",")) {
|
|
183
|
+
url = url.replace(/,+$/, "")
|
|
184
|
+
if (url) out.push(url)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (url) out.push(url)
|
|
188
|
+
let depth = 0
|
|
189
|
+
while (i < n) {
|
|
190
|
+
const c = value[i]!
|
|
191
|
+
if (c === "(") depth++
|
|
192
|
+
else if (c === ")") depth = Math.max(0, depth - 1)
|
|
193
|
+
else if (c === "," && depth === 0) break
|
|
194
|
+
i++
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const CSS_TOKEN = /\/\*|url\(|@import/gi
|
|
201
|
+
|
|
202
|
+
/** The references of a style sheet: `url()` (files) and `@import` (style sheets), outside comments, in order. */
|
|
203
|
+
export function cssReferences(css: string): Reference[] {
|
|
204
|
+
const out: Reference[] = []
|
|
205
|
+
const n = css.length
|
|
206
|
+
const re = new RegExp(CSS_TOKEN.source, "gi")
|
|
207
|
+
for (let m = re.exec(css); m; m = re.exec(css)) {
|
|
208
|
+
const token = m[0].toLowerCase()
|
|
209
|
+
let i = m.index + m[0].length
|
|
210
|
+
if (token === "/*") {
|
|
211
|
+
const end = css.indexOf("*/", i)
|
|
212
|
+
if (end < 0) break
|
|
213
|
+
re.lastIndex = end + 2
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
let isImport = false
|
|
217
|
+
if (token === "@import") {
|
|
218
|
+
isImport = true
|
|
219
|
+
while (i < n && /\s/.test(css[i]!)) i++
|
|
220
|
+
if (css[i] === '"' || css[i] === "'") {
|
|
221
|
+
const close = css.indexOf(css[i]!, i + 1)
|
|
222
|
+
if (close < 0) break
|
|
223
|
+
out.push({ url: css.slice(i + 1, close).trim(), css: true })
|
|
224
|
+
re.lastIndex = close + 1
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (css.slice(i, i + 4).toLowerCase() !== "url(") {
|
|
228
|
+
re.lastIndex = i
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
i += 4
|
|
232
|
+
}
|
|
233
|
+
while (i < n && /\s/.test(css[i]!)) i++
|
|
234
|
+
let url: string
|
|
235
|
+
if (css[i] === '"' || css[i] === "'") {
|
|
236
|
+
const close = css.indexOf(css[i]!, i + 1)
|
|
237
|
+
if (close < 0) break
|
|
238
|
+
url = css.slice(i + 1, close)
|
|
239
|
+
const paren = css.indexOf(")", close + 1)
|
|
240
|
+
re.lastIndex = paren < 0 ? n : paren + 1
|
|
241
|
+
} else {
|
|
242
|
+
const paren = css.indexOf(")", i)
|
|
243
|
+
if (paren < 0) break
|
|
244
|
+
url = css.slice(i, paren)
|
|
245
|
+
re.lastIndex = paren + 1
|
|
246
|
+
}
|
|
247
|
+
url = url.trim()
|
|
248
|
+
if (url) out.push({ url, css: isImport })
|
|
249
|
+
}
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- the walk -------------------------------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export type BundleLimits = { -readonly [K in "files" | "fileBytes" | "totalBytes"]: number }
|
|
256
|
+
|
|
257
|
+
export interface WalkInput extends PolicyContext {
|
|
258
|
+
/** The page as written in the message (a code span or link target). */
|
|
259
|
+
readonly src: string
|
|
260
|
+
/** The message's text (its text parts joined). */
|
|
261
|
+
readonly text: string
|
|
262
|
+
/** For tests: smaller caps. */
|
|
263
|
+
readonly limits?: Partial<BundleLimits>
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface WalkedFile {
|
|
267
|
+
readonly path: string
|
|
268
|
+
readonly mediaType: string
|
|
269
|
+
readonly bytes: Uint8Array
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type WalkResult =
|
|
273
|
+
| { readonly entry: string; readonly files: readonly WalkedFile[]; readonly skipped: readonly PreviewBundleSkip[]; readonly skippedMore?: number }
|
|
274
|
+
| { readonly refused: "path" | "type" }
|
|
275
|
+
| { readonly tooLarge: { readonly bytes: number; readonly max: number } }
|
|
276
|
+
|
|
277
|
+
const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
278
|
+
|
|
279
|
+
/** A reference as followed: without query and fragment, `\` read as `/` (as browsers do); undefined: left alone. */
|
|
280
|
+
function localReference(raw: string): { written: string; path?: string; refused?: PreviewSkipReason } | undefined {
|
|
281
|
+
const written = raw.trim().replace(/\\/g, "/").replace(/[?#].*$/s, "")
|
|
282
|
+
if (!written || SCHEME.test(written)) return undefined
|
|
283
|
+
if (written.startsWith("/")) return { written, refused: "refused" }
|
|
284
|
+
let path: string
|
|
285
|
+
try {
|
|
286
|
+
path = decodeURIComponent(written)
|
|
287
|
+
} catch {
|
|
288
|
+
return { written, refused: "name" }
|
|
289
|
+
}
|
|
290
|
+
if (path.includes("\u0000")) return { written, refused: "refused" }
|
|
291
|
+
return { written, path }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The directory every path is under (the deepest common one). */
|
|
295
|
+
function commonDir(dirs: readonly string[]): string {
|
|
296
|
+
let common = dirs[0]!
|
|
297
|
+
for (const d of dirs.slice(1)) {
|
|
298
|
+
while (common !== d && !d.startsWith(common.endsWith(sep) ? common : common + sep)) {
|
|
299
|
+
const up = dirname(common)
|
|
300
|
+
if (up === common) break
|
|
301
|
+
common = up
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return common
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const posix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"))
|
|
308
|
+
|
|
309
|
+
/** Walks the page `src` names and what it refers to (see the file's header). */
|
|
310
|
+
export async function walkBundle(input: WalkInput): Promise<WalkResult> {
|
|
311
|
+
const limits: BundleLimits = { files: PREVIEW_BUNDLE_LIMITS.files, fileBytes: PREVIEW_BUNDLE_LIMITS.fileBytes, totalBytes: PREVIEW_BUNDLE_LIMITS.totalBytes, ...input.limits }
|
|
312
|
+
if (!markdownHtmlPaths(input.text).includes(input.src)) return { refused: "path" }
|
|
313
|
+
const entryPath = sourcePath(input.src, input.directory)
|
|
314
|
+
if (!entryPath) return { refused: "path" }
|
|
315
|
+
// The touched set is resolved once for the walk, not once per reference.
|
|
316
|
+
const ctx: PolicyContext = await resolvedPolicy({ directory: input.directory, touched: input.touched, ...(input.fs ? { fs: input.fs } : {}) })
|
|
317
|
+
// File-system lookups per walk (each reference read once): past this, references are skipped as "limit".
|
|
318
|
+
const maxProbes = 2 * limits.files
|
|
319
|
+
let probes = 0
|
|
320
|
+
const page = await readAllowedPath(entryPath, { ...ctx, max: limits.fileBytes })
|
|
321
|
+
if ("refused" in page) return page
|
|
322
|
+
if ("tooLarge" in page) return { tooLarge: { bytes: page.tooLarge, max: limits.fileBytes } }
|
|
323
|
+
if (!/\.html?$/i.test(page.path)) return { refused: "type" }
|
|
324
|
+
|
|
325
|
+
// Lexical paths keep the layout the page's relative references expect; the policy checks each real path.
|
|
326
|
+
const entryName = isBundlePath(basename(entryPath)) ? basename(entryPath) : "index.html"
|
|
327
|
+
const files: Array<{ abs: string; bytes: Uint8Array }> = [{ abs: resolve(dirname(entryPath), entryName), bytes: page.bytes }]
|
|
328
|
+
const seen = new Set<string>([entryPath, files[0]!.abs])
|
|
329
|
+
const skipped: PreviewBundleSkip[] = []
|
|
330
|
+
let skippedMore = 0
|
|
331
|
+
let total = page.bytes.length
|
|
332
|
+
let root = dirname(entryPath)
|
|
333
|
+
const skip = (written: string, reason: PreviewSkipReason) => {
|
|
334
|
+
if (skipped.length < PREVIEW_BUNDLE_LIMITS.skipped) skipped.push({ path: written.slice(0, PREVIEW_BUNDLE_LIMITS.refMax), reason })
|
|
335
|
+
else skippedMore++
|
|
336
|
+
}
|
|
337
|
+
const relOf = (abs: string, under: string) => posix(relative(under, abs))
|
|
338
|
+
/** The root with `abs` added, when every file still has a valid bundle path under it. */
|
|
339
|
+
const rootWith = (abs: string): string | undefined => {
|
|
340
|
+
const next = commonDir([root, dirname(abs)])
|
|
341
|
+
return [...files.map((f) => f.abs), abs].every((p) => isBundlePath(relOf(p, next))) ? next : undefined
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const queue: Array<{ abs: string; css: boolean; text: string }> = [{ abs: entryPath, css: false, text: new TextDecoder().decode(page.bytes) }]
|
|
345
|
+
for (let q = 0; q < queue.length; q++) {
|
|
346
|
+
const file = queue[q]!
|
|
347
|
+
const refs = file.css ? cssReferences(file.text) : htmlReferences(file.text)
|
|
348
|
+
for (const ref of refs) {
|
|
349
|
+
const local = localReference(ref.url)
|
|
350
|
+
if (!local) continue
|
|
351
|
+
if (local.refused) {
|
|
352
|
+
skip(local.written, local.refused)
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
const abs = resolve(dirname(file.abs), local.path!)
|
|
356
|
+
if (seen.has(abs)) continue
|
|
357
|
+
seen.add(abs)
|
|
358
|
+
if (!previewMediaType(abs)) {
|
|
359
|
+
skip(local.written, "type")
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
if (files.length >= limits.files || probes >= maxProbes) {
|
|
363
|
+
skip(local.written, "limit")
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
probes++
|
|
367
|
+
const read = await readAllowedPath(abs, { ...ctx, max: limits.fileBytes })
|
|
368
|
+
if ("refused" in read) {
|
|
369
|
+
// "missing" only where the policy would allow the file: outside it, existing or not reads the same.
|
|
370
|
+
skip(local.written, (await missingAllowed(abs, ctx)) ? "missing" : "refused")
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if ("tooLarge" in read) {
|
|
374
|
+
skip(local.written, "too_large")
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (total + read.bytes.length > limits.totalBytes) {
|
|
378
|
+
skip(local.written, "limit")
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
const next = rootWith(abs)
|
|
382
|
+
if (next === undefined) {
|
|
383
|
+
skip(local.written, "name")
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
root = next
|
|
387
|
+
total += read.bytes.length
|
|
388
|
+
files.push({ abs, bytes: read.bytes })
|
|
389
|
+
if (ref.css) queue.push({ abs, css: true, text: new TextDecoder().decode(read.bytes) })
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const out = files.map((f) => {
|
|
393
|
+
const path = relOf(f.abs, root)
|
|
394
|
+
return { path, mediaType: previewMediaType(path)!, bytes: f.bytes }
|
|
395
|
+
})
|
|
396
|
+
return { entry: out[0]!.path, files: out, skipped, ...(skippedMore > 0 ? { skippedMore } : {}) }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
// ---- the op ---------------------------------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/** A walked bundle as the op serves it: its identity and its files with their hashes. */
|
|
403
|
+
export interface CachedBundle {
|
|
404
|
+
readonly id: string
|
|
405
|
+
readonly entry: string
|
|
406
|
+
readonly files: ReadonlyArray<{ readonly path: string; readonly mediaType: string; readonly bytes: Uint8Array; readonly sha256: string }>
|
|
407
|
+
readonly skipped: readonly PreviewBundleSkip[]
|
|
408
|
+
readonly skippedMore?: number
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const sizeOf = (b: CachedBundle) => b.files.reduce((n, f) => n + f.bytes.length, 0)
|
|
412
|
+
|
|
413
|
+
/** Walked bundles by key (session, message, page), least recently used first; entries expire after `ttlMs`. */
|
|
414
|
+
export class BundleCache {
|
|
415
|
+
private readonly entries = new Map<string, { bundle: CachedBundle; at: number; size: number }>()
|
|
416
|
+
private total = 0
|
|
417
|
+
private readonly ttlMs: number
|
|
418
|
+
private readonly maxBytes: number
|
|
419
|
+
private readonly now: () => number
|
|
420
|
+
|
|
421
|
+
constructor(options: { ttlMs?: number; maxBytes?: number; now?: () => number } = {}) {
|
|
422
|
+
this.ttlMs = options.ttlMs ?? 60_000
|
|
423
|
+
this.maxBytes = options.maxBytes ?? 48 * 1024 * 1024
|
|
424
|
+
this.now = options.now ?? Date.now
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
get(key: string): CachedBundle | undefined {
|
|
428
|
+
const entry = this.entries.get(key)
|
|
429
|
+
if (!entry) return undefined
|
|
430
|
+
this.entries.delete(key)
|
|
431
|
+
if (this.now() - entry.at >= this.ttlMs) {
|
|
432
|
+
this.total -= entry.size
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
this.entries.set(key, entry)
|
|
436
|
+
return entry.bundle
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
set(key: string, bundle: CachedBundle): void {
|
|
440
|
+
this.drop(key)
|
|
441
|
+
const size = sizeOf(bundle)
|
|
442
|
+
if (size > this.maxBytes) return
|
|
443
|
+
this.entries.set(key, { bundle, at: this.now(), size })
|
|
444
|
+
this.total += size
|
|
445
|
+
while (this.total > this.maxBytes) this.drop(this.entries.keys().next().value!)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Forget a session's bundles (deleted, or a revert hid messages). */
|
|
449
|
+
invalidate(sessionID: string): void {
|
|
450
|
+
for (const key of [...this.entries.keys()]) if (key.startsWith(`${sessionID}\u0000`)) this.drop(key)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private drop(key: string): void {
|
|
454
|
+
const entry = this.entries.get(key)
|
|
455
|
+
if (!entry) return
|
|
456
|
+
this.entries.delete(key)
|
|
457
|
+
this.total -= entry.size
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export interface PreviewBundleInput {
|
|
462
|
+
readonly sessionID: string
|
|
463
|
+
readonly messageID: string
|
|
464
|
+
readonly path: string
|
|
465
|
+
readonly bundle?: string
|
|
466
|
+
readonly file?: number
|
|
467
|
+
readonly offset?: number
|
|
468
|
+
readonly cache: BundleCache
|
|
469
|
+
/** The session's messages (read for every walk, so access is checked again). */
|
|
470
|
+
readonly load: () => Promise<MediaMessages>
|
|
471
|
+
readonly directory: string
|
|
472
|
+
readonly fs?: PolicyContext["fs"]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
|
|
476
|
+
|
|
477
|
+
/** The bundle's identity: a hash of its entry and every file's path, type and content hash. */
|
|
478
|
+
const bundleID = (entry: string, files: CachedBundle["files"]) => createHash("sha256").update(JSON.stringify([entry, files.map((f) => [f.path, f.mediaType, f.sha256])])).digest("hex")
|
|
479
|
+
|
|
480
|
+
/** Walks the page of the message again (message, revert and path checks included). */
|
|
481
|
+
async function walkMessage(input: PreviewBundleInput): Promise<CachedBundle | PreviewBundleResult> {
|
|
482
|
+
const messages = await input.load()
|
|
483
|
+
const message = messages.all.map(record).find((m) => m.id === input.messageID)
|
|
484
|
+
if (!message) return input.messageID === messages.committed ? { refused: "hidden" } : {}
|
|
485
|
+
if (!messages.shown.some((m) => record(m).id === input.messageID)) return { refused: "hidden" }
|
|
486
|
+
if (message.type !== "assistant" || !Array.isArray(message.content)) return { refused: "path" }
|
|
487
|
+
const text = message.content
|
|
488
|
+
.filter((p: any) => p?.type === "text" && typeof p.text === "string")
|
|
489
|
+
.map((p: any) => p.text as string)
|
|
490
|
+
.join("\n\n")
|
|
491
|
+
const walked = await walkBundle({ src: input.path, text, directory: input.directory, touched: touchedPaths(messages.shown, input.directory), ...(input.fs ? { fs: input.fs } : {}) })
|
|
492
|
+
if (!("files" in walked)) return walked
|
|
493
|
+
const files = walked.files.map((f) => ({ ...f, sha256: createHash("sha256").update(f.bytes).digest("hex") }))
|
|
494
|
+
return { id: bundleID(walked.entry, files), entry: walked.entry, files, skipped: walked.skipped, ...(walked.skippedMore ? { skippedMore: walked.skippedMore } : {}) }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const isBundle = (v: CachedBundle | PreviewBundleResult): v is CachedBundle => "id" in v
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The answer to previewBundle (packages/protocol/src/preview-bundle.ts): without `bundle`, a fresh walk's manifest
|
|
501
|
+
* (files inlined in order while they fit PREVIEW_BUNDLE_LIMITS.inlineBytes); with `bundle, file, offset`, that chunk,
|
|
502
|
+
* from the cache or a new walk with the same identity, else `stale`. Throws for no such file or an offset past its end.
|
|
503
|
+
*/
|
|
504
|
+
export async function previewBundle(input: PreviewBundleInput): Promise<PreviewBundleResult> {
|
|
505
|
+
const key = `${input.sessionID}\u0000${input.messageID}\u0000${input.path}`
|
|
506
|
+
if (input.bundle !== undefined) {
|
|
507
|
+
let bundle = input.cache.get(key)
|
|
508
|
+
if (bundle?.id !== input.bundle) {
|
|
509
|
+
const walked = await walkMessage(input)
|
|
510
|
+
if (!isBundle(walked)) return walked
|
|
511
|
+
input.cache.set(key, walked)
|
|
512
|
+
if (walked.id !== input.bundle) return { stale: true }
|
|
513
|
+
bundle = walked
|
|
514
|
+
}
|
|
515
|
+
const file = bundle.files[input.file ?? -1]
|
|
516
|
+
if (!file) throw new Error("No such file in the preview bundle")
|
|
517
|
+
const offset = input.offset ?? 0
|
|
518
|
+
if (offset >= file.bytes.length) throw new Error("The offset is past the end of the file")
|
|
519
|
+
const end = Math.min(file.bytes.length, offset + PREVIEW_BUNDLE_LIMITS.chunkBytes)
|
|
520
|
+
return { chunk: { offset, data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset + offset, end - offset).toString("base64"), last: end >= file.bytes.length } }
|
|
521
|
+
}
|
|
522
|
+
const walked = await walkMessage(input)
|
|
523
|
+
if (!isBundle(walked)) return walked
|
|
524
|
+
input.cache.set(key, walked)
|
|
525
|
+
let inline = 0
|
|
526
|
+
const files: PreviewBundleFile[] = walked.files.map((f) => {
|
|
527
|
+
const fits = inline + f.bytes.length <= PREVIEW_BUNDLE_LIMITS.inlineBytes
|
|
528
|
+
if (fits) inline += f.bytes.length
|
|
529
|
+
return { path: f.path, mediaType: f.mediaType, bytes: f.bytes.length, sha256: f.sha256, ...(fits ? { data: Buffer.from(f.bytes).toString("base64") } : {}) }
|
|
530
|
+
})
|
|
531
|
+
return {
|
|
532
|
+
bundle: walked.id,
|
|
533
|
+
entry: walked.entry,
|
|
534
|
+
files,
|
|
535
|
+
...(walked.skipped.length ? { skipped: walked.skipped } : {}),
|
|
536
|
+
...(walked.skippedMore ? { skippedMore: walked.skippedMore } : {}),
|
|
537
|
+
}
|
|
538
|
+
}
|