@huynle/hub 0.7.3 → 0.8.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 (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,325 @@
1
+ // Warms OpenCode servers so their hub plugin loads (RP-10, feedback qe6lpzxz #4; RF-04). OpenCode 2.x loads plugins
2
+ // per location, on the first request that names one, and its plugin API has no server-start hook: a freshly started
3
+ // server (`opencode service restart`, a login item, a foreground `opencode serve`) has no hub spoke, so its machine
4
+ // shows offline and host launches cannot be placed until a client opens a project; and OpenCode unloads a location
5
+ // after an hour without session activity (its experimental.location_idle_timeout), which takes the plugin with it.
6
+ // The node daemon asks each server for cheap location reads on every tick: GET /api/agent without a location (the
7
+ // server's default location), then GET /api/agent?location[directory]=<dir> for each project directory to keep
8
+ // loaded: node.json's list, else the projects of the server's recent root sessions under the node's workspace roots
9
+ // (at most WARM_DIRECTORIES_MAX), so a foreground server's spoke reports those projects' idle sessions.
10
+ // The background service is found through its registration file (readServiceRegistration: no request; not
11
+ // @opencode/client's Service.discover, which probes whatever URL the file names with the password and follows
12
+ // redirects); a foreground server through node.json `opencode.servers` (serverEndpoint: its URL and a password file).
13
+ // Security: only a literal loopback URL (127.0.0.1, [::1]; localhost is asked as 127.0.0.1, no resolver) without
14
+ // user info is ever asked, never across a redirect, so a password never leaves this machine; a password file must be
15
+ // a private regular file (no group/other access, not a link) and its content is never logged; logs carry the URL's
16
+ // origin only. Logs on changes only.
17
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"
18
+ import path from "node:path"
19
+ import { readFile } from "node:fs/promises"
20
+ import { listServiceSessions, type ListingClient, type RawProject, type RawSession } from "../../../src/spoke/listing.ts"
21
+
22
+ /** How often the daemon asks (a restarted server is warmed within this). */
23
+ export const WARM_INTERVAL_MS = 30_000
24
+ const WARM_TIMEOUT_MS = 10_000
25
+ /** Project locations warmed per server and tick, besides its default location. */
26
+ export const WARM_DIRECTORIES_MAX = 8
27
+ /** Automatic project locations: sessions updated within this many days, from one page of this many. */
28
+ const SESSION_DAYS = 7
29
+ const SESSION_PAGE = 100
30
+ /** Loopback hosts asked as they are (after URL normalization); localhost is rewritten to 127.0.0.1. */
31
+ const LOOPBACK = new Set(["127.0.0.1", "[::1]"])
32
+ const PASSWORD_FILE_MAX = 4096
33
+
34
+ /** A local OpenCode server (the service's registration, or a node.json server with its password). */
35
+ export interface WarmEndpoint {
36
+ readonly url: string
37
+ readonly auth?: { readonly type: "basic"; readonly username: string; readonly password: string } | undefined
38
+ /** Why the endpoint cannot be used (e.g. its password file); the tick fails with it and sends nothing. */
39
+ readonly problem?: string
40
+ }
41
+
42
+ export interface OpenCodeWarmerOptions {
43
+ /** The running server, or undefined when there is none (the service's registration file, or a node.json server). */
44
+ readonly discover: () => Promise<WarmEndpoint | undefined>
45
+ readonly log: (line: string) => void
46
+ readonly intervalMs?: number
47
+ readonly timeoutMs?: number
48
+ readonly fetch?: typeof fetch
49
+ /** What is warmed, in log lines (default "the OpenCode service"). */
50
+ readonly name?: string
51
+ /** Project directories to keep loaded besides the default location; replaces the automatic ones ([]: none). */
52
+ readonly directories?: readonly string[]
53
+ /** Automatic project directories (when `directories` is absent): recent sessions' projects under these roots. */
54
+ readonly roots?: () => readonly string[]
55
+ /** Whether a directory exists (default: stat); missing ones are not asked for. */
56
+ readonly isDirectory?: (directory: string) => boolean
57
+ }
58
+
59
+ export type WarmOutcome = "warmed" | "none" | "failed"
60
+
61
+ const isDirectory = (directory: string) => {
62
+ try {
63
+ return statSync(directory).isDirectory()
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
69
+ const trimSlash = (dir: string) => dir.replace(/\/+$/, "") || "/"
70
+ const within = (dir: string, root: string) => {
71
+ const r = trimSlash(root)
72
+ return dir === r || dir.startsWith(r === "/" ? "/" : `${r}/`)
73
+ }
74
+
75
+ /**
76
+ * A directory the server reported, normalized: absolute, without `.` or `..` segments (refused, not resolved: a
77
+ * server's `/roots/../etc` must not pass a prefix check), trailing slashes dropped; undefined otherwise.
78
+ */
79
+ function normalDirectory(dir: string): string | undefined {
80
+ if (typeof dir !== "string" || !path.isAbsolute(dir) || dir.split("/").some((part) => part === "." || part === "..")) return undefined
81
+ return path.resolve(dir)
82
+ }
83
+
84
+ export class OpenCodeWarmer {
85
+ /** What was last logged per topic, so only changes are logged. */
86
+ private readonly last = new Map<string, string>()
87
+ private timer?: ReturnType<typeof setTimeout>
88
+ private warmed: string[] = []
89
+
90
+ constructor(private readonly options: OpenCodeWarmerOptions) {}
91
+
92
+ /** The project directories the last tick loaded (besides the default location). */
93
+ warmedDirectories(): readonly string[] {
94
+ return this.warmed
95
+ }
96
+
97
+ /** One discovery and, when a server runs, its location reads. */
98
+ async tick(): Promise<WarmOutcome> {
99
+ this.warmed = []
100
+ let endpoint: WarmEndpoint | undefined
101
+ try {
102
+ endpoint = await this.options.discover()
103
+ } catch {
104
+ endpoint = undefined
105
+ }
106
+ if (!endpoint) return "none"
107
+ let registered: URL
108
+ try {
109
+ registered = new URL(endpoint.url)
110
+ } catch {
111
+ return this.report("the registered URL", "an invalid URL")
112
+ }
113
+ const label = registered.origin
114
+ if (registered.username || registered.password) return this.report(label, "a URL with user info")
115
+ if (registered.protocol !== "http:" && registered.protocol !== "https:") return this.report(label, "not an http URL")
116
+ const host = registered.hostname === "localhost" ? "127.0.0.1" : registered.hostname
117
+ if (!LOOPBACK.has(host)) return this.report(label, "not a loopback URL")
118
+ if (endpoint.problem) return this.report(label, endpoint.problem)
119
+ const base = `${registered.protocol}//${host}${registered.port ? `:${registered.port}` : ""}`
120
+ const headers: Record<string, string> = endpoint.auth ? { authorization: `Basic ${btoa(`${endpoint.auth.username}:${endpoint.auth.password}`)}` } : {}
121
+ const problem = await this.get(`${base}/api/agent`, headers).then(() => undefined, (cause: Error) => cause.message)
122
+ if (problem) return this.report(label, problem)
123
+ this.report(label)
124
+ await this.warmDirectories(base, headers, label)
125
+ return "warmed"
126
+ }
127
+
128
+ /** Ticks now and every intervalMs until `signal` aborts. */
129
+ start(signal: AbortSignal): void {
130
+ const loop = async () => {
131
+ if (signal.aborted) return
132
+ await this.tick()
133
+ if (!signal.aborted) this.timer = setTimeout(loop, this.options.intervalMs ?? WARM_INTERVAL_MS)
134
+ }
135
+ signal.addEventListener("abort", () => clearTimeout(this.timer), { once: true })
136
+ void loop()
137
+ }
138
+
139
+ /** Loads each project location (listed, else automatic); problems are logged on change, never fatal. */
140
+ private async warmDirectories(base: string, headers: Record<string, string>, label: string): Promise<void> {
141
+ let directories: readonly string[]
142
+ if (this.options.directories) directories = this.options.directories
143
+ else if (this.options.roots) {
144
+ try {
145
+ directories = await this.sessionProjects(base, headers, this.options.roots())
146
+ this.note("list", label)
147
+ } catch (cause) {
148
+ this.note("list", label, `could not list the sessions of ${this.name()} at ${label} (${(cause as Error).message}); warming its default location only`)
149
+ directories = []
150
+ }
151
+ } else directories = []
152
+ const exists = this.options.isDirectory ?? isDirectory
153
+ const failed: string[] = []
154
+ for (const directory of directories.slice(0, WARM_DIRECTORIES_MAX)) {
155
+ if (!exists(directory)) continue
156
+ try {
157
+ await this.get(`${base}/api/agent?${new URLSearchParams({ "location[directory]": directory })}`, headers)
158
+ this.warmed.push(directory)
159
+ } catch (cause) {
160
+ failed.push(`${directory} (${(cause as Error).message})`)
161
+ }
162
+ }
163
+ this.note("directories", label, failed.length ? `could not load ${failed.length} project location(s) of ${this.name()} at ${label}: ${failed.join(", ")}` : undefined)
164
+ }
165
+
166
+ /** The projects of the server's recent root sessions under `roots`, newest first. */
167
+ private async sessionProjects(base: string, headers: Record<string, string>, roots: readonly string[]): Promise<string[]> {
168
+ if (roots.length === 0) return []
169
+ const client: ListingClient = {
170
+ session: {
171
+ list: async (input) => {
172
+ const query = new URLSearchParams({ limit: String(input.limit ?? SESSION_PAGE), ...(input.cursor ? { cursor: input.cursor } : {}) })
173
+ const page = (await this.json(`${base}/api/session?${query}`, headers)) as { data?: RawSession[]; cursor?: { next?: string | null } }
174
+ if (!page || !Array.isArray(page.data)) throw new Error("not a session list")
175
+ return { data: page.data, cursor: { next: null } }
176
+ },
177
+ },
178
+ project: { list: async () => (await this.json(`${base}/api/project`, headers)) as RawProject[] },
179
+ }
180
+ const sessions = await listServiceSessions(client, { days: SESSION_DAYS, max: SESSION_PAGE, pageSize: SESSION_PAGE, maxPages: 1 })
181
+ const out: string[] = []
182
+ for (const s of sessions) {
183
+ const project = normalDirectory(s.project)
184
+ if (!project || project === "/" || !roots.some((root) => within(project, path.resolve(root))) || out.includes(project)) continue
185
+ out.push(project)
186
+ if (out.length >= WARM_DIRECTORIES_MAX) break
187
+ }
188
+ return out
189
+ }
190
+
191
+ private async json(url: string, headers: Record<string, string>): Promise<unknown> {
192
+ const response = await this.request(url, headers)
193
+ return response.json()
194
+ }
195
+
196
+ private async get(url: string, headers: Record<string, string>): Promise<void> {
197
+ const response = await this.request(url, headers)
198
+ void response.body?.cancel().catch(() => {})
199
+ }
200
+
201
+ /** One request: no redirect followed (a 3xx is a failure), bounded in time. Errors carry no URL or credentials. */
202
+ private async request(url: string, headers: Record<string, string>): Promise<Response> {
203
+ let response: Response
204
+ try {
205
+ response = await (this.options.fetch ?? fetch)(url, { headers, redirect: "manual", signal: AbortSignal.timeout(this.options.timeoutMs ?? WARM_TIMEOUT_MS) })
206
+ } catch (cause) {
207
+ throw new Error((cause as Error).name === "TimeoutError" ? "timed out" : "unreachable")
208
+ }
209
+ if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
210
+ void response.body?.cancel().catch(() => {})
211
+ throw new Error(`a redirect (HTTP ${response.status}), not followed`)
212
+ }
213
+ if (!response.ok) {
214
+ void response.body?.cancel().catch(() => {})
215
+ throw new Error(`HTTP ${response.status}`)
216
+ }
217
+ return response
218
+ }
219
+
220
+ private name(): string {
221
+ return this.options.name ?? "the OpenCode service"
222
+ }
223
+
224
+ /** Logs `line` when a topic's state changed (undefined: fine now, logged only as a change back to fine). */
225
+ private note(topic: string, label: string, line?: string): void {
226
+ const state = line ?? `ok ${label}`
227
+ if (this.last.get(topic) === state) return
228
+ const before = this.last.get(topic)
229
+ this.last.set(topic, state)
230
+ if (line) this.options.log(line)
231
+ else if (before !== undefined && !before.startsWith("ok ")) this.options.log(`${topic === "list" ? "listing the sessions" : "loading the project locations"} of ${this.name()} at ${label} works again`)
232
+ }
233
+
234
+ private report(url: string, problem?: string): WarmOutcome {
235
+ const line = problem ? `failed ${url}: ${problem}` : `ok ${url}`
236
+ if (line !== this.last.get("warm")) {
237
+ this.last.set("warm", line)
238
+ this.options.log(problem ? `could not warm ${this.name()} at ${url} (${problem}); retrying` : `warmed ${this.name()} at ${url}: its hub plugin loads, so its spoke connects`)
239
+ }
240
+ return problem ? "failed" : "warmed"
241
+ }
242
+ }
243
+
244
+ /**
245
+ * OpenCode's background service from its registration file (service.json: {url, pid, password?, id?, version?}),
246
+ * without any request: undefined when the file is missing or malformed or its process is gone. The URL is passed on
247
+ * as it is; OpenCodeWarmer.tick decides whether it may be asked.
248
+ */
249
+ export async function readServiceRegistration(file: string): Promise<WarmEndpoint | undefined> {
250
+ let info: unknown
251
+ try {
252
+ info = JSON.parse(await readFile(file, "utf8"))
253
+ } catch {
254
+ return undefined
255
+ }
256
+ if (!info || typeof info !== "object") return undefined
257
+ const { url, pid, password } = info as Record<string, unknown>
258
+ if (typeof url !== "string" || url.length > 2048 || typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return undefined
259
+ try {
260
+ process.kill(pid, 0)
261
+ } catch (cause) {
262
+ if ((cause as NodeJS.ErrnoException).code !== "EPERM") return undefined
263
+ }
264
+ return typeof password === "string" ? { url, auth: { type: "basic", username: "opencode", password } } : { url }
265
+ }
266
+
267
+ /** Pseudo-file systems: their files report size 0 yet read data (/proc/self/environ), or are devices. */
268
+ const PSEUDO_ROOTS = ["/proc", "/sys", "/dev"]
269
+
270
+ /**
271
+ * A server password from a file (its first line): only a private regular file (not a link, FIFO, device or socket,
272
+ * nothing under /proc, /sys or /dev, no symbolic link in its directory path, no group or other access, owned by this
273
+ * user) of 1 byte to 4 KiB. Never blocks: the file is checked with lstat before it is opened, and opened with
274
+ * O_NONBLOCK and O_NOFOLLOW (then checked again on the descriptor). Problems never include the content.
275
+ */
276
+ export function readPasswordFile(file: string): { password: string } | { problem: string } {
277
+ if (!path.isAbsolute(file)) return { problem: `password file ${file} is not an absolute path` }
278
+ const resolved = path.resolve(file)
279
+ if (PSEUDO_ROOTS.some((root) => resolved === root || resolved.startsWith(`${root}/`))) return { problem: `password file ${file} is not a regular file` }
280
+ try {
281
+ const dir = path.dirname(resolved)
282
+ if (realpathSync(dir) !== dir) return { problem: `password file ${file} is reached through a symbolic link (use its real path)` }
283
+ } catch {
284
+ return { problem: `password file ${file} does not exist` }
285
+ }
286
+ let before: Stats
287
+ try {
288
+ before = lstatSync(resolved)
289
+ } catch (cause) {
290
+ return { problem: `password file ${file} ${(cause as NodeJS.ErrnoException).code === "ENOENT" ? "does not exist" : "cannot be read"}` }
291
+ }
292
+ if (before.isSymbolicLink()) return { problem: `password file ${file} is a symbolic link` }
293
+ if (!before.isFile()) return { problem: `password file ${file} is not a regular file` }
294
+ let fd: number
295
+ try {
296
+ fd = openSync(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
297
+ } catch (cause) {
298
+ const code = (cause as NodeJS.ErrnoException).code
299
+ return { problem: `password file ${file} ${code === "ELOOP" ? "is a symbolic link" : code === "ENOENT" ? "does not exist" : "cannot be read"}` }
300
+ }
301
+ try {
302
+ const stat = fstatSync(fd)
303
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return { problem: `password file ${file} is not a regular file` }
304
+ if ((stat.mode & 0o077) !== 0) return { problem: `password file ${file} is readable by others (chmod 600 it)` }
305
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return { problem: `password file ${file} is not owned by this user` }
306
+ if (stat.size === 0) return { problem: `password file ${file} is empty` }
307
+ if (stat.size > PASSWORD_FILE_MAX) return { problem: `password file ${file} is too large` }
308
+ const buffer = Buffer.alloc(PASSWORD_FILE_MAX + 1)
309
+ const length = readSync(fd, buffer, 0, buffer.length, 0)
310
+ if (length > stat.size || length > PASSWORD_FILE_MAX) return { problem: `password file ${file} is too large` }
311
+ const password = buffer.subarray(0, length).toString("utf8").split(/\r?\n/)[0]!.trim()
312
+ if (!password) return { problem: `password file ${file} is empty` }
313
+ return { password }
314
+ } finally {
315
+ closeSync(fd)
316
+ }
317
+ }
318
+
319
+ /** A node.json server's endpoint: its URL with the password read from its file on every discovery (rotation). */
320
+ export function serverEndpoint(server: { readonly url: string; readonly passwordFile: string }): () => Promise<WarmEndpoint> {
321
+ return async () => {
322
+ const read = readPasswordFile(server.passwordFile)
323
+ return "password" in read ? { url: server.url, auth: { type: "basic", username: "opencode", password: read.password } } : { url: server.url, problem: read.problem }
324
+ }
325
+ }
@@ -3,10 +3,13 @@
3
3
  // also offers the openshell runtime (sandboxes through the openshell CLI): it checks the CLI, asks the gateway for
4
4
  // its backends and sweeps this machine's sandboxes from an earlier run into the hello; an unusable CLI is logged and
5
5
  // the node runs with the host runtime only. The host runtime offers the harnesses found on this machine
6
- // (harnesses.ts: opencode, claude-code with its CLI, node.json `harnesses` overriding). Exit codes: 0 after a clean stop, 1 when
6
+ // (harnesses.ts: opencode, claude-code with its CLI, node.json `harnesses` overriding). While it offers opencode, the
7
+ // node keeps OpenCode's background service and node.json's foreground servers warm (opencode-warm.ts) so their hub
8
+ // plugin loads without a client opening a project. Exit codes: 0 after a clean stop, 1 when
7
9
  // the machine is not enrolled, EXIT_CONFIG (78, EX_CONFIG) when node.json gives the node nothing to do; the
8
10
  // service units do not restart on 78.
9
11
  import { homedir } from "node:os"
12
+ import path from "node:path"
10
13
  import type { NodeLaunchReport } from "../../protocol/src/index.ts"
11
14
  import { OpenShellCli } from "../../runtime-openshell/src/cli.ts"
12
15
  import type { Runtime } from "../../runtime-sdk/src/index.ts"
@@ -14,6 +17,7 @@ import { nodeConfigPath, readNodeConfig, readSpokeCredentials, type NodeConfig,
14
17
  import { NodeDaemon, NODE_VERSION } from "./daemon.ts"
15
18
  import { detectHarnesses } from "./harnesses.ts"
16
19
  import { HostRuntime } from "./host.ts"
20
+ import { OpenCodeWarmer, readServiceRegistration, serverEndpoint, type WarmEndpoint } from "./opencode-warm.ts"
17
21
  import { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
18
22
 
19
23
  export const EXIT_CONFIG = 78
@@ -29,6 +33,27 @@ export interface RunNodeOptions {
29
33
  readonly signal: AbortSignal
30
34
  /** The openshell executable (default "openshell" on PATH). */
31
35
  readonly openshellBinary?: string
36
+ /** Finds OpenCode's background service (default: readServiceRegistration on this env's registration file). */
37
+ readonly openCodeService?: () => Promise<WarmEndpoint | undefined>
38
+ }
39
+
40
+ /** OpenCode's service registration file for `env` and `home` (@opencode/client's default location). */
41
+ export const openCodeServiceFile = (env: Env, home: string): string =>
42
+ path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "opencode", "service.json")
43
+
44
+
45
+ /**
46
+ * Keeps OpenCode's hub plugin loaded (opencode-warm.ts): the background service and each node.json
47
+ * `opencode.servers` entry (a foreground `opencode serve`), at their default location and project directories
48
+ * (listed, else the projects of their recent sessions under the workspace roots).
49
+ */
50
+ function startOpenCodeWarmers(config: NodeConfig, service: () => Promise<WarmEndpoint | undefined>, log: (line: string) => void, signal: AbortSignal): void {
51
+ const roots = () => config.workspaceRoots
52
+ const directories = config.opencode?.directories
53
+ new OpenCodeWarmer({ discover: service, log, roots, ...(directories ? { directories } : {}) }).start(signal)
54
+ for (const server of config.opencode?.servers ?? []) {
55
+ new OpenCodeWarmer({ discover: serverEndpoint(server), log, name: "the OpenCode server", roots, ...(server.directories ? { directories: server.directories } : {}) }).start(signal)
56
+ }
32
57
  }
33
58
 
34
59
  interface OpenShellSetup {
@@ -96,6 +121,7 @@ export async function runNode(options: RunNodeOptions): Promise<number> {
96
121
  })
97
122
  log(`hub node ${NODE_VERSION}: ${config.slots} slots, roots ${config.workspaceRoots.join(", ")}; host harnesses ${harnesses.join(", ") || "none"}`)
98
123
  daemon.start()
124
+ if (harnesses.includes("opencode")) startOpenCodeWarmers(config, options.openCodeService ?? (() => readServiceRegistration(openCodeServiceFile(env, home))), log, options.signal)
99
125
  await new Promise<void>((resolve) => {
100
126
  if (options.signal.aborted) return resolve()
101
127
  options.signal.addEventListener("abort", () => resolve(), { once: true })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -0,0 +1,34 @@
1
+ // Profile application on preset sessions (HWA-18; protocol packages/protocol/src/profiles.ts). A preset session is a
2
+ // terminal the spoke pastes into (evidence "screen"): there is no session-scoped place for instructions, skills or
3
+ // commands, the model is fixed by the harness, and the spoke could not confirm any change took effect. Typing a
4
+ // profile into the conversation would be a prompt, not a profile, so nothing is ever pasted.
5
+ //
6
+ // The spoke still announces profileApply so the answer is explicit: every known session's request is answered
7
+ // "unsupported" with one note per part and PRESET_PROFILE_NOTE; a mismatched digest or an unknown session is
8
+ // "rejected". Command-only support (rendering a command and pasting it at invocation) is not offered: on screen
9
+ // evidence it cannot be verified.
10
+ import type { ProfileApplyRequest, ProfileApplyResult } from "../../protocol/src/index.ts"
11
+ import { planProfileApplication, profileApplyResult, profileDigest, type ProfileCapabilities } from "../../spoke-sdk/src/index.ts"
12
+
13
+ /** What the preset spoke can apply of a profile to a running harness: nothing. */
14
+ export const PRESET_PROFILE_CAPABILITIES: ProfileCapabilities = Object.freeze({
15
+ instructions: false,
16
+ skills: false,
17
+ commands: false,
18
+ model: false,
19
+ live: false,
20
+ renderAtInvocation: false,
21
+ })
22
+
23
+ export const PRESET_PROFILE_NOTE =
24
+ "preset sessions are terminals the spoke pastes into: a running harness's instructions, skills, commands or model cannot be changed without typing into the conversation, and no change could be confirmed (screen evidence)"
25
+
26
+ /** The `profileApply` op handler for a preset spoke whose sessions are `has`. */
27
+ export function presetProfileApply(has: (sessionID: string) => boolean): (req: ProfileApplyRequest) => Promise<ProfileApplyResult> {
28
+ return async (req) => {
29
+ if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
30
+ if (!has(req.sessionID)) return profileApplyResult(req, "rejected", [`unknown session ${req.sessionID}`])
31
+ const plan = planProfileApplication(req, { capabilities: PRESET_PROFILE_CAPABILITIES })
32
+ return profileApplyResult(req, "unsupported", [...plan.notes, PRESET_PROFILE_NOTE])
33
+ }
34
+ }
@@ -8,6 +8,7 @@
8
8
  // is "retained" (nothing is typed into the user's input line) and pasted ahead of the session's next turn or
9
9
  // message; a prompt (hubPrompt) goes in its provenance envelope;
10
10
  // - interrupt and stop: Ctrl-C, only when the backend can send keys;
11
+ // - profileApply: answered "unsupported" with notes (or "rejected"), never pasted (profile.ts);
11
12
  // - never permission, form or steer (a terminal paste can answer neither a dialog nor a running turn reliably);
12
13
  // - never an event: no activity, and above all no idle, is inferred from the screen (the hub never treats screen
13
14
  // evidence as idle anyway). The screen is only sampled for the harness's native session ID (source screen) and the
@@ -21,6 +22,7 @@ import path from "node:path"
21
22
  import type { HarnessPreset, SessionInfo } from "../../protocol/src/index.ts"
22
23
  import { deliverText, HarnessSpoke, hubSessionID, type Admission, type DeliverRequest, type HarnessSpokeOptions, type OpHandlers } from "../../spoke-sdk/src/index.ts"
23
24
  import { launchArgv, nativeIDFromScreen } from "./presets.ts"
25
+ import { presetProfileApply } from "./profile.ts"
24
26
  import type { TerminalBackend, TerminalPane } from "./tmux.ts"
25
27
 
26
28
  /** The hub session ID namespace of preset sessions (ses_pr…). */
@@ -111,6 +113,8 @@ export class PresetSpoke {
111
113
  await this.#submit(this.#session(req.sessionID), req.text)
112
114
  return { outcome: "timeout", reply: "" }
113
115
  },
116
+ // Answered explicitly, never applied (profile.ts): unsupported with notes, or rejected.
117
+ profileApply: presetProfileApply((id) => this.#sessions.has(id)),
114
118
  ...(this.#backend.canSendKeys
115
119
  ? {
116
120
  interrupt: async (req: { sessionID: string }) => (await this.#interrupt(req.sessionID), {}),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -0,0 +1,142 @@
1
+ // Persistent artifacts (HWA-02 contract; metadata/revisions + BlobStore interface HWA-20, standalone blobs and
2
+ // transfer API HWA-21, cluster blobs HWA-22, viewers HWA-23, AI jobs HWA-24/25, isolated app host HWA-26/27).
3
+ //
4
+ // Invariants frozen here:
5
+ // - Blobs are content-addressed (sha256) and immutable; revisions reference blobs; metadata lives in the state
6
+ // store. Standalone keeps blobs as files under the hub data directory; a cluster keeps them in a chunked
7
+ // Postgres table (decision tctkrik8). Nothing lives in ephemeral session or launch paths.
8
+ // - Access: private to the owner by default. Visibility widens only explicitly; every read checks it. AI actions
9
+ // run with the viewer's own authority (no privilege laundering through the artifact or its job session).
10
+ // - Interactive artifacts ("app" kind) run only in the isolated artifact host (separate origin, sandboxed frame,
11
+ // CSP, capability bridge); never inside the console bundle, never as a console plugin, never in central.
12
+ // - Concurrent revisions: a new revision names `baseRevisionID`; a stale base is 409 `conflict` (no silent merge).
13
+ import { DESCRIPTION_MAX, isContractID, isLabel, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
14
+ import { isObj } from "./wire.ts"
15
+
16
+ export const ARTIFACT_LIMITS = {
17
+ artifactsPerUser: 5_000,
18
+ revisionsPerArtifact: 500,
19
+ /** Bytes of one blob and of all of one user's blobs. */
20
+ blobBytes: 25 * 1024 * 1024,
21
+ userBlobBytes: 2 * 1024 * 1024 * 1024,
22
+ /** Files in a multi-file ("bundle") revision, e.g. an app. */
23
+ bundleFiles: 200,
24
+ /** Chunk size of the cluster Postgres blob table. */
25
+ chunkBytes: 1024 * 1024,
26
+ /** Concurrent AI jobs per user and per artifact. */
27
+ aiJobsPerUser: 8,
28
+ aiJobsPerArtifact: 2,
29
+ /** Characters of an AI job instruction. */
30
+ aiInstructionChars: 8_000,
31
+ } as const
32
+
33
+ export const ARTIFACT_SCOPES = ["artifacts:read", "artifacts:write", "artifacts:ai"] as const
34
+
35
+ /** What renders an artifact. Declarative kinds render in the console; "app" only in the isolated host. */
36
+ export const ARTIFACT_KINDS = ["markdown", "code", "image", "table", "chart", "dashboard", "html", "app", "file"] as const
37
+ export type ArtifactKind = (typeof ARTIFACT_KINDS)[number]
38
+ export const ISOLATED_KINDS: readonly ArtifactKind[] = ["html", "app"]
39
+
40
+ export type ArtifactVisibility = "private" | "workspace"
41
+
42
+ export interface Artifact extends Revisioned {
43
+ readonly v: 1
44
+ readonly id: string
45
+ readonly title: string
46
+ readonly description?: string
47
+ readonly kind: ArtifactKind
48
+ readonly state: RecordState
49
+ /** "private": owner only; "workspace": the owner plus sessions that are current members of workspaceID. */
50
+ readonly visibility: ArtifactVisibility
51
+ readonly workspaceID?: string
52
+ /** Session that created it (provenance only; grants nothing). */
53
+ readonly createdBySessionID?: string
54
+ readonly latestRevisionID: string
55
+ readonly tags?: readonly string[]
56
+ readonly metadata?: Readonly<Record<string, string>>
57
+ readonly createdAt: number
58
+ readonly updatedAt: number
59
+ }
60
+
61
+ export interface BlobRef {
62
+ /** `sha256:<64 hex>`. */
63
+ readonly hash: string
64
+ readonly bytes: number
65
+ /** A media type from a fixed allowlist (the transfer API rejects others). */
66
+ readonly mediaType: string
67
+ /** Path inside a bundle revision; absent for a single-file revision. */
68
+ readonly path?: string
69
+ }
70
+
71
+ export interface ArtifactRevision {
72
+ readonly v: 1
73
+ readonly id: string
74
+ readonly artifactID: string
75
+ readonly number: number
76
+ readonly baseRevisionID?: string
77
+ readonly blobs: readonly BlobRef[]
78
+ /** Entry file of a bundle (apps, html). */
79
+ readonly entry?: string
80
+ readonly author: { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string } | { readonly kind: "ai_job"; readonly jobID: string }
81
+ readonly createdAt: number
82
+ }
83
+
84
+ export type AiJobState = "queued" | "running" | "succeeded" | "failed" | "cancelled"
85
+
86
+ /** An agent-backed AI action on an artifact, durable across hub restarts; runs as the requesting viewer. */
87
+ export interface ArtifactAiJob extends Revisioned {
88
+ readonly v: 1
89
+ readonly id: string
90
+ readonly artifactID: string
91
+ readonly baseRevisionID: string
92
+ readonly requestedBy: { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string }
93
+ readonly state: AiJobState
94
+ /** Launch or session doing the work (when started). */
95
+ readonly launchID?: string
96
+ readonly sessionID?: string
97
+ readonly resultRevisionID?: string
98
+ readonly error?: string
99
+ readonly createdAt: number
100
+ readonly updatedAt: number
101
+ }
102
+
103
+ export const ARTIFACT_EVENT_TYPES = ["artifact.created", "artifact.revised", "artifact.archived", "artifact.visibility", "artifact.job"] as const
104
+
105
+ /** Media types the transfer API accepts. Anything else is rejected, never sniffed. */
106
+ export const ARTIFACT_MEDIA_TYPES = [
107
+ "text/markdown",
108
+ "text/plain",
109
+ "text/csv",
110
+ "text/html",
111
+ "text/css",
112
+ "text/javascript",
113
+ "application/json",
114
+ "application/pdf",
115
+ "image/png",
116
+ "image/jpeg",
117
+ "image/gif",
118
+ "image/webp",
119
+ "image/svg+xml",
120
+ ] as const
121
+
122
+ export const isBlobHash = (v: unknown): v is string => typeof v === "string" && /^sha256:[0-9a-f]{64}$/.test(v)
123
+
124
+ export function artifactInputProblem(v: unknown): string | undefined {
125
+ if (!isObj(v)) return "body must be an object"
126
+ if (!isLabel(v.title)) return "title must be 1–120 characters of text"
127
+ if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
128
+ if (!ARTIFACT_KINDS.includes(v.kind as ArtifactKind)) return `kind must be one of ${ARTIFACT_KINDS.join(", ")}`
129
+ if (v.visibility !== undefined && v.visibility !== "private" && v.visibility !== "workspace") return "visibility must be private or workspace"
130
+ if (v.visibility === "workspace" && !isContractID("workspace", v.workspaceID)) return "workspace visibility needs a workspaceID"
131
+ if (v.workspaceID !== undefined && !isContractID("workspace", v.workspaceID)) return "workspaceID must be a workspace ID"
132
+ return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
133
+ }
134
+
135
+ export function blobRefProblem(v: unknown): string | undefined {
136
+ if (!isObj(v)) return "blob must be an object"
137
+ if (!isBlobHash(v.hash)) return "hash must be sha256:<64 hex>"
138
+ if (typeof v.bytes !== "number" || !Number.isSafeInteger(v.bytes) || v.bytes < 0 || v.bytes > ARTIFACT_LIMITS.blobBytes) return `bytes must be 0–${ARTIFACT_LIMITS.blobBytes}`
139
+ if (!ARTIFACT_MEDIA_TYPES.includes(v.mediaType as (typeof ARTIFACT_MEDIA_TYPES)[number])) return "mediaType is not allowed"
140
+ if (v.path !== undefined && (typeof v.path !== "string" || !/^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[A-Za-z0-9._/-]{1,200}$/.test(v.path))) return "path must be a relative path without . or .. segments"
141
+ return undefined
142
+ }