@huynle/hub 0.8.6 → 0.9.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.
- package/CHANGELOG.md +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- 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/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- 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 +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- 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 +18 -1
- 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/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-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.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-CZM_R7MG.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-TwvrwqSk.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-rKNDpLjR.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-DtJylLrt.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-Bmgdyfjb.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-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.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-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.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,240 @@
|
|
|
1
|
+
// (RF2, session media §4) The per-user media allowlist: hostnames (`github.com`) or suffixes
|
|
2
|
+
// (`*.githubusercontent.com`) whose web images the console loads at once through GET /api/media/remote. Shared by
|
|
3
|
+
// the hub (PUT /api/settings/media validates with parseMediaAllowlist) and the console (Settings → Media, and the
|
|
4
|
+
// match for a reply's image). Entries are stored normalised: lowercase, no trailing dot, IDNs as punycode. Refused:
|
|
5
|
+
// IP addresses, ports, schemes, paths, userinfo, a bare `*`, and wildcards (or exact hosts) that are only a public
|
|
6
|
+
// suffix (`*.com`, `*.co.uk`, `*.github.io`): a wildcard needs at least two labels after `*.`, none of them a known
|
|
7
|
+
// public suffix. A wildcard matches subdomains only, never the suffix itself.
|
|
8
|
+
|
|
9
|
+
export const MEDIA_ALLOWLIST_LIMITS = { entries: 100, chars: 253 } as const
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Common public suffixes of more than one label (and shared hosting suffixes whose subdomains belong to different
|
|
13
|
+
* owners). Single-label suffixes (`com`, `de` …) are refused by the two-label rule. Not the full Public Suffix List:
|
|
14
|
+
* a small built-in guard against the obvious mistakes.
|
|
15
|
+
*/
|
|
16
|
+
const PUBLIC_SUFFIXES = new Set([
|
|
17
|
+
...["co", "org", "ac", "gov", "net", "me", "ltd", "plc", "sch", "nhs"].map((s) => `${s}.uk`),
|
|
18
|
+
...["com", "net", "org", "edu", "gov", "id", "asn"].map((s) => `${s}.au`),
|
|
19
|
+
...["co", "ne", "or", "ac", "go", "gr", "ed", "ad", "lg"].map((s) => `${s}.jp`),
|
|
20
|
+
...["co", "org", "net", "ac", "govt", "school", "geek", "gen", "kiwi", "maori"].map((s) => `${s}.nz`),
|
|
21
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.br`),
|
|
22
|
+
...["com", "net", "org", "gov", "edu", "ac"].map((s) => `${s}.cn`),
|
|
23
|
+
...["com", "net", "org", "gob", "edu"].map((s) => `${s}.mx`),
|
|
24
|
+
...["co", "net", "org", "firm", "gen", "ind", "ac", "edu", "gov", "res"].map((s) => `${s}.in`),
|
|
25
|
+
...["co", "org", "net", "ac", "gov", "web"].map((s) => `${s}.za`),
|
|
26
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.tr`),
|
|
27
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.sg`),
|
|
28
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.hk`),
|
|
29
|
+
...["co", "or", "ne", "go", "ac", "re"].map((s) => `${s}.kr`),
|
|
30
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.tw`),
|
|
31
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.ar`),
|
|
32
|
+
...["co", "or", "ac", "go"].map((s) => `${s}.id`),
|
|
33
|
+
...["co", "or", "ac", "go"].map((s) => `${s}.th`),
|
|
34
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.my`),
|
|
35
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.ph`),
|
|
36
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.pk`),
|
|
37
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.ua`),
|
|
38
|
+
...["com", "net", "org", "gov", "edu"].map((s) => `${s}.pl`),
|
|
39
|
+
...["co", "or", "ac"].map((s) => `${s}.at`),
|
|
40
|
+
...["co", "org", "ac"].map((s) => `${s}.il`),
|
|
41
|
+
"com.es",
|
|
42
|
+
"org.es",
|
|
43
|
+
"co.it",
|
|
44
|
+
"com.ru",
|
|
45
|
+
"co.ca",
|
|
46
|
+
"eu.org",
|
|
47
|
+
// Shared hosting: every subdomain is someone else's site.
|
|
48
|
+
"github.io",
|
|
49
|
+
"githubpreview.dev",
|
|
50
|
+
"gitlab.io",
|
|
51
|
+
"pages.dev",
|
|
52
|
+
"workers.dev",
|
|
53
|
+
"netlify.app",
|
|
54
|
+
"vercel.app",
|
|
55
|
+
"herokuapp.com",
|
|
56
|
+
"appspot.com",
|
|
57
|
+
"blogspot.com",
|
|
58
|
+
"cloudfront.net",
|
|
59
|
+
"azurewebsites.net",
|
|
60
|
+
"web.app",
|
|
61
|
+
"firebaseapp.com",
|
|
62
|
+
"s3.amazonaws.com",
|
|
63
|
+
"ngrok.io",
|
|
64
|
+
"ngrok-free.app",
|
|
65
|
+
"glitch.me",
|
|
66
|
+
"repl.co",
|
|
67
|
+
"fly.dev",
|
|
68
|
+
"onrender.com",
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
72
|
+
|
|
73
|
+
type Entry = { ok: true; entry: string } | { ok: false; reason: string }
|
|
74
|
+
|
|
75
|
+
/** One allowlist entry, normalised (lowercase, no trailing dot, punycode), or why it is refused. */
|
|
76
|
+
export function normalizeAllowlistEntry(raw: string): Entry {
|
|
77
|
+
if (typeof raw !== "string") return { ok: false, reason: "Expected a hostname" }
|
|
78
|
+
const value = raw.trim()
|
|
79
|
+
if (!value) return { ok: false, reason: "Expected a hostname" }
|
|
80
|
+
if (value.length > MEDIA_ALLOWLIST_LIMITS.chars)
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: `${short(value)}: at most ${MEDIA_ALLOWLIST_LIMITS.chars} characters`,
|
|
84
|
+
}
|
|
85
|
+
if (/[\s/\\:@?#[\]%]/.test(value))
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: `${short(value)}: a hostname only (no scheme, port, path or address)`,
|
|
89
|
+
}
|
|
90
|
+
const wildcard = value.startsWith("*.")
|
|
91
|
+
const rest = wildcard ? value.slice(2) : value
|
|
92
|
+
if (rest.includes("*"))
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: `${short(value)}: a wildcard only as a leading "*."`,
|
|
96
|
+
}
|
|
97
|
+
const ascii = toASCII(rest.replace(/\.$/, ""))
|
|
98
|
+
if (ascii === undefined) return { ok: false, reason: `${short(value)}: not a hostname` }
|
|
99
|
+
const labels = ascii.split(".")
|
|
100
|
+
if (labels.some((l) => !LABEL.test(l))) return { ok: false, reason: `${short(value)}: not a hostname` }
|
|
101
|
+
if (/^\d+$/.test(labels[labels.length - 1]!))
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: `${short(value)}: an IP address or numeric domain is not allowed`,
|
|
105
|
+
}
|
|
106
|
+
if (labels.length < 2)
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: wildcard ? `${short(value)}: too broad (name at least two labels after "*.")` : `${short(value)}: name a full hostname (like github.com)`,
|
|
110
|
+
}
|
|
111
|
+
if (PUBLIC_SUFFIXES.has(ascii))
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: `${short(value)}: too broad (a public suffix: name the site itself)`,
|
|
115
|
+
}
|
|
116
|
+
const entry = wildcard ? `*.${ascii}` : ascii
|
|
117
|
+
if (entry.length > MEDIA_ALLOWLIST_LIMITS.chars)
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: `${short(value)}: at most ${MEDIA_ALLOWLIST_LIMITS.chars} characters`,
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, entry }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const short = (value: string) => (value.length > 60 ? `${value.slice(0, 57)}…` : value)
|
|
126
|
+
|
|
127
|
+
/** A host in punycode through the URL parser (which also lowercases); undefined when it is not a valid one. */
|
|
128
|
+
function toASCII(host: string): string | undefined {
|
|
129
|
+
if (!host) return undefined
|
|
130
|
+
try {
|
|
131
|
+
const parsed = new URL(`http://${host}/`)
|
|
132
|
+
return parsed.hostname && parsed.hostname === parsed.host ? parsed.hostname : undefined
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The whole setting (PUT /api/settings/media's `allowlist`): normalised, order kept, duplicates dropped. */
|
|
139
|
+
export function parseMediaAllowlist(value: unknown): { ok: true; allowlist: string[] } | { ok: false; reason: string } {
|
|
140
|
+
if (!Array.isArray(value)) return { ok: false, reason: "Expected allowlist: a list of hostnames" }
|
|
141
|
+
if (value.length > MEDIA_ALLOWLIST_LIMITS.entries)
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reason: `At most ${MEDIA_ALLOWLIST_LIMITS.entries} allowed sites`,
|
|
145
|
+
}
|
|
146
|
+
const out: string[] = []
|
|
147
|
+
for (const raw of value) {
|
|
148
|
+
const entry = normalizeAllowlistEntry(raw as string)
|
|
149
|
+
if (!entry.ok) return entry
|
|
150
|
+
if (!out.includes(entry.entry)) out.push(entry.entry)
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, allowlist: out }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The hostname of an http(s) image URL, normalised like allowlist entries (the URL parser's host: lowercase,
|
|
157
|
+
* punycode, userinfo dropped); undefined for other schemes, unparsable URLs and ports other than 80/443 (the hub's
|
|
158
|
+
* fetcher refuses those).
|
|
159
|
+
*/
|
|
160
|
+
export function imageHost(url: string): string | undefined {
|
|
161
|
+
let parsed: URL
|
|
162
|
+
try {
|
|
163
|
+
parsed = new URL(url)
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined
|
|
168
|
+
if (parsed.port && parsed.port !== "80" && parsed.port !== "443") return undefined
|
|
169
|
+
return parsed.hostname.replace(/\.$/, "") || undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Whether a host is on the allowlist: an exact entry, or a `*.suffix` entry for a subdomain of it. */
|
|
173
|
+
export function allowlistMatches(allowlist: readonly string[], host: string): boolean {
|
|
174
|
+
const h = host.toLowerCase().replace(/\.$/, "")
|
|
175
|
+
if (!h) return false
|
|
176
|
+
return allowlist.some((entry) => (entry.startsWith("*.") ? h.endsWith(entry.slice(1)) && h.length > entry.length - 1 : h === entry))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A host with its punycode labels shown in unicode (for display next to the punycode form). */
|
|
180
|
+
export function hostUnicode(host: string): string {
|
|
181
|
+
return host
|
|
182
|
+
.split(".")
|
|
183
|
+
.map((label) => {
|
|
184
|
+
if (!label.toLowerCase().startsWith("xn--")) return label
|
|
185
|
+
return decodePunycode(label.slice(4)) ?? label
|
|
186
|
+
})
|
|
187
|
+
.join(".")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** RFC 3492 decoding of one label's punycode (after "xn--"); undefined when it is not valid. */
|
|
191
|
+
function decodePunycode(input: string): string | undefined {
|
|
192
|
+
const base = 36
|
|
193
|
+
const tMin = 1
|
|
194
|
+
const tMax = 26
|
|
195
|
+
const skew = 38
|
|
196
|
+
const damp = 700
|
|
197
|
+
let n = 128
|
|
198
|
+
let i = 0
|
|
199
|
+
let bias = 72
|
|
200
|
+
const delimiter = input.lastIndexOf("-")
|
|
201
|
+
const output: number[] = []
|
|
202
|
+
for (let j = 0; j < Math.max(delimiter, 0); j++) {
|
|
203
|
+
const c = input.charCodeAt(j)
|
|
204
|
+
if (c >= 0x80) return undefined
|
|
205
|
+
output.push(c)
|
|
206
|
+
}
|
|
207
|
+
const adapt = (delta: number, points: number, first: boolean) => {
|
|
208
|
+
delta = first ? Math.floor(delta / damp) : delta >> 1
|
|
209
|
+
delta += Math.floor(delta / points)
|
|
210
|
+
let k = 0
|
|
211
|
+
while (delta > ((base - tMin) * tMax) >> 1) {
|
|
212
|
+
delta = Math.floor(delta / (base - tMin))
|
|
213
|
+
k += base
|
|
214
|
+
}
|
|
215
|
+
return k + Math.floor(((base - tMin + 1) * delta) / (delta + skew))
|
|
216
|
+
}
|
|
217
|
+
const digit = (c: number) => (c >= 48 && c <= 57 ? c - 22 : c >= 65 && c <= 90 ? c - 65 : c >= 97 && c <= 122 ? c - 97 : base)
|
|
218
|
+
for (let at = delimiter > 0 ? delimiter + 1 : 0; at < input.length;) {
|
|
219
|
+
const old = i
|
|
220
|
+
for (let w = 1, k = base; ; k += base) {
|
|
221
|
+
if (at >= input.length) return undefined
|
|
222
|
+
const d = digit(input.charCodeAt(at++))
|
|
223
|
+
if (d >= base) return undefined
|
|
224
|
+
i += d * w
|
|
225
|
+
const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias
|
|
226
|
+
if (d < t) break
|
|
227
|
+
w *= base - t
|
|
228
|
+
}
|
|
229
|
+
bias = adapt(i - old, output.length + 1, old === 0)
|
|
230
|
+
n += Math.floor(i / (output.length + 1))
|
|
231
|
+
i %= output.length + 1
|
|
232
|
+
if (n > 0x10ffff) return undefined
|
|
233
|
+
output.splice(i++, 0, n)
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return String.fromCodePoint(...output)
|
|
237
|
+
} catch {
|
|
238
|
+
return undefined
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (S4 M2) Media in sessions: images a session shows that the console pulls on demand through the spoke op
|
|
3
|
+
* `timelineMedia` (feature "timelineMedia", announced, never implied). An image is named by its message and a ref:
|
|
4
|
+
*
|
|
5
|
+
* - `f<n>`: the n-th file attached to a user message (the same files `timelineFile` serves)
|
|
6
|
+
* - `t<part>.<item>`: content item `item` of the tool part at index `part` of an assistant message
|
|
7
|
+
* - `p:<base64url path>`: an image path written in an assistant message's text (``), exactly as written;
|
|
8
|
+
* the spoke re-reads the message and serves the file only under its path policy (src/spoke/media-policy.ts)
|
|
9
|
+
*
|
|
10
|
+
* The bytes travel in chunks of at most MEDIA_LIMITS.chunkBytes; the first answer (offset 0) carries the image's
|
|
11
|
+
* metadata, each answer the chunk at `offset` and whether it is the last.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MEDIA_LIMITS = {
|
|
15
|
+
/** Largest image served (decoded bytes). */
|
|
16
|
+
maxBytes: 8 * 1024 * 1024,
|
|
17
|
+
/** Most pixels (width × height) of a raster image. */
|
|
18
|
+
maxPixels: 40_000_000,
|
|
19
|
+
/** Widest or tallest raster image, in pixels. */
|
|
20
|
+
maxSide: 16_384,
|
|
21
|
+
/** Largest chunk of one answer (decoded bytes). */
|
|
22
|
+
chunkBytes: 512 * 1024,
|
|
23
|
+
/** Most media a timeline row lists. */
|
|
24
|
+
perRow: 8,
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
/** The image types served: magic bytes must match. SVG is served only as an attachment (never a document). */
|
|
28
|
+
export const MEDIA_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"] as const
|
|
29
|
+
export type MediaMime = (typeof MEDIA_MIMES)[number]
|
|
30
|
+
|
|
31
|
+
/** Longest path a `p:` ref carries (UTF-8 bytes). */
|
|
32
|
+
export const MEDIA_PATH_MAX = 4096
|
|
33
|
+
/** Longest ref (a `p:` ref of the longest path). */
|
|
34
|
+
export const MEDIA_REF_MAX = 2 + Math.ceil((MEDIA_PATH_MAX * 4) / 3) + 4
|
|
35
|
+
|
|
36
|
+
export type MediaRef =
|
|
37
|
+
| { readonly kind: "file"; readonly index: number }
|
|
38
|
+
| { readonly kind: "tool"; readonly part: number; readonly item: number }
|
|
39
|
+
| { readonly kind: "path"; readonly path: string }
|
|
40
|
+
|
|
41
|
+
/** What a served image is. `width`/`height`: from its header (raster types). */
|
|
42
|
+
export interface MediaInfo {
|
|
43
|
+
readonly mime: MediaMime
|
|
44
|
+
readonly bytes: number
|
|
45
|
+
/** Hex SHA-256 of the bytes. */
|
|
46
|
+
readonly sha256: string
|
|
47
|
+
readonly width?: number
|
|
48
|
+
readonly height?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MediaChunk {
|
|
52
|
+
readonly offset: number
|
|
53
|
+
/** Base64 of at most MEDIA_LIMITS.chunkBytes bytes. */
|
|
54
|
+
readonly data: string
|
|
55
|
+
readonly last: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Why an image is not served: not an allowed type, its message is hidden by a revert, too many pixels, a path outside the policy. */
|
|
59
|
+
export const MEDIA_REFUSALS = ["type", "hidden", "dimensions", "path"] as const
|
|
60
|
+
export type MediaRefusal = (typeof MEDIA_REFUSALS)[number]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A timelineMedia answer: `media` and `chunk` when served; `tooLarge` when it exceeds MEDIA_LIMITS.maxBytes;
|
|
64
|
+
* `refused` with the reason; nothing at all when there is no such image.
|
|
65
|
+
*/
|
|
66
|
+
export interface TimelineMediaResult {
|
|
67
|
+
readonly media?: MediaInfo
|
|
68
|
+
readonly chunk?: MediaChunk
|
|
69
|
+
readonly tooLarge?: { readonly bytes: number; readonly max: number }
|
|
70
|
+
readonly refused?: MediaRefusal
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const FILE_REF = /^f(0|[1-9]\d{0,2})$/
|
|
74
|
+
const TOOL_REF = /^t(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,2})$/
|
|
75
|
+
const PATH_REF = /^p:([A-Za-z0-9_-]+)$/
|
|
76
|
+
|
|
77
|
+
function base64url(bytes: Uint8Array): string {
|
|
78
|
+
let binary = ""
|
|
79
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
80
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fromBase64url(text: string): Uint8Array | undefined {
|
|
84
|
+
if (text.length % 4 === 1) return undefined
|
|
85
|
+
try {
|
|
86
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"))
|
|
87
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The `p:` ref of a path as written in a message. */
|
|
94
|
+
export function mediaPathRef(path: string): string {
|
|
95
|
+
return `p:${base64url(new TextEncoder().encode(path))}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A ref's parts, or undefined when it is not a well-formed ref. */
|
|
99
|
+
export function parseMediaRef(ref: string): MediaRef | undefined {
|
|
100
|
+
if (typeof ref !== "string" || ref.length > MEDIA_REF_MAX) return undefined
|
|
101
|
+
const file = FILE_REF.exec(ref)
|
|
102
|
+
if (file) return { kind: "file", index: Number(file[1]) }
|
|
103
|
+
const tool = TOOL_REF.exec(ref)
|
|
104
|
+
if (tool) return { kind: "tool", part: Number(tool[1]), item: Number(tool[2]) }
|
|
105
|
+
const path = PATH_REF.exec(ref)
|
|
106
|
+
if (!path) return undefined
|
|
107
|
+
const bytes = fromBase64url(path[1]!)
|
|
108
|
+
if (!bytes || bytes.length === 0 || bytes.length > MEDIA_PATH_MAX) return undefined
|
|
109
|
+
let text: string
|
|
110
|
+
try {
|
|
111
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
if (text.includes("\u0000") || mediaPathRef(text) !== ref) return undefined
|
|
116
|
+
return { kind: "path", path: text }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether `offset` is a valid chunk offset of a timelineMedia request. */
|
|
120
|
+
export const isMediaOffset = (offset: unknown): boolean => Number.isInteger(offset) && (offset as number) >= 0 && (offset as number) <= MEDIA_LIMITS.maxBytes
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (Session media §3, item 4b) Previews of local HTML files. The console names a page only by a path written in an
|
|
3
|
+
* assistant message (`htmlTargets`: a code span or a same-site path link ending in .html/.htm); the spoke re-reads the
|
|
4
|
+
* message, opens the page under the media path policy (src/spoke/media-policy.ts) and collects the files it refers to
|
|
5
|
+
* (src/spoke/preview-bundle.ts). Feature "previewBundle" (announced by the OpenCode spoke, never implied).
|
|
6
|
+
*
|
|
7
|
+
* Paging (a frame holds at most MAX_FRAME characters): the first request (`{sessionID, messageID, path}`) answers the
|
|
8
|
+
* bundle's manifest: `bundle` (its identity, a hex SHA-256 of the entry and every file's path, type and hash),
|
|
9
|
+
* `entry`, `files` and `skipped`. Files are inlined (`data`, base64) in manifest order while their decoded bytes fit
|
|
10
|
+
* in PREVIEW_BUNDLE_LIMITS.inlineBytes; every other file is read with `{bundle, file, offset}` requests, one chunk of at
|
|
11
|
+
* most chunkBytes each (`chunk: {offset, data, last}`). The spoke keeps the walked bundle for a minute; when it has to
|
|
12
|
+
* walk again and the bundle changed, a chunk request answers `stale: true` and the hub starts over.
|
|
13
|
+
*/
|
|
14
|
+
import type { Inline, MdBlock } from "./markdown.ts"
|
|
15
|
+
|
|
16
|
+
export const PREVIEW_BUNDLE_FEATURE = "previewBundle"
|
|
17
|
+
|
|
18
|
+
export const PREVIEW_BUNDLE_LIMITS = {
|
|
19
|
+
/** Most files in a bundle, the entry included. */
|
|
20
|
+
files: 200,
|
|
21
|
+
/** Largest file (the entry over it: tooLarge; an asset over it: skipped). */
|
|
22
|
+
fileBytes: 5 * 1024 * 1024,
|
|
23
|
+
/** Largest bundle (assets past it are skipped). */
|
|
24
|
+
totalBytes: 20 * 1024 * 1024,
|
|
25
|
+
/** Largest chunk of a chunk answer (decoded bytes). */
|
|
26
|
+
chunkBytes: 512 * 1024,
|
|
27
|
+
/** Decoded bytes inlined in the first answer. */
|
|
28
|
+
inlineBytes: 384 * 1024,
|
|
29
|
+
/** Longest path in a bundle (the artifact store's limit). */
|
|
30
|
+
bundlePathMax: 200,
|
|
31
|
+
/** Longest page path a request names. */
|
|
32
|
+
pathMax: 4096,
|
|
33
|
+
/** Longest reference listed as skipped (longer ones are cut). */
|
|
34
|
+
refMax: 300,
|
|
35
|
+
/** Most skipped references listed (`skippedMore` counts the rest). */
|
|
36
|
+
skipped: 200,
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
/** Why a referenced file is not in the bundle. */
|
|
40
|
+
export const PREVIEW_SKIP_REASONS = ["missing", "refused", "too_large", "limit", "name", "type"] as const
|
|
41
|
+
export type PreviewSkipReason = (typeof PREVIEW_SKIP_REASONS)[number]
|
|
42
|
+
|
|
43
|
+
/** Why the page itself is not served: not in the policy (or not in the message), not an HTML file, hidden by a revert. */
|
|
44
|
+
export const PREVIEW_BUNDLE_REFUSALS = ["path", "type", "hidden"] as const
|
|
45
|
+
export type PreviewBundleRefusal = (typeof PREVIEW_BUNDLE_REFUSALS)[number]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Media types by file extension, all of them artifact store types (ARTIFACT_MEDIA_TYPES: the store takes no other and
|
|
49
|
+
* never sniffs). A file of any other type (fonts, video, …) is left out of a bundle (skipped "type").
|
|
50
|
+
*/
|
|
51
|
+
export const PREVIEW_MEDIA_TYPES: Readonly<Record<string, string>> = {
|
|
52
|
+
html: "text/html",
|
|
53
|
+
htm: "text/html",
|
|
54
|
+
css: "text/css",
|
|
55
|
+
js: "text/javascript",
|
|
56
|
+
mjs: "text/javascript",
|
|
57
|
+
json: "application/json",
|
|
58
|
+
txt: "text/plain",
|
|
59
|
+
csv: "text/csv",
|
|
60
|
+
md: "text/markdown",
|
|
61
|
+
pdf: "application/pdf",
|
|
62
|
+
png: "image/png",
|
|
63
|
+
jpg: "image/jpeg",
|
|
64
|
+
jpeg: "image/jpeg",
|
|
65
|
+
gif: "image/gif",
|
|
66
|
+
webp: "image/webp",
|
|
67
|
+
svg: "image/svg+xml",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The media type a bundle file gets from its name (undefined: not one a bundle carries). */
|
|
71
|
+
export function previewMediaType(path: string): string | undefined {
|
|
72
|
+
const name = path.slice(path.lastIndexOf("/") + 1)
|
|
73
|
+
const dot = name.lastIndexOf(".")
|
|
74
|
+
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""
|
|
75
|
+
return Object.hasOwn(PREVIEW_MEDIA_TYPES, ext) ? PREVIEW_MEDIA_TYPES[ext] : undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MEDIA_TYPES = new Set(Object.values(PREVIEW_MEDIA_TYPES))
|
|
79
|
+
|
|
80
|
+
/** A path inside a bundle: relative, no `.`/`..` segments or `//`, the artifact host's characters, ≤ 200. */
|
|
81
|
+
export const isBundlePath = (v: unknown): v is string =>
|
|
82
|
+
typeof v === "string" && /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\/\/)(?!.*\/$)[A-Za-z0-9._/-]{1,200}$/.test(v)
|
|
83
|
+
|
|
84
|
+
export interface PreviewBundleFile {
|
|
85
|
+
readonly path: string
|
|
86
|
+
readonly mediaType: string
|
|
87
|
+
readonly bytes: number
|
|
88
|
+
/** Hex SHA-256 of the bytes. */
|
|
89
|
+
readonly sha256: string
|
|
90
|
+
/** Base64 of the bytes, when inlined in the first answer. */
|
|
91
|
+
readonly data?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PreviewBundleSkip {
|
|
95
|
+
/** The reference as written (without its query or fragment; cut at PREVIEW_BUNDLE_LIMITS.refMax). */
|
|
96
|
+
readonly path: string
|
|
97
|
+
readonly reason: PreviewSkipReason
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface PreviewBundleRequest {
|
|
101
|
+
readonly op: "previewBundle"
|
|
102
|
+
readonly sessionID: string
|
|
103
|
+
readonly messageID: string
|
|
104
|
+
/** The page as written in the message (a code span or link target). */
|
|
105
|
+
readonly path: string
|
|
106
|
+
/** A chunk request: the bundle's identity, the file's index in `files`, and the chunk's offset (all three or none). */
|
|
107
|
+
readonly bundle?: string
|
|
108
|
+
readonly file?: number
|
|
109
|
+
readonly offset?: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A previewBundle answer: the manifest (bundle, entry, files, skipped), a chunk, `stale`, `tooLarge` (the page itself
|
|
114
|
+
* is over fileBytes), `refused`, or nothing at all (no such message).
|
|
115
|
+
*/
|
|
116
|
+
export interface PreviewBundleResult {
|
|
117
|
+
readonly bundle?: string
|
|
118
|
+
readonly entry?: string
|
|
119
|
+
readonly files?: readonly PreviewBundleFile[]
|
|
120
|
+
readonly skipped?: readonly PreviewBundleSkip[]
|
|
121
|
+
readonly skippedMore?: number
|
|
122
|
+
readonly chunk?: { readonly offset: number; readonly data: string; readonly last: boolean }
|
|
123
|
+
readonly stale?: true
|
|
124
|
+
readonly tooLarge?: { readonly bytes: number; readonly max: number }
|
|
125
|
+
readonly refused?: PreviewBundleRefusal
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
129
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
130
|
+
const int = (v: unknown, min: number, max: number): v is number => Number.isSafeInteger(v) && (v as number) >= min && (v as number) <= max
|
|
131
|
+
|
|
132
|
+
/** A page path as a request carries it: 1–pathMax characters, no NUL or line break. */
|
|
133
|
+
export const isPreviewPath = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= PREVIEW_BUNDLE_LIMITS.pathMax && !/[\u0000\r\n]/.test(v)
|
|
134
|
+
|
|
135
|
+
/** Whether a previewBundle request is well-formed (index.ts parseServerMessage). */
|
|
136
|
+
export function isPreviewBundleRequest(v: Record<string, unknown>): boolean {
|
|
137
|
+
if (typeof v.sessionID !== "string" || !v.sessionID) return false
|
|
138
|
+
if (typeof v.messageID !== "string" || !v.messageID || v.messageID.length > 300) return false
|
|
139
|
+
if (!isPreviewPath(v.path)) return false
|
|
140
|
+
const chunk = [v.bundle, v.file, v.offset].filter((x) => x !== undefined).length
|
|
141
|
+
if (chunk === 0) return true
|
|
142
|
+
return chunk === 3 && typeof v.bundle === "string" && HEX64.test(v.bundle) && int(v.file, 0, PREVIEW_BUNDLE_LIMITS.files - 1) && int(v.offset, 0, PREVIEW_BUNDLE_LIMITS.fileBytes - 1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The problem with a previewBundle answer's shape (undefined: none). */
|
|
146
|
+
export function previewBundleResultProblem(v: unknown): string | undefined {
|
|
147
|
+
if (!isObj(v)) return "must be an object"
|
|
148
|
+
const manifest = v.bundle !== undefined || v.entry !== undefined || v.files !== undefined || v.skipped !== undefined
|
|
149
|
+
const kinds = [manifest, v.chunk !== undefined, v.stale !== undefined, v.tooLarge !== undefined, v.refused !== undefined].filter(Boolean).length
|
|
150
|
+
if (kinds > 1) return "carries more than one of a manifest, chunk, stale, tooLarge and refused"
|
|
151
|
+
if (v.refused !== undefined && !(PREVIEW_BUNDLE_REFUSALS as readonly unknown[]).includes(v.refused)) return "refused must be path, type or hidden"
|
|
152
|
+
if (v.stale !== undefined && v.stale !== true) return "stale must be true"
|
|
153
|
+
if (v.tooLarge !== undefined && !(isObj(v.tooLarge) && int(v.tooLarge.bytes, 0, Number.MAX_SAFE_INTEGER) && int(v.tooLarge.max, 0, Number.MAX_SAFE_INTEGER))) return "tooLarge needs bytes and max"
|
|
154
|
+
if (v.chunk !== undefined) {
|
|
155
|
+
const c = v.chunk
|
|
156
|
+
if (!isObj(c) || !int(c.offset, 0, PREVIEW_BUNDLE_LIMITS.fileBytes) || typeof c.data !== "string" || typeof c.last !== "boolean") return "chunk needs offset, data and last"
|
|
157
|
+
if (c.data.length > Math.ceil(PREVIEW_BUNDLE_LIMITS.chunkBytes / 3) * 4) return "chunk is larger than chunkBytes"
|
|
158
|
+
}
|
|
159
|
+
if (!manifest) return undefined
|
|
160
|
+
if (typeof v.bundle !== "string" || !HEX64.test(v.bundle)) return "bundle must be a hex SHA-256"
|
|
161
|
+
if (!Array.isArray(v.files) || v.files.length < 1 || v.files.length > PREVIEW_BUNDLE_LIMITS.files) return `files must list 1–${PREVIEW_BUNDLE_LIMITS.files} files`
|
|
162
|
+
const paths = new Set<string>()
|
|
163
|
+
for (const f of v.files as unknown[]) {
|
|
164
|
+
if (!isObj(f) || !isBundlePath(f.path)) return "every file needs a relative bundle path"
|
|
165
|
+
if (paths.has(f.path)) return `path ${f.path} appears twice`
|
|
166
|
+
paths.add(f.path)
|
|
167
|
+
if (typeof f.mediaType !== "string" || !MEDIA_TYPES.has(f.mediaType)) return "mediaType is not allowed"
|
|
168
|
+
if (!int(f.bytes, 0, PREVIEW_BUNDLE_LIMITS.fileBytes)) return `bytes must be 0–${PREVIEW_BUNDLE_LIMITS.fileBytes}`
|
|
169
|
+
if (typeof f.sha256 !== "string" || !HEX64.test(f.sha256)) return "sha256 must be hex"
|
|
170
|
+
if (f.data !== undefined && typeof f.data !== "string") return "data must be base64"
|
|
171
|
+
}
|
|
172
|
+
if (typeof v.entry !== "string" || !paths.has(v.entry)) return "entry must be one of the files"
|
|
173
|
+
if (v.skipped !== undefined) {
|
|
174
|
+
if (!Array.isArray(v.skipped) || v.skipped.length > PREVIEW_BUNDLE_LIMITS.skipped) return `skipped lists at most ${PREVIEW_BUNDLE_LIMITS.skipped}`
|
|
175
|
+
for (const s of v.skipped as unknown[]) {
|
|
176
|
+
if (!isObj(s) || typeof s.path !== "string" || s.path.length > PREVIEW_BUNDLE_LIMITS.refMax || !(PREVIEW_SKIP_REASONS as readonly unknown[]).includes(s.reason)) return "a skipped file needs its path and a reason"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (v.skippedMore !== undefined && !int(v.skippedMore, 1, Number.MAX_SAFE_INTEGER)) return "skippedMore must be a positive count"
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether a written target names a local HTML page: ends in .html/.htm (any case), no URL scheme but file:, no `~`
|
|
185
|
+
* (never expanded), at most pathMax characters, no NUL or line break.
|
|
186
|
+
*/
|
|
187
|
+
export function isHtmlTarget(target: string): boolean {
|
|
188
|
+
if (!isPreviewPath(target) || target.startsWith("~")) return false
|
|
189
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) && !/^file:/i.test(target)) return false
|
|
190
|
+
return /\.html?$/i.test(target)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The local HTML pages a parsed text names, in order, without duplicates: code spans and link targets (same-site
|
|
195
|
+
* links' `href`, other links' raw `target`: `./page.html`) through paragraphs, headings, quotes, lists and tables; never in code blocks. The console shows Preview on
|
|
196
|
+
* exactly these, the spoke serves only these (it also leaves out indented code and reads a text's first 64 KiB).
|
|
197
|
+
*/
|
|
198
|
+
export function htmlTargets(blocks: readonly MdBlock[], out: string[] = []): string[] {
|
|
199
|
+
const add = (t: string) => {
|
|
200
|
+
if (isHtmlTarget(t) && !out.includes(t)) out.push(t)
|
|
201
|
+
}
|
|
202
|
+
const inline = (nodes: readonly Inline[]) => {
|
|
203
|
+
for (const node of nodes) {
|
|
204
|
+
if (node.type === "code") add(node.text)
|
|
205
|
+
else if (node.type === "link") {
|
|
206
|
+
add(node.href)
|
|
207
|
+
inline(node.children)
|
|
208
|
+
} else if (node.type === "label") {
|
|
209
|
+
add(node.target)
|
|
210
|
+
inline(node.children)
|
|
211
|
+
} else if ("children" in node) inline(node.children)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const block of blocks) {
|
|
215
|
+
if (block.type === "paragraph" || block.type === "heading") inline(block.children)
|
|
216
|
+
else if (block.type === "quote") htmlTargets(block.blocks, out)
|
|
217
|
+
else if (block.type === "list") for (const item of block.items) htmlTargets(item.blocks, out)
|
|
218
|
+
else if (block.type === "table") for (const row of [block.head, ...block.rows]) for (const cell of row) inline(cell)
|
|
219
|
+
}
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
@@ -7,6 +7,13 @@ export const RECORDING_HEADER = "x-hub-recording" // on /api/transcri
|
|
|
7
7
|
export const RECORDING_SEGMENT_HEADER = "x-hub-segment" // on /api/transcribe: the segment number
|
|
8
8
|
export const RECORDING_REF_HEADER = "x-hub-tts-ref" // on /api/speak: "<messageID>:<ordinal>:<seq>:<i>"
|
|
9
9
|
export const RECORDING_AUDIO_RESULT_HEADER = "x-hub-recording-audio" // transcribe answer: RecordingAudioResult
|
|
10
|
+
/**
|
|
11
|
+
* Console kinds and values added after version 1, listed in the start answer (`features`): a console sends them only
|
|
12
|
+
* to a hub that lists them (an older hub refuses a whole batch with an unknown kind or value).
|
|
13
|
+
* "turn.predict": the turn.predict kind and utterance.sent reasons "smart" / "smart-fallback" (Smart Turn).
|
|
14
|
+
*/
|
|
15
|
+
export const RECORDING_FEATURES = ["turn.predict"] as const
|
|
16
|
+
export type RecordingFeature = (typeof RECORDING_FEATURES)[number]
|
|
10
17
|
|
|
11
18
|
export const RECORDING_LIMITS = {
|
|
12
19
|
retentionMs: 7 * 24 * 3_600_000,
|
|
@@ -82,6 +89,7 @@ export type ConsoleEventKind =
|
|
|
82
89
|
| "heard" | "say.received" | "tts.sentence.queued" | "tts.request" | "tts.first.byte" | "tts.sentence.played"
|
|
83
90
|
| "tts.sentence.ended" | "barge.in" | "cue.played" | "mic" | "control" | "turn.timing" | "decision.voice"
|
|
84
91
|
| "clock.sync" | "error"
|
|
92
|
+
| "turn.predict" // feature "turn.predict"
|
|
85
93
|
export type HubEventKind =
|
|
86
94
|
| "recording.started" | "recording.ended" | "conversation" | "stt.request" | "stt.result" | "tts.synth"
|
|
87
95
|
| "utterance.received" | "utterance.delivered" | "turn.started" | "first.token" | "say.sent" | "reply.full"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|