@vellumai/assistant 0.6.1 → 0.6.3

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 (463) hide show
  1. package/bun.lock +40 -40
  2. package/bunfig.toml +3 -0
  3. package/docker-entrypoint.sh +12 -2
  4. package/docs/architecture/memory.md +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  6. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +42 -0
  7. package/openapi.yaml +184 -69
  8. package/package.json +41 -41
  9. package/scripts/generate-openapi.ts +1 -2
  10. package/src/__tests__/acp-session.test.ts +43 -0
  11. package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +1 -0
  13. package/src/__tests__/app-source-watcher.test.ts +37 -11
  14. package/src/__tests__/approval-routes-http.test.ts +178 -1
  15. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  16. package/src/__tests__/browser-fill-credential.test.ts +229 -94
  17. package/src/__tests__/browser-manager.test.ts +40 -27
  18. package/src/__tests__/catalog-files.test.ts +862 -0
  19. package/src/__tests__/channel-approvals.test.ts +53 -0
  20. package/src/__tests__/checker.test.ts +104 -170
  21. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  22. package/src/__tests__/config-managed-gemini-defaults.test.ts +326 -0
  23. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  24. package/src/__tests__/config-schema.test.ts +125 -48
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +23 -0
  26. package/src/__tests__/context-overflow-approval.test.ts +21 -6
  27. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  28. package/src/__tests__/conversation-agent-loop.test.ts +1 -1
  29. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  30. package/src/__tests__/conversation-attachments.test.ts +80 -4
  31. package/src/__tests__/conversation-confirmation-signals.test.ts +155 -0
  32. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  33. package/src/__tests__/conversation-fork-crud.test.ts +17 -0
  34. package/src/__tests__/conversation-history-web-search.test.ts +1 -0
  35. package/src/__tests__/conversation-host-access-routes.test.ts +229 -0
  36. package/src/__tests__/conversation-inject-context.test.ts +103 -0
  37. package/src/__tests__/conversation-queue.test.ts +45 -2
  38. package/src/__tests__/conversation-routes-disk-view.test.ts +5 -0
  39. package/src/__tests__/conversation-routes-guardian-reply.test.ts +16 -0
  40. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  41. package/src/__tests__/conversation-runtime-assembly.test.ts +269 -46
  42. package/src/__tests__/conversation-starter-routes.test.ts +126 -0
  43. package/src/__tests__/conversation-starters-cadence.test.ts +161 -0
  44. package/src/__tests__/conversation-store.test.ts +195 -0
  45. package/src/__tests__/conversation-workspace-cache-state.test.ts +193 -0
  46. package/src/__tests__/credential-execution-approval-bridge.test.ts +32 -3
  47. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  48. package/src/__tests__/credential-vault-unit.test.ts +4 -4
  49. package/src/__tests__/credential-vault.test.ts +152 -13
  50. package/src/__tests__/credentials-cli.test.ts +2 -2
  51. package/src/__tests__/date-context.test.ts +4 -4
  52. package/src/__tests__/embedding-managed-proxy-selection.test.ts +256 -0
  53. package/src/__tests__/extension-id-sync-guard.test.ts +155 -0
  54. package/src/__tests__/fixtures/mock-chrome-extension.ts +375 -0
  55. package/src/__tests__/gateway-only-guard.test.ts +3 -0
  56. package/src/__tests__/gemini-provider.test.ts +2 -2
  57. package/src/__tests__/guardian-routing-invariants.test.ts +70 -2
  58. package/src/__tests__/headless-browser-interactions.test.ts +707 -371
  59. package/src/__tests__/headless-browser-navigate.test.ts +389 -47
  60. package/src/__tests__/headless-browser-read-tools.test.ts +266 -103
  61. package/src/__tests__/headless-browser-snapshot.test.ts +240 -77
  62. package/src/__tests__/host-bash-proxy.test.ts +150 -1
  63. package/src/__tests__/host-browser-e2e-cloud.test.ts +462 -0
  64. package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +286 -0
  65. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +374 -0
  66. package/src/__tests__/host-browser-event-routes.test.ts +350 -0
  67. package/src/__tests__/host-browser-proxy.test.ts +444 -0
  68. package/src/__tests__/host-browser-routes.test.ts +198 -0
  69. package/src/__tests__/host-browser-ws-events-e2e.test.ts +320 -0
  70. package/src/__tests__/host-cu-proxy.test.ts +171 -1
  71. package/src/__tests__/host-file-proxy.test.ts +185 -1
  72. package/src/__tests__/host-file-read-tool.test.ts +52 -0
  73. package/src/__tests__/host-proxy-interface.test.ts +165 -0
  74. package/src/__tests__/host-shell-tool.test.ts +1 -11
  75. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  76. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  77. package/src/__tests__/inline-command-runner.test.ts +7 -5
  78. package/src/__tests__/integration-status.test.ts +6 -7
  79. package/src/__tests__/list-messages-tool-merge.test.ts +37 -12
  80. package/src/__tests__/log-export-workspace.test.ts +190 -0
  81. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  82. package/src/__tests__/mcp-client-auth.test.ts +40 -4
  83. package/src/__tests__/mcp-health-check.test.ts +10 -3
  84. package/src/__tests__/migration-cross-version-compatibility.test.ts +3 -1
  85. package/src/__tests__/migration-export-http.test.ts +61 -2
  86. package/src/__tests__/migration-export-streaming.test.ts +66 -0
  87. package/src/__tests__/migration-import-commit-http.test.ts +101 -1
  88. package/src/__tests__/native-host-marker-sync-guard.test.ts +157 -0
  89. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  90. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  91. package/src/__tests__/oauth-apps-routes.test.ts +17 -12
  92. package/src/__tests__/oauth-cli.test.ts +707 -60
  93. package/src/__tests__/oauth-connect-orchestrator.test.ts +116 -24
  94. package/src/__tests__/oauth-provider-seed-logos.test.ts +23 -0
  95. package/src/__tests__/oauth-provider-serializer.test.ts +146 -10
  96. package/src/__tests__/oauth-provider-visibility.test.ts +19 -21
  97. package/src/__tests__/oauth-providers-routes.test.ts +50 -14
  98. package/src/__tests__/oauth-store.test.ts +1386 -182
  99. package/src/__tests__/oauth2-gateway-transport.test.ts +211 -20
  100. package/src/__tests__/onboarding-template-contract.test.ts +74 -55
  101. package/src/__tests__/openai-provider.test.ts +2 -2
  102. package/src/__tests__/outlook-categories.test.ts +1 -1
  103. package/src/__tests__/outlook-client-automation.test.ts +1 -1
  104. package/src/__tests__/outlook-compose-tools.test.ts +1 -1
  105. package/src/__tests__/outlook-email-watcher.test.ts +1 -1
  106. package/src/__tests__/outlook-follow-up.test.ts +1 -1
  107. package/src/__tests__/outlook-messaging-provider.test.ts +2 -2
  108. package/src/__tests__/outlook-trash.test.ts +1 -1
  109. package/src/__tests__/outlook-unsubscribe.test.ts +1 -1
  110. package/src/__tests__/permission-checker-host-gate.test.ts +74 -14
  111. package/src/__tests__/permission-mode.test.ts +28 -56
  112. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  113. package/src/__tests__/platform-callback-registration.test.ts +19 -0
  114. package/src/__tests__/post-turn-tool-result-truncation.test.ts +296 -0
  115. package/src/__tests__/proxy-approval-callback.test.ts +18 -0
  116. package/src/__tests__/require-fresh-approval.test.ts +40 -3
  117. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  118. package/src/__tests__/sanitize-config-for-transfer.test.ts +132 -0
  119. package/src/__tests__/schedule-routes.test.ts +162 -0
  120. package/src/__tests__/secret-detection-handler.test.ts +84 -0
  121. package/src/__tests__/secret-ingress-http.test.ts +1 -0
  122. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  123. package/src/__tests__/set-permission-mode.test.ts +13 -250
  124. package/src/__tests__/skills-file-content-endpoint.test.ts +670 -0
  125. package/src/__tests__/skills-files-catalog-fallback.test.ts +450 -0
  126. package/src/__tests__/slack-channel-config.test.ts +12 -15
  127. package/src/__tests__/subagent-detail.test.ts +44 -2
  128. package/src/__tests__/subagent-disposal.test.ts +1 -0
  129. package/src/__tests__/subagent-fork-notifications.test.ts +291 -0
  130. package/src/__tests__/subagent-fork-spawn.test.ts +384 -0
  131. package/src/__tests__/subagent-manager-notify.test.ts +1 -0
  132. package/src/__tests__/subagent-notify-parent.test.ts +1 -0
  133. package/src/__tests__/subagent-spawn-tool-fork.test.ts +411 -0
  134. package/src/__tests__/subagent-tools.test.ts +1 -0
  135. package/src/__tests__/subagent-types.test.ts +1 -0
  136. package/src/__tests__/system-prompt-ask-mode.test.ts +27 -71
  137. package/src/__tests__/system-prompt.test.ts +72 -1
  138. package/src/__tests__/task-scheduler.test.ts +32 -6
  139. package/src/__tests__/telegram-config.test.ts +10 -13
  140. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  141. package/src/__tests__/terminal-tools.test.ts +11 -5
  142. package/src/__tests__/test-preload.ts +14 -0
  143. package/src/__tests__/tool-approval-handler.test.ts +73 -0
  144. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  145. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  146. package/src/__tests__/tool-executor.test.ts +0 -1
  147. package/src/__tests__/tool-side-effects-slack-dm.test.ts +22 -0
  148. package/src/__tests__/top-level-renderer.test.ts +73 -1
  149. package/src/__tests__/transport-hints-queue.test.ts +62 -0
  150. package/src/__tests__/trust-store.test.ts +4 -4
  151. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +109 -0
  152. package/src/__tests__/v2-consent-policy.test.ts +103 -0
  153. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  154. package/src/__tests__/workspace-policy.test.ts +2 -7
  155. package/src/acp/client-handler.ts +30 -4
  156. package/src/agent/loop.ts +12 -35
  157. package/src/approvals/guardian-request-resolvers.ts +21 -15
  158. package/src/browser-session/__tests__/manager.test.ts +297 -0
  159. package/src/browser-session/backends/cdp-inspect.ts +30 -0
  160. package/src/browser-session/backends/extension.ts +26 -0
  161. package/src/browser-session/backends/local.ts +24 -0
  162. package/src/browser-session/events.ts +164 -0
  163. package/src/browser-session/index.ts +27 -0
  164. package/src/browser-session/manager.ts +159 -0
  165. package/src/browser-session/types.ts +28 -0
  166. package/src/channels/__tests__/types.test.ts +134 -0
  167. package/src/channels/types.ts +55 -0
  168. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  169. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  170. package/src/cli/commands/browser-relay.ts +339 -409
  171. package/src/cli/commands/credentials.ts +3 -3
  172. package/src/cli/commands/default-action.ts +68 -1
  173. package/src/cli/commands/email.ts +18 -13
  174. package/src/cli/commands/mcp.ts +16 -4
  175. package/src/cli/commands/oauth/__tests__/connect.test.ts +68 -41
  176. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +21 -21
  177. package/src/cli/commands/oauth/__tests__/mode.test.ts +17 -17
  178. package/src/cli/commands/oauth/__tests__/ping.test.ts +16 -16
  179. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +31 -33
  180. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +329 -0
  181. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +116 -12
  182. package/src/cli/commands/oauth/__tests__/status.test.ts +10 -10
  183. package/src/cli/commands/oauth/__tests__/token.test.ts +7 -7
  184. package/src/cli/commands/oauth/apps.ts +7 -4
  185. package/src/cli/commands/oauth/connect.ts +16 -2
  186. package/src/cli/commands/oauth/disconnect.ts +1 -1
  187. package/src/cli/commands/oauth/providers.ts +200 -36
  188. package/src/cli/commands/oauth/shared.ts +5 -5
  189. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +259 -0
  190. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  191. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  192. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  193. package/src/cli/commands/platform/index.ts +107 -10
  194. package/src/cli/commands/usage.ts +10 -9
  195. package/src/cli/lib/daemon-credential-client.ts +4 -0
  196. package/src/cli/program.ts +10 -3
  197. package/src/config/assistant-feature-flags.ts +59 -55
  198. package/src/config/bundled-skills/app-builder/SKILL.md +33 -173
  199. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +105 -0
  200. package/src/config/bundled-skills/app-builder/references/INTERACTION_HOOKS.md +56 -0
  201. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +125 -0
  202. package/src/config/bundled-skills/contacts/SKILL.md +3 -0
  203. package/src/config/bundled-skills/document/SKILL.md +4 -0
  204. package/src/config/bundled-skills/gmail/SKILL.md +12 -7
  205. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  206. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  207. package/src/config/bundled-skills/outlook/SKILL.md +7 -0
  208. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  209. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  210. package/src/config/bundled-skills/subagent/SKILL.md +21 -0
  211. package/src/config/bundled-skills/subagent/TOOLS.json +8 -4
  212. package/src/config/bundled-skills/tasks/SKILL.md +5 -0
  213. package/src/config/env-registry.ts +14 -0
  214. package/src/config/env.ts +21 -0
  215. package/src/config/feature-flag-registry.json +46 -7
  216. package/src/config/loader.ts +56 -1
  217. package/src/config/sanitize-for-transfer.ts +47 -0
  218. package/src/config/schema.ts +46 -5
  219. package/src/config/schemas/host-browser.ts +66 -0
  220. package/src/config/schemas/memory-lifecycle.ts +1 -1
  221. package/src/config/schemas/memory-retrieval.ts +103 -0
  222. package/src/config/schemas/security.ts +0 -6
  223. package/src/config/schemas/services.ts +16 -0
  224. package/src/config/types.ts +0 -1
  225. package/src/context/post-turn-tool-result-truncation.ts +176 -0
  226. package/src/context/window-manager.ts +19 -1
  227. package/src/credential-execution/approval-bridge.ts +49 -16
  228. package/src/credential-execution/managed-catalog.ts +3 -7
  229. package/src/daemon/__tests__/conversation-tool-setup.test.ts +186 -0
  230. package/src/daemon/app-source-watcher.ts +35 -0
  231. package/src/daemon/config-watcher.ts +6 -2
  232. package/src/daemon/context-overflow-approval.ts +5 -1
  233. package/src/daemon/conversation-agent-loop-handlers.ts +17 -2
  234. package/src/daemon/conversation-agent-loop.ts +74 -19
  235. package/src/daemon/conversation-attachments.ts +40 -1
  236. package/src/daemon/conversation-messaging.ts +3 -0
  237. package/src/daemon/conversation-process.ts +66 -3
  238. package/src/daemon/conversation-queue-manager.ts +8 -0
  239. package/src/daemon/conversation-runtime-assembly.ts +159 -20
  240. package/src/daemon/conversation-surfaces.ts +78 -12
  241. package/src/daemon/conversation-tool-setup.ts +74 -11
  242. package/src/daemon/conversation-workspace.ts +12 -0
  243. package/src/daemon/conversation.ts +227 -11
  244. package/src/daemon/date-context.ts +10 -10
  245. package/src/daemon/first-greeting.ts +3 -2
  246. package/src/daemon/handlers/conversations.ts +9 -139
  247. package/src/daemon/handlers/shared.ts +65 -0
  248. package/src/daemon/handlers/skills.ts +232 -37
  249. package/src/daemon/host-bash-proxy.ts +48 -13
  250. package/src/daemon/host-browser-proxy.ts +191 -0
  251. package/src/daemon/host-cu-proxy.ts +36 -11
  252. package/src/daemon/host-file-proxy.ts +57 -9
  253. package/src/daemon/lifecycle.ts +86 -12
  254. package/src/daemon/message-protocol.ts +7 -0
  255. package/src/daemon/message-types/conversations.ts +59 -13
  256. package/src/daemon/message-types/host-browser.ts +100 -0
  257. package/src/daemon/message-types/messages.ts +5 -6
  258. package/src/daemon/message-types/notifications.ts +12 -0
  259. package/src/daemon/message-types/settings.ts +12 -0
  260. package/src/daemon/message-types/skills.ts +10 -0
  261. package/src/daemon/message-types/subagents.ts +2 -0
  262. package/src/daemon/server.ts +112 -35
  263. package/src/daemon/tool-side-effects.ts +6 -0
  264. package/src/daemon/transport-hints.ts +14 -0
  265. package/src/inbound/platform-callback-registration.ts +18 -17
  266. package/src/index.ts +1 -1
  267. package/src/mcp/client.ts +59 -24
  268. package/src/memory/app-store.ts +31 -1
  269. package/src/memory/conversation-crud.ts +38 -10
  270. package/src/memory/conversation-directories.ts +39 -0
  271. package/src/memory/conversation-group-migration.ts +65 -5
  272. package/src/memory/conversation-starters-cadence.ts +76 -0
  273. package/src/memory/conversation-title-service.ts +5 -2
  274. package/src/memory/db-init.ts +12 -0
  275. package/src/memory/embedding-backend.test.ts +75 -0
  276. package/src/memory/embedding-backend.ts +131 -5
  277. package/src/memory/embedding-gemini.test.ts +54 -0
  278. package/src/memory/embedding-gemini.ts +20 -9
  279. package/src/memory/embedding-local.ts +177 -18
  280. package/src/memory/graph/capability-seed.ts +3 -5
  281. package/src/memory/graph/consolidation.ts +10 -23
  282. package/src/memory/graph/extraction-job.ts +15 -0
  283. package/src/memory/graph/retriever.ts +40 -22
  284. package/src/memory/graph/store.test.ts +7 -3
  285. package/src/memory/graph/store.ts +47 -12
  286. package/src/memory/group-crud.ts +25 -9
  287. package/src/memory/llm-usage-store.ts +45 -4
  288. package/src/memory/migrations/213-oauth-providers-scope-separator.ts +13 -0
  289. package/src/memory/migrations/214-oauth-providers-refresh-url.ts +11 -0
  290. package/src/memory/migrations/215-oauth-providers-revoke.ts +14 -0
  291. package/src/memory/migrations/216-oauth-providers-token-auth-method.ts +30 -0
  292. package/src/memory/migrations/217-conversation-host-access.ts +40 -0
  293. package/src/memory/migrations/218-oauth-providers-logo-url.ts +11 -0
  294. package/src/memory/migrations/index.ts +6 -0
  295. package/src/memory/migrations/registry.ts +8 -0
  296. package/src/memory/schema/conversations.ts +1 -0
  297. package/src/memory/schema/oauth.ts +18 -13
  298. package/src/messaging/provider.ts +1 -1
  299. package/src/notifications/broadcaster.ts +6 -0
  300. package/src/notifications/conversation-pairing.ts +12 -4
  301. package/src/notifications/emit-signal.ts +14 -0
  302. package/src/notifications/signal.ts +11 -0
  303. package/src/oauth/AGENTS.md +76 -0
  304. package/src/oauth/__tests__/identity-verifier.test.ts +24 -19
  305. package/src/oauth/__tests__/seed-providers-managed.test.ts +32 -0
  306. package/src/oauth/byo-connection.test.ts +8 -8
  307. package/src/oauth/byo-connection.ts +7 -7
  308. package/src/oauth/connect-orchestrator.ts +23 -21
  309. package/src/oauth/connect-types.ts +3 -3
  310. package/src/oauth/connection-resolver.test.ts +17 -4
  311. package/src/oauth/connection-resolver.ts +16 -16
  312. package/src/oauth/connection.ts +1 -1
  313. package/src/oauth/manual-token-connection.ts +13 -13
  314. package/src/oauth/oauth-store.ts +214 -100
  315. package/src/oauth/platform-connection.test.ts +5 -5
  316. package/src/oauth/platform-connection.ts +4 -4
  317. package/src/oauth/provider-serializer.ts +31 -5
  318. package/src/oauth/revoke.ts +76 -0
  319. package/src/oauth/seed-providers.ts +127 -87
  320. package/src/oauth/token-persistence.ts +1 -1
  321. package/src/permissions/checker.ts +3 -3
  322. package/src/permissions/defaults.ts +7 -8
  323. package/src/permissions/permission-mode.ts +4 -11
  324. package/src/permissions/prompter.ts +13 -3
  325. package/src/permissions/v2-consent-policy.ts +87 -0
  326. package/src/platform/client.ts +1 -1
  327. package/src/prompts/system-prompt.ts +18 -21
  328. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +3 -65
  329. package/src/prompts/templates/BOOTSTRAP.md +59 -96
  330. package/src/prompts/templates/SOUL.md +11 -11
  331. package/src/providers/anthropic/client.ts +1 -0
  332. package/src/providers/types.ts +1 -1
  333. package/src/runtime/AGENTS.md +23 -0
  334. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +715 -0
  335. package/src/runtime/__tests__/capability-tokens.test.ts +258 -0
  336. package/src/runtime/__tests__/chrome-extension-registry.test.ts +518 -0
  337. package/src/runtime/assistant-event-hub.ts +24 -2
  338. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -0
  339. package/src/runtime/auth/__tests__/middleware.test.ts +116 -1
  340. package/src/runtime/auth/__tests__/route-policy.test.ts +8 -0
  341. package/src/runtime/auth/middleware.ts +98 -0
  342. package/src/runtime/auth/route-policy.ts +6 -7
  343. package/src/runtime/auth/token-service.ts +8 -0
  344. package/src/runtime/capability-tokens.ts +414 -0
  345. package/src/runtime/channel-approvals.ts +18 -5
  346. package/src/runtime/chrome-extension-registry.ts +332 -0
  347. package/src/runtime/confirmation-request-guardian-bridge.ts +6 -0
  348. package/src/runtime/guardian-decision-types.ts +7 -0
  349. package/src/runtime/http-server.ts +425 -70
  350. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +172 -0
  351. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +276 -0
  352. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +162 -0
  353. package/src/runtime/migrations/migration-transport.ts +6 -0
  354. package/src/runtime/migrations/migration-wizard.ts +22 -2
  355. package/src/runtime/migrations/rebind-secrets-screen.ts +76 -15
  356. package/src/runtime/migrations/vbundle-builder.ts +145 -38
  357. package/src/runtime/migrations/vbundle-import-analyzer.ts +19 -0
  358. package/src/runtime/migrations/vbundle-importer.ts +55 -5
  359. package/src/runtime/pending-interactions.ts +29 -13
  360. package/src/runtime/routes/approval-routes.ts +90 -16
  361. package/src/runtime/routes/browser-cdp-routes.ts +229 -0
  362. package/src/runtime/routes/browser-extension-pair-routes.ts +497 -0
  363. package/src/runtime/routes/conversation-analysis-routes.ts +18 -5
  364. package/src/runtime/routes/conversation-management-routes.ts +108 -0
  365. package/src/runtime/routes/conversation-routes.ts +308 -28
  366. package/src/runtime/routes/conversation-starter-routes.ts +78 -16
  367. package/src/runtime/routes/group-routes.ts +22 -8
  368. package/src/runtime/routes/guardian-action-routes.ts +24 -13
  369. package/src/runtime/routes/host-browser-routes.ts +279 -0
  370. package/src/runtime/routes/host-file-routes.ts +9 -1
  371. package/src/runtime/routes/identity-routes.ts +259 -16
  372. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  373. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  374. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  375. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  376. package/src/runtime/routes/log-export-routes.ts +60 -25
  377. package/src/runtime/routes/memory-item-routes.ts +1 -7
  378. package/src/runtime/routes/migration-routes.ts +87 -2
  379. package/src/runtime/routes/oauth-apps.ts +15 -17
  380. package/src/runtime/routes/oauth-providers.ts +4 -0
  381. package/src/runtime/routes/schedule-routes.ts +24 -11
  382. package/src/runtime/routes/settings-routes.ts +9 -97
  383. package/src/runtime/routes/skills-routes.ts +52 -2
  384. package/src/runtime/routes/subagents-routes.ts +14 -10
  385. package/src/runtime/routes/usage-routes.ts +8 -7
  386. package/src/runtime/routes/workspace-routes.test.ts +22 -0
  387. package/src/runtime/routes/workspace-routes.ts +8 -1
  388. package/src/runtime/routes/workspace-utils.ts +2 -0
  389. package/src/schedule/scheduler.ts +7 -5
  390. package/src/security/ces-credential-client.ts +20 -0
  391. package/src/security/ces-rpc-credential-backend.ts +17 -0
  392. package/src/security/credential-backend.ts +5 -0
  393. package/src/security/oauth2.ts +42 -25
  394. package/src/security/secure-keys.ts +118 -25
  395. package/src/security/token-manager.ts +23 -10
  396. package/src/skills/catalog-files.ts +492 -0
  397. package/src/skills/inline-command-runner.ts +12 -14
  398. package/src/subagent/manager.ts +131 -26
  399. package/src/subagent/types.ts +19 -0
  400. package/src/tools/apps/executors.ts +11 -2
  401. package/src/tools/browser/__tests__/auth-detector.test.ts +202 -108
  402. package/src/tools/browser/auth-detector.ts +43 -12
  403. package/src/tools/browser/browser-execution.ts +645 -340
  404. package/src/tools/browser/browser-manager.ts +36 -12
  405. package/src/tools/browser/cdp-client/__tests__/accessibility-snapshot.test.ts +318 -0
  406. package/src/tools/browser/cdp-client/__tests__/cdp-dom-helpers.test.ts +1175 -0
  407. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +870 -0
  408. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +330 -0
  409. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +377 -0
  410. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-nested-frames.json +64 -0
  411. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-simple.json +69 -0
  412. package/src/tools/browser/cdp-client/__tests__/local-cdp-client.test.ts +310 -0
  413. package/src/tools/browser/cdp-client/__tests__/types.test.ts +96 -0
  414. package/src/tools/browser/cdp-client/accessibility-snapshot.ts +387 -0
  415. package/src/tools/browser/cdp-client/cdp-dom-helpers.ts +695 -0
  416. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +743 -0
  417. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +580 -0
  418. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +578 -0
  419. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +579 -0
  420. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +635 -0
  421. package/src/tools/browser/cdp-client/errors.ts +34 -0
  422. package/src/tools/browser/cdp-client/extension-cdp-client.ts +125 -0
  423. package/src/tools/browser/cdp-client/factory.ts +204 -0
  424. package/src/tools/browser/cdp-client/index.ts +14 -0
  425. package/src/tools/browser/cdp-client/local-cdp-client.ts +187 -0
  426. package/src/tools/browser/cdp-client/types.ts +52 -0
  427. package/src/tools/filesystem/edit.ts +1 -1
  428. package/src/tools/filesystem/list.ts +1 -1
  429. package/src/tools/filesystem/read.ts +1 -1
  430. package/src/tools/filesystem/write.ts +2 -1
  431. package/src/tools/host-filesystem/edit.ts +1 -1
  432. package/src/tools/host-filesystem/read.ts +12 -15
  433. package/src/tools/host-filesystem/write.ts +1 -1
  434. package/src/tools/host-terminal/host-shell.ts +21 -16
  435. package/src/tools/permission-checker.ts +77 -100
  436. package/src/tools/registry.ts +0 -2
  437. package/src/tools/secret-detection-handler.ts +34 -1
  438. package/src/tools/shared/filesystem/image-read.ts +61 -40
  439. package/src/tools/skills/sandbox-runner.ts +3 -6
  440. package/src/tools/subagent/spawn.ts +47 -3
  441. package/src/tools/subagent/status.ts +2 -0
  442. package/src/tools/system/register.ts +2 -16
  443. package/src/tools/terminal/safe-env.ts +7 -0
  444. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  445. package/src/tools/terminal/sandbox.ts +4 -1
  446. package/src/tools/terminal/shell.ts +24 -21
  447. package/src/tools/tool-approval-handler.ts +48 -2
  448. package/src/tools/types.ts +2 -3
  449. package/src/util/platform.ts +14 -19
  450. package/src/watcher/provider-types.ts +1 -1
  451. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  452. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  453. package/src/workspace/migrations/registry.ts +2 -0
  454. package/src/workspace/top-level-renderer.ts +19 -1
  455. package/src/__tests__/chrome-cdp.test.ts +0 -419
  456. package/src/__tests__/permission-mode-sse.test.ts +0 -418
  457. package/src/__tests__/permission-mode-store.test.ts +0 -277
  458. package/src/browser-extension-relay/protocol.ts +0 -63
  459. package/src/browser-extension-relay/server.ts +0 -203
  460. package/src/config/schemas/sandbox.ts +0 -14
  461. package/src/permissions/permission-mode-store.ts +0 -180
  462. package/src/tools/browser/chrome-cdp.ts +0 -239
  463. package/src/tools/system/set-permission-mode.ts +0 -103
@@ -0,0 +1,862 @@
1
+ /**
2
+ * Unit tests for `skills/catalog-files.ts` — preview listings and single-file
3
+ * content for catalog skills (installed or not).
4
+ *
5
+ * Covers:
6
+ * - sanitizeRelativePath (accepts safe, rejects traversal / absolute / null)
7
+ * - readCatalogSkillFiles dev-mode (reads from a temp fake repo skills dir,
8
+ * does NOT touch fetch)
9
+ * - readCatalogSkillFiles platform-mode (stubbed fetch with success + 500
10
+ * + 404 + network-error)
11
+ * - readCatalogSkillFileContent dev-mode (text, traversal rejection,
12
+ * binary, oversized)
13
+ * - readCatalogSkillFileContent platform-mode (text, binary, oversized,
14
+ * 404, pre-fetch traversal rejection)
15
+ * - catalog-miss short-circuit (no fetch call when id unknown)
16
+ */
17
+
18
+ import {
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ rmSync,
22
+ symlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
28
+
29
+ import type { CatalogSkill } from "../skills/catalog-install.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Mocks — must be declared before importing the module under test
33
+ // ---------------------------------------------------------------------------
34
+
35
+ // Suppress logger output
36
+ mock.module("../util/logger.js", () => ({
37
+ getLogger: () =>
38
+ new Proxy({} as Record<string, unknown>, {
39
+ get: () => () => {},
40
+ }),
41
+ }));
42
+
43
+ let mockCatalog: CatalogSkill[] = [];
44
+ let mockRepoSkillsDir: string | undefined = undefined;
45
+
46
+ mock.module("../skills/catalog-cache.js", () => ({
47
+ getCatalog: async () => mockCatalog,
48
+ }));
49
+
50
+ mock.module("../skills/catalog-install.js", () => ({
51
+ getRepoSkillsDir: () => mockRepoSkillsDir,
52
+ }));
53
+
54
+ let mockPlatformToken: string | null = null;
55
+ mock.module("../util/platform.ts", () => ({
56
+ readPlatformToken: () => mockPlatformToken,
57
+ }));
58
+ mock.module("../util/platform.js", () => ({
59
+ readPlatformToken: () => mockPlatformToken,
60
+ }));
61
+
62
+ let mockPlatformBaseUrl = "https://platform.test";
63
+ mock.module("../config/env.ts", () => ({
64
+ getPlatformBaseUrl: () => mockPlatformBaseUrl,
65
+ }));
66
+ mock.module("../config/env.js", () => ({
67
+ getPlatformBaseUrl: () => mockPlatformBaseUrl,
68
+ }));
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Imports (after mocks)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ import {
75
+ readCatalogSkillFileContent,
76
+ readCatalogSkillFiles,
77
+ sanitizeRelativePath,
78
+ } from "../skills/catalog-files.js";
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ type FetchFn = typeof globalThis.fetch;
85
+
86
+ interface FetchCall {
87
+ url: string;
88
+ init?: RequestInit;
89
+ }
90
+
91
+ let originalFetch: FetchFn;
92
+ let fetchCalls: FetchCall[] = [];
93
+
94
+ function installFetchMock(
95
+ handler: (url: string, init?: RequestInit) => Response | Promise<Response>,
96
+ ): void {
97
+ fetchCalls = [];
98
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
99
+ const url =
100
+ typeof input === "string"
101
+ ? input
102
+ : input instanceof URL
103
+ ? input.toString()
104
+ : input.url;
105
+ fetchCalls.push({ url, init });
106
+ return handler(url, init);
107
+ }) as unknown as FetchFn;
108
+ }
109
+
110
+ function installFetchThrow(error: Error): void {
111
+ fetchCalls = [];
112
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
113
+ const url =
114
+ typeof input === "string"
115
+ ? input
116
+ : input instanceof URL
117
+ ? input.toString()
118
+ : input.url;
119
+ fetchCalls.push({ url, init });
120
+ throw error;
121
+ }) as unknown as FetchFn;
122
+ }
123
+
124
+ function installFetchForbidden(): void {
125
+ fetchCalls = [];
126
+ globalThis.fetch = (async () => {
127
+ throw new Error("fetch should not have been called");
128
+ }) as unknown as FetchFn;
129
+ }
130
+
131
+ // Temp directories created during tests, cleaned up in afterEach.
132
+ const tempDirs: string[] = [];
133
+
134
+ function makeTempSkillsDir(): string {
135
+ const dir = mkdtempSync(join(tmpdir(), "catalog-files-test-"));
136
+ tempDirs.push(dir);
137
+ return dir;
138
+ }
139
+
140
+ function writeSkill(
141
+ root: string,
142
+ skillId: string,
143
+ files: Record<string, string | Buffer>,
144
+ ): string {
145
+ const skillDir = join(root, skillId);
146
+ mkdirSync(skillDir, { recursive: true });
147
+ for (const [relPath, content] of Object.entries(files)) {
148
+ const abs = join(skillDir, relPath);
149
+ mkdirSync(join(abs, ".."), { recursive: true });
150
+ writeFileSync(abs, content);
151
+ }
152
+ return skillDir;
153
+ }
154
+
155
+ function skill(id: string): CatalogSkill {
156
+ return { id, name: id, description: id };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Setup / teardown
161
+ // ---------------------------------------------------------------------------
162
+
163
+ beforeEach(() => {
164
+ originalFetch = globalThis.fetch;
165
+ fetchCalls = [];
166
+ mockCatalog = [];
167
+ mockRepoSkillsDir = undefined;
168
+ mockPlatformToken = null;
169
+ mockPlatformBaseUrl = "https://platform.test";
170
+ });
171
+
172
+ afterEach(() => {
173
+ globalThis.fetch = originalFetch;
174
+ for (const dir of tempDirs) {
175
+ try {
176
+ rmSync(dir, { recursive: true, force: true });
177
+ } catch {
178
+ // best effort
179
+ }
180
+ }
181
+ tempDirs.length = 0;
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // sanitizeRelativePath
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("sanitizeRelativePath", () => {
189
+ test("accepts simple posix paths", () => {
190
+ expect(sanitizeRelativePath("SKILL.md")).toBe("SKILL.md");
191
+ expect(sanitizeRelativePath("tools/run.sh")).toBe("tools/run.sh");
192
+ expect(sanitizeRelativePath("a/b/c.txt")).toBe("a/b/c.txt");
193
+ });
194
+
195
+ test("normalizes leading ./", () => {
196
+ expect(sanitizeRelativePath("./x")).toBe("x");
197
+ expect(sanitizeRelativePath("./tools/run.sh")).toBe("tools/run.sh");
198
+ });
199
+
200
+ test("normalizes backslashes to forward slashes", () => {
201
+ expect(sanitizeRelativePath("tools\\run.sh")).toBe("tools/run.sh");
202
+ });
203
+
204
+ test("rejects empty strings", () => {
205
+ expect(sanitizeRelativePath("")).toBeNull();
206
+ });
207
+
208
+ test("rejects parent-traversal", () => {
209
+ expect(sanitizeRelativePath("..")).toBeNull();
210
+ expect(sanitizeRelativePath("../x")).toBeNull();
211
+ expect(sanitizeRelativePath("../../etc/passwd")).toBeNull();
212
+ });
213
+
214
+ test("rejects absolute unix paths", () => {
215
+ expect(sanitizeRelativePath("/etc/passwd")).toBeNull();
216
+ expect(sanitizeRelativePath("/")).toBeNull();
217
+ });
218
+
219
+ test("rejects windows drive-prefixed paths", () => {
220
+ expect(sanitizeRelativePath("C:/win")).toBeNull();
221
+ expect(sanitizeRelativePath("C:\\Windows")).toBeNull();
222
+ });
223
+
224
+ test("rejects paths that become absolute after ./ stripping", () => {
225
+ // `sanitizeRelativePath` performs a post-normalization absolute-path
226
+ // check so inputs like `.//etc/passwd` cannot reach the filesystem:
227
+ // the leading `./` strip loop leaves `/etc/passwd`, which
228
+ // `posix.normalize` would otherwise pass through as an absolute path.
229
+ expect(sanitizeRelativePath(".//etc/passwd")).toBeNull();
230
+ expect(sanitizeRelativePath("./././/etc/passwd")).toBeNull();
231
+ // The backslash normalizes to `/`, so `.\\/etc/passwd` becomes
232
+ // `.//etc/passwd` before the strip loop, then `/etc/passwd`.
233
+ expect(sanitizeRelativePath(".\\/etc/passwd")).toBeNull();
234
+ // Windows-drive bypass via the same mechanism.
235
+ expect(sanitizeRelativePath(".//C:/Windows/system32")).toBeNull();
236
+ });
237
+
238
+ test("rejects paths containing null bytes", () => {
239
+ expect(sanitizeRelativePath("SKILL.md\0.png")).toBeNull();
240
+ expect(sanitizeRelativePath("\0")).toBeNull();
241
+ });
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // readCatalogSkillFiles — dev mode
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe("readCatalogSkillFiles (dev mode)", () => {
249
+ test("lists files from the repo skills dir without touching fetch", async () => {
250
+ const root = makeTempSkillsDir();
251
+ writeSkill(root, "my-skill", {
252
+ "SKILL.md": "# hello",
253
+ "tools/run.sh": "#!/bin/sh\necho hi\n",
254
+ "data/img.png": Buffer.from([0x89, 0x50, 0x4e, 0x47]),
255
+ });
256
+ mockRepoSkillsDir = root;
257
+ mockCatalog = [skill("my-skill")];
258
+ installFetchForbidden();
259
+
260
+ const entries = await readCatalogSkillFiles("my-skill");
261
+ expect(entries).not.toBeNull();
262
+ const paths = entries!.map((e) => e.path).sort();
263
+ expect(paths).toEqual(["SKILL.md", "data/img.png", "tools/run.sh"]);
264
+
265
+ const md = entries!.find((e) => e.path === "SKILL.md")!;
266
+ expect(md.name).toBe("SKILL.md");
267
+ expect(md.size).toBe("# hello".length);
268
+ expect(md.isBinary).toBe(false);
269
+ expect(md.content).toBeNull();
270
+
271
+ const png = entries!.find((e) => e.path === "data/img.png")!;
272
+ expect(png.name).toBe("img.png");
273
+ expect(png.isBinary).toBe(true);
274
+ expect(png.content).toBeNull();
275
+
276
+ expect(fetchCalls.length).toBe(0);
277
+ });
278
+
279
+ test("returns null when skill id is not in the catalog (dev mode)", async () => {
280
+ const root = makeTempSkillsDir();
281
+ writeSkill(root, "my-skill", { "SKILL.md": "x" });
282
+ mockRepoSkillsDir = root;
283
+ mockCatalog = []; // not in catalog
284
+ installFetchForbidden();
285
+
286
+ expect(await readCatalogSkillFiles("my-skill")).toBeNull();
287
+ expect(fetchCalls.length).toBe(0);
288
+ });
289
+
290
+ test("rejects a symlinked skill root and falls through to platform mode", async () => {
291
+ // An attacker (or a misconfigured dev) creates
292
+ // <repoSkillsDir>/my-skill as a symlink pointing at an external
293
+ // directory. `resolveCatalogSource` rejects symlinked skill roots so
294
+ // the dev-mode branch never walks the external directory — the
295
+ // realpath containment check downstream would otherwise derive
296
+ // `realRoot` from the already-resolved symlink target and become a
297
+ // no-op.
298
+ //
299
+ // Expected behavior: the dev-mode shortcut is rejected up-front, and
300
+ // we fall through to platform mode — which in this test is stubbed
301
+ // to return an empty file list. So `readCatalogSkillFiles` returns
302
+ // the empty platform response, and `fetch` MUST be called (proving
303
+ // the fall-through happened, rather than the dev-mode shortcut
304
+ // silently reading from the external directory).
305
+ const root = makeTempSkillsDir();
306
+ const externalRoot = mkdtempSync(join(tmpdir(), "catalog-files-ext-"));
307
+ tempDirs.push(externalRoot);
308
+
309
+ // External directory populated with a real SKILL.md + a secret file
310
+ // that must NOT be exposed by the listing.
311
+ writeFileSync(join(externalRoot, "SKILL.md"), "# EXTERNAL SKILL");
312
+ writeFileSync(join(externalRoot, "secret.txt"), "EXTERNAL_SECRET");
313
+
314
+ // Symlink the skill root at `<root>/my-skill` to the external dir.
315
+ symlinkSync(externalRoot, join(root, "my-skill"));
316
+
317
+ mockRepoSkillsDir = root;
318
+ mockCatalog = [skill("my-skill")];
319
+ installFetchMock(() => Response.json({ skill_id: "my-skill", files: [] }));
320
+
321
+ const entries = await readCatalogSkillFiles("my-skill");
322
+
323
+ // Fall-through should produce the platform response (empty array),
324
+ // NOT the external directory contents.
325
+ expect(entries).toEqual([]);
326
+
327
+ // Confirm the dev-mode shortcut was bypassed: platform fetch was
328
+ // called against the preview endpoint.
329
+ expect(fetchCalls.length).toBe(1);
330
+ expect(fetchCalls[0]!.url).toBe(
331
+ "https://platform.test/v1/skills/my-skill/files/",
332
+ );
333
+ });
334
+
335
+ test("rejects a symlinked skill root for content reads and falls through to platform mode", async () => {
336
+ // Direct reproduction for `readCatalogSkillFileContent`: with a
337
+ // symlinked skill root, the dev branch must not read the external
338
+ // file. Instead we should fall through to the platform endpoint and
339
+ // return whatever the platform says.
340
+ const root = makeTempSkillsDir();
341
+ const externalRoot = mkdtempSync(join(tmpdir(), "catalog-files-ext-"));
342
+ tempDirs.push(externalRoot);
343
+
344
+ writeFileSync(join(externalRoot, "SKILL.md"), "EXTERNAL_SKILL_CONTENT");
345
+ symlinkSync(externalRoot, join(root, "my-skill"));
346
+
347
+ mockRepoSkillsDir = root;
348
+ mockCatalog = [skill("my-skill")];
349
+ installFetchMock(() =>
350
+ Response.json({
351
+ path: "SKILL.md",
352
+ name: "SKILL.md",
353
+ size: 14,
354
+ mime_type: "text/markdown",
355
+ is_binary: false,
356
+ content: "PLATFORM_CONTENT",
357
+ }),
358
+ );
359
+
360
+ const entry = await readCatalogSkillFileContent("my-skill", "SKILL.md");
361
+ expect(entry).not.toBeNull();
362
+ // Content must be the platform payload, NOT the external file's
363
+ // bytes — otherwise the fall-through didn't happen.
364
+ expect(entry!.content).toBe("PLATFORM_CONTENT");
365
+ expect(entry!.mimeType).toBe("text/markdown");
366
+
367
+ // And fetch was called, confirming dev-mode was bypassed.
368
+ expect(fetchCalls.length).toBe(1);
369
+ const url = fetchCalls[0]!.url;
370
+ expect(
371
+ url.startsWith("https://platform.test/v1/skills/my-skill/files/content/"),
372
+ ).toBe(true);
373
+ });
374
+
375
+ test("non-symlinked skill root still uses the dev-mode shortcut", async () => {
376
+ // Sanity check: a normal directory-based skill root must still be
377
+ // served from disk without touching fetch. This guards against the
378
+ // symlink-rejection path collateral-damaging regular dev flows.
379
+ const root = makeTempSkillsDir();
380
+ writeSkill(root, "normal-skill", { "SKILL.md": "# normal\n" });
381
+ mockRepoSkillsDir = root;
382
+ mockCatalog = [skill("normal-skill")];
383
+ installFetchForbidden();
384
+
385
+ const entries = await readCatalogSkillFiles("normal-skill");
386
+ expect(entries).not.toBeNull();
387
+ const paths = entries!.map((e) => e.path).sort();
388
+ expect(paths).toEqual(["SKILL.md"]);
389
+ // No platform round-trip happened.
390
+ expect(fetchCalls.length).toBe(0);
391
+ });
392
+
393
+ test("filters hidden files and SKIP_DIRS from the listing", async () => {
394
+ // Simulates a dev working on a catalog skill locally who has a
395
+ // node_modules/, a .git/, and a .hidden.md file sitting next to
396
+ // SKILL.md. The preview listing must only show SKILL.md — matching
397
+ // the behavior of `readDirRecursive` in `daemon/handlers/skills.ts`
398
+ // for installed skills.
399
+ const root = makeTempSkillsDir();
400
+ writeSkill(root, "my-skill", {
401
+ "SKILL.md": "# hello",
402
+ "node_modules/foo.js": "module.exports = {};",
403
+ "node_modules/nested/bar.js": "module.exports = {};",
404
+ "__pycache__/cached.pyc": Buffer.from([0x00, 0x01, 0x02]),
405
+ ".git/HEAD": "ref: refs/heads/main\n",
406
+ ".git/config": "[core]\n",
407
+ ".hidden.md": "secret",
408
+ ".DS_Store": Buffer.from([0x00, 0x00]),
409
+ });
410
+ mockRepoSkillsDir = root;
411
+ mockCatalog = [skill("my-skill")];
412
+ installFetchForbidden();
413
+
414
+ const entries = await readCatalogSkillFiles("my-skill");
415
+ expect(entries).not.toBeNull();
416
+ const paths = entries!.map((e) => e.path).sort();
417
+ expect(paths).toEqual(["SKILL.md"]);
418
+ expect(fetchCalls.length).toBe(0);
419
+ });
420
+ });
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // readCatalogSkillFiles — platform mode
424
+ // ---------------------------------------------------------------------------
425
+
426
+ describe("readCatalogSkillFiles (platform mode)", () => {
427
+ test("fetches file listing from the platform and maps it", async () => {
428
+ mockRepoSkillsDir = undefined;
429
+ mockCatalog = [skill("remote-skill")];
430
+ mockPlatformToken = "tok-123";
431
+ installFetchMock(() =>
432
+ Response.json({
433
+ skill_id: "remote-skill",
434
+ files: [
435
+ { path: "SKILL.md", name: "SKILL.md", size: 12, sha: "a" },
436
+ { path: "data/img.png", name: "img.png", size: 200, sha: "b" },
437
+ ],
438
+ }),
439
+ );
440
+
441
+ const entries = await readCatalogSkillFiles("remote-skill");
442
+ expect(entries).not.toBeNull();
443
+ expect(entries!.length).toBe(2);
444
+
445
+ // URL + headers
446
+ expect(fetchCalls.length).toBe(1);
447
+ expect(fetchCalls[0]!.url).toBe(
448
+ "https://platform.test/v1/skills/remote-skill/files/",
449
+ );
450
+ const headers = (fetchCalls[0]!.init?.headers ?? {}) as Record<
451
+ string,
452
+ string
453
+ >;
454
+ expect(headers["Accept"]).toBe("application/json");
455
+ expect(headers["X-Conversation-Token"]).toBe("tok-123");
456
+
457
+ // Mapped entries: always content === null, isBinary from filename.
458
+ const md = entries!.find((e) => e.path === "SKILL.md")!;
459
+ expect(md.isBinary).toBe(false);
460
+ expect(md.content).toBeNull();
461
+ expect(md.mimeType).toBe("");
462
+
463
+ const png = entries!.find((e) => e.path === "data/img.png")!;
464
+ expect(png.isBinary).toBe(true);
465
+ expect(png.content).toBeNull();
466
+ expect(png.mimeType).toBe("");
467
+ });
468
+
469
+ test("does not set X-Conversation-Token when no token is present", async () => {
470
+ mockCatalog = [skill("remote-skill")];
471
+ mockPlatformToken = null;
472
+ installFetchMock(() =>
473
+ Response.json({ skill_id: "remote-skill", files: [] }),
474
+ );
475
+
476
+ await readCatalogSkillFiles("remote-skill");
477
+ const headers = (fetchCalls[0]!.init?.headers ?? {}) as Record<
478
+ string,
479
+ string
480
+ >;
481
+ expect(headers["X-Conversation-Token"]).toBeUndefined();
482
+ });
483
+
484
+ test("returns null on 500", async () => {
485
+ mockCatalog = [skill("remote-skill")];
486
+ installFetchMock(
487
+ () => new Response("boom", { status: 500, statusText: "Server Error" }),
488
+ );
489
+ expect(await readCatalogSkillFiles("remote-skill")).toBeNull();
490
+ expect(fetchCalls.length).toBe(1);
491
+ });
492
+
493
+ test("returns null on 404", async () => {
494
+ mockCatalog = [skill("remote-skill")];
495
+ installFetchMock(
496
+ () => new Response("missing", { status: 404, statusText: "Not Found" }),
497
+ );
498
+ expect(await readCatalogSkillFiles("remote-skill")).toBeNull();
499
+ });
500
+
501
+ test("returns null on network error", async () => {
502
+ mockCatalog = [skill("remote-skill")];
503
+ installFetchThrow(new Error("ECONNRESET"));
504
+ expect(await readCatalogSkillFiles("remote-skill")).toBeNull();
505
+ });
506
+
507
+ test("returns null without fetching when skill id missing from catalog", async () => {
508
+ mockCatalog = [];
509
+ installFetchForbidden();
510
+ expect(await readCatalogSkillFiles("unknown")).toBeNull();
511
+ expect(fetchCalls.length).toBe(0);
512
+ });
513
+ });
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // readCatalogSkillFileContent — dev mode
517
+ // ---------------------------------------------------------------------------
518
+
519
+ describe("readCatalogSkillFileContent (dev mode)", () => {
520
+ test("returns inline UTF-8 content for a text file", async () => {
521
+ const root = makeTempSkillsDir();
522
+ writeSkill(root, "my-skill", { "SKILL.md": "# hello world\n" });
523
+ mockRepoSkillsDir = root;
524
+ mockCatalog = [skill("my-skill")];
525
+ installFetchForbidden();
526
+
527
+ const entry = await readCatalogSkillFileContent("my-skill", "SKILL.md");
528
+ expect(entry).not.toBeNull();
529
+ expect(entry!.path).toBe("SKILL.md");
530
+ expect(entry!.name).toBe("SKILL.md");
531
+ expect(entry!.isBinary).toBe(false);
532
+ expect(entry!.content).toBe("# hello world\n");
533
+ expect(fetchCalls.length).toBe(0);
534
+ });
535
+
536
+ test("rejects traversal paths without touching the filesystem", async () => {
537
+ const root = makeTempSkillsDir();
538
+ writeSkill(root, "my-skill", { "SKILL.md": "ok" });
539
+ mockRepoSkillsDir = root;
540
+ mockCatalog = [skill("my-skill")];
541
+ installFetchForbidden();
542
+
543
+ expect(
544
+ await readCatalogSkillFileContent("my-skill", "../escape"),
545
+ ).toBeNull();
546
+ expect(
547
+ await readCatalogSkillFileContent("my-skill", "/etc/passwd"),
548
+ ).toBeNull();
549
+ expect(await readCatalogSkillFileContent("my-skill", "..")).toBeNull();
550
+ expect(await readCatalogSkillFileContent("my-skill", "")).toBeNull();
551
+ });
552
+
553
+ test("returns content=null for a binary file", async () => {
554
+ const root = makeTempSkillsDir();
555
+ writeSkill(root, "my-skill", {
556
+ "img.png": Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
557
+ });
558
+ mockRepoSkillsDir = root;
559
+ mockCatalog = [skill("my-skill")];
560
+ installFetchForbidden();
561
+
562
+ const entry = await readCatalogSkillFileContent("my-skill", "img.png");
563
+ expect(entry).not.toBeNull();
564
+ expect(entry!.isBinary).toBe(true);
565
+ expect(entry!.content).toBeNull();
566
+ });
567
+
568
+ test("returns content=null for oversized text files", async () => {
569
+ const root = makeTempSkillsDir();
570
+ // Just over 2 MB of 'a'
571
+ const oversized = "a".repeat(2 * 1024 * 1024 + 1);
572
+ writeSkill(root, "my-skill", { "big.txt": oversized });
573
+ mockRepoSkillsDir = root;
574
+ mockCatalog = [skill("my-skill")];
575
+ installFetchForbidden();
576
+
577
+ const entry = await readCatalogSkillFileContent("my-skill", "big.txt");
578
+ expect(entry).not.toBeNull();
579
+ expect(entry!.isBinary).toBe(false);
580
+ expect(entry!.content).toBeNull();
581
+ expect(entry!.size).toBe(oversized.length);
582
+ });
583
+
584
+ test("returns null for a missing file", async () => {
585
+ const root = makeTempSkillsDir();
586
+ writeSkill(root, "my-skill", { "SKILL.md": "ok" });
587
+ mockRepoSkillsDir = root;
588
+ mockCatalog = [skill("my-skill")];
589
+ installFetchForbidden();
590
+
591
+ expect(
592
+ await readCatalogSkillFileContent("my-skill", "does/not/exist.txt"),
593
+ ).toBeNull();
594
+ });
595
+
596
+ test("returns null without fetching when skill id missing from catalog", async () => {
597
+ mockCatalog = [];
598
+ installFetchForbidden();
599
+ expect(await readCatalogSkillFileContent("unknown", "SKILL.md")).toBeNull();
600
+ expect(fetchCalls.length).toBe(0);
601
+ });
602
+
603
+ test("rejects symlinked files that point outside the skill root", async () => {
604
+ // Create a temp skill root AND a separate external directory.
605
+ const root = makeTempSkillsDir();
606
+ const externalRoot = mkdtempSync(join(tmpdir(), "catalog-files-ext-"));
607
+ tempDirs.push(externalRoot);
608
+
609
+ // Write an "external secret" file completely outside the skill tree.
610
+ const externalSecret = join(externalRoot, "secret.txt");
611
+ writeFileSync(externalSecret, "EXTERNAL_SECRET");
612
+
613
+ // Create the skill directory itself with a legitimate file.
614
+ const skillDir = writeSkill(root, "my-skill", { "SKILL.md": "ok" });
615
+
616
+ // Create a symlink INSIDE the skill dir pointing at the external file.
617
+ const linkPath = join(skillDir, "link-to-secret.md");
618
+ symlinkSync(externalSecret, linkPath);
619
+
620
+ mockRepoSkillsDir = root;
621
+ mockCatalog = [skill("my-skill")];
622
+ installFetchForbidden();
623
+
624
+ const entry = await readCatalogSkillFileContent(
625
+ "my-skill",
626
+ "link-to-secret.md",
627
+ );
628
+ expect(entry).toBeNull();
629
+
630
+ // And the legitimate file is still readable, so the check didn't
631
+ // collateral-damage normal requests.
632
+ const ok = await readCatalogSkillFileContent("my-skill", "SKILL.md");
633
+ expect(ok).not.toBeNull();
634
+ expect(ok!.content).toBe("ok");
635
+ });
636
+
637
+ test("rejects files accessed through a symlinked parent directory", async () => {
638
+ const root = makeTempSkillsDir();
639
+ const externalRoot = mkdtempSync(join(tmpdir(), "catalog-files-ext-"));
640
+ tempDirs.push(externalRoot);
641
+
642
+ // External directory with a real file inside it.
643
+ const externalDir = join(externalRoot, "external-dir");
644
+ mkdirSync(externalDir, { recursive: true });
645
+ writeFileSync(join(externalDir, "payload.txt"), "EXTERNAL_PAYLOAD");
646
+
647
+ // Legitimate skill dir with a normal file.
648
+ const skillDir = writeSkill(root, "my-skill", { "SKILL.md": "ok" });
649
+
650
+ // Inside the skill dir, create a symlinked subdirectory that points at
651
+ // the external directory. Then try to request
652
+ // `escape/payload.txt` — lexically this is inside the skill root, but
653
+ // the physical file lives outside.
654
+ const escapeLink = join(skillDir, "escape");
655
+ symlinkSync(externalDir, escapeLink);
656
+
657
+ mockRepoSkillsDir = root;
658
+ mockCatalog = [skill("my-skill")];
659
+ installFetchForbidden();
660
+
661
+ const entry = await readCatalogSkillFileContent(
662
+ "my-skill",
663
+ "escape/payload.txt",
664
+ );
665
+ expect(entry).toBeNull();
666
+ });
667
+
668
+ test("rejects dotfile paths and returns null without reading disk", async () => {
669
+ // `.env` is a valid sanitized path (sanitizeRelativePath accepts it),
670
+ // but the hidden-segment check must reject it so the catalog content
671
+ // reader never exposes dotfiles, matching the listing API that hides
672
+ // them. This preserves parity between listing and content endpoints.
673
+ const root = makeTempSkillsDir();
674
+ writeSkill(root, "my-skill", {
675
+ "SKILL.md": "ok",
676
+ ".env": "SECRET=abc\n",
677
+ });
678
+ mockRepoSkillsDir = root;
679
+ mockCatalog = [skill("my-skill")];
680
+ installFetchForbidden();
681
+
682
+ expect(await readCatalogSkillFileContent("my-skill", ".env")).toBeNull();
683
+ expect(
684
+ await readCatalogSkillFileContent("my-skill", ".git/config"),
685
+ ).toBeNull();
686
+ expect(
687
+ await readCatalogSkillFileContent("my-skill", "docs/.hidden/file.md"),
688
+ ).toBeNull();
689
+ });
690
+
691
+ test("rejects SKIP_DIRS paths and returns null without reading disk", async () => {
692
+ const root = makeTempSkillsDir();
693
+ writeSkill(root, "my-skill", {
694
+ "SKILL.md": "ok",
695
+ "node_modules/foo/index.js": "module.exports = {};",
696
+ });
697
+ mockRepoSkillsDir = root;
698
+ mockCatalog = [skill("my-skill")];
699
+ installFetchForbidden();
700
+
701
+ expect(
702
+ await readCatalogSkillFileContent(
703
+ "my-skill",
704
+ "node_modules/foo/index.js",
705
+ ),
706
+ ).toBeNull();
707
+ expect(
708
+ await readCatalogSkillFileContent("my-skill", "__pycache__/cached.pyc"),
709
+ ).toBeNull();
710
+ expect(
711
+ await readCatalogSkillFileContent(
712
+ "my-skill",
713
+ "nested/node_modules/foo.js",
714
+ ),
715
+ ).toBeNull();
716
+ });
717
+
718
+ test("regular docs/readme.md still returns content (sanity)", async () => {
719
+ const root = makeTempSkillsDir();
720
+ writeSkill(root, "my-skill", {
721
+ "docs/readme.md": "# readme\n",
722
+ });
723
+ mockRepoSkillsDir = root;
724
+ mockCatalog = [skill("my-skill")];
725
+ installFetchForbidden();
726
+
727
+ const entry = await readCatalogSkillFileContent(
728
+ "my-skill",
729
+ "docs/readme.md",
730
+ );
731
+ expect(entry).not.toBeNull();
732
+ expect(entry!.content).toBe("# readme\n");
733
+ expect(entry!.name).toBe("readme.md");
734
+ });
735
+ });
736
+
737
+ // ---------------------------------------------------------------------------
738
+ // readCatalogSkillFileContent — platform mode
739
+ // ---------------------------------------------------------------------------
740
+
741
+ describe("readCatalogSkillFileContent (platform mode)", () => {
742
+ test("maps snake_case text response to camelCase entry", async () => {
743
+ mockCatalog = [skill("remote-skill")];
744
+ installFetchMock(() =>
745
+ Response.json({
746
+ path: "SKILL.md",
747
+ name: "SKILL.md",
748
+ size: 14,
749
+ mime_type: "text/markdown",
750
+ is_binary: false,
751
+ content: "# hello world\n",
752
+ }),
753
+ );
754
+
755
+ const entry = await readCatalogSkillFileContent("remote-skill", "SKILL.md");
756
+ expect(entry).not.toBeNull();
757
+ expect(entry!.path).toBe("SKILL.md");
758
+ expect(entry!.name).toBe("SKILL.md");
759
+ expect(entry!.size).toBe(14);
760
+ expect(entry!.mimeType).toBe("text/markdown");
761
+ expect(entry!.isBinary).toBe(false);
762
+ expect(entry!.content).toBe("# hello world\n");
763
+
764
+ expect(fetchCalls.length).toBe(1);
765
+ const url = fetchCalls[0]!.url;
766
+ expect(
767
+ url.startsWith(
768
+ "https://platform.test/v1/skills/remote-skill/files/content/",
769
+ ),
770
+ ).toBe(true);
771
+ expect(url).toContain("path=SKILL.md");
772
+ });
773
+
774
+ test("preserves binary response (content=null, isBinary=true)", async () => {
775
+ mockCatalog = [skill("remote-skill")];
776
+ installFetchMock(() =>
777
+ Response.json({
778
+ path: "img.png",
779
+ name: "img.png",
780
+ size: 1024,
781
+ mime_type: "image/png",
782
+ is_binary: true,
783
+ content: null,
784
+ }),
785
+ );
786
+
787
+ const entry = await readCatalogSkillFileContent("remote-skill", "img.png");
788
+ expect(entry).not.toBeNull();
789
+ expect(entry!.isBinary).toBe(true);
790
+ expect(entry!.content).toBeNull();
791
+ expect(entry!.mimeType).toBe("image/png");
792
+ });
793
+
794
+ test("preserves oversized text response (content=null)", async () => {
795
+ mockCatalog = [skill("remote-skill")];
796
+ installFetchMock(() =>
797
+ Response.json({
798
+ path: "big.txt",
799
+ name: "big.txt",
800
+ size: 3 * 1024 * 1024,
801
+ mime_type: "text/plain",
802
+ is_binary: false,
803
+ content: null,
804
+ }),
805
+ );
806
+
807
+ const entry = await readCatalogSkillFileContent("remote-skill", "big.txt");
808
+ expect(entry).not.toBeNull();
809
+ expect(entry!.isBinary).toBe(false);
810
+ expect(entry!.content).toBeNull();
811
+ expect(entry!.size).toBe(3 * 1024 * 1024);
812
+ });
813
+
814
+ test("returns null on 404", async () => {
815
+ mockCatalog = [skill("remote-skill")];
816
+ installFetchMock(
817
+ () => new Response("missing", { status: 404, statusText: "Not Found" }),
818
+ );
819
+ expect(
820
+ await readCatalogSkillFileContent("remote-skill", "ghost.md"),
821
+ ).toBeNull();
822
+ });
823
+
824
+ test("rejects traversal BEFORE making any fetch call", async () => {
825
+ mockCatalog = [skill("remote-skill")];
826
+ installFetchForbidden();
827
+ expect(await readCatalogSkillFileContent("remote-skill", "..")).toBeNull();
828
+ expect(
829
+ await readCatalogSkillFileContent("remote-skill", "../etc/passwd"),
830
+ ).toBeNull();
831
+ expect(fetchCalls.length).toBe(0);
832
+ });
833
+
834
+ test("rejects hidden / SKIP_DIRS paths BEFORE making any fetch call", async () => {
835
+ // Platform-mode defense in depth: even though the platform endpoint
836
+ // would refuse these reads server-side, we must short-circuit in
837
+ // `readCatalogSkillFileContent` so an attacker cannot use the daemon
838
+ // as a probe channel (and so we avoid unnecessary network traffic).
839
+ mockCatalog = [skill("remote-skill")];
840
+ installFetchForbidden();
841
+ expect(
842
+ await readCatalogSkillFileContent("remote-skill", ".env"),
843
+ ).toBeNull();
844
+ expect(
845
+ await readCatalogSkillFileContent("remote-skill", ".git/config"),
846
+ ).toBeNull();
847
+ expect(
848
+ await readCatalogSkillFileContent(
849
+ "remote-skill",
850
+ "node_modules/foo/index.js",
851
+ ),
852
+ ).toBeNull();
853
+ expect(fetchCalls.length).toBe(0);
854
+ });
855
+
856
+ test("returns null without fetching when skill id missing from catalog", async () => {
857
+ mockCatalog = [];
858
+ installFetchForbidden();
859
+ expect(await readCatalogSkillFileContent("unknown", "SKILL.md")).toBeNull();
860
+ expect(fetchCalls.length).toBe(0);
861
+ });
862
+ });