@vellumai/assistant 0.11.5 → 0.11.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -0,0 +1,104 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ realpathSync,
5
+ symlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, test } from "bun:test";
11
+
12
+ import {
13
+ resolveStagedImportPath,
14
+ RESTORE_STAGING_DIRNAME,
15
+ StagedImportPathError,
16
+ } from "../staged-import-path.js";
17
+
18
+ function stagingWorkspace(): { workspace: string; staging: string } {
19
+ const workspace = realpathSync(mkdtempSync(join(tmpdir(), "staged-import-")));
20
+ const staging = join(workspace, RESTORE_STAGING_DIRNAME);
21
+ mkdirSync(staging, { recursive: true });
22
+ return { workspace, staging };
23
+ }
24
+
25
+ describe("resolveStagedImportPath", () => {
26
+ test("resolves a relative path inside the staging directory", () => {
27
+ const { workspace, staging } = stagingWorkspace();
28
+ const file = join(staging, "backup.vbundle");
29
+ writeFileSync(file, "bundle");
30
+
31
+ expect(
32
+ resolveStagedImportPath(`${RESTORE_STAGING_DIRNAME}/backup.vbundle`, workspace),
33
+ ).toBe(realpathSync(file));
34
+ });
35
+
36
+ test("resolves an absolute path inside the staging directory", () => {
37
+ const { workspace, staging } = stagingWorkspace();
38
+ const file = join(staging, "backup.vbundle");
39
+ writeFileSync(file, "bundle");
40
+
41
+ expect(resolveStagedImportPath(file, workspace)).toBe(realpathSync(file));
42
+ });
43
+
44
+ test("rejects a missing file", () => {
45
+ const { workspace } = stagingWorkspace();
46
+ expect(() =>
47
+ resolveStagedImportPath(
48
+ `${RESTORE_STAGING_DIRNAME}/missing.vbundle`,
49
+ workspace,
50
+ ),
51
+ ).toThrow(StagedImportPathError);
52
+ });
53
+
54
+ test("rejects traversal out of the staging directory", () => {
55
+ const { workspace } = stagingWorkspace();
56
+ writeFileSync(join(workspace, "secret.vbundle"), "nope");
57
+
58
+ expect(() =>
59
+ resolveStagedImportPath(
60
+ `${RESTORE_STAGING_DIRNAME}/../secret.vbundle`,
61
+ workspace,
62
+ ),
63
+ ).toThrow(StagedImportPathError);
64
+ });
65
+
66
+ test("rejects a path outside the workspace", () => {
67
+ const { workspace } = stagingWorkspace();
68
+ const outside = join(tmpdir(), `outside-${Date.now()}.vbundle`);
69
+ writeFileSync(outside, "nope");
70
+
71
+ expect(() => resolveStagedImportPath(outside, workspace)).toThrow(
72
+ StagedImportPathError,
73
+ );
74
+ });
75
+
76
+ test("rejects a symlink even when it points at a staging file", () => {
77
+ const { workspace, staging } = stagingWorkspace();
78
+ const file = join(staging, "backup.vbundle");
79
+ writeFileSync(file, "bundle");
80
+ const link = join(staging, "alias.vbundle");
81
+ symlinkSync(file, link);
82
+
83
+ expect(() => resolveStagedImportPath(link, workspace)).toThrow(
84
+ StagedImportPathError,
85
+ );
86
+ });
87
+
88
+ test("rejects a non-.vbundle file in staging", () => {
89
+ const { workspace, staging } = stagingWorkspace();
90
+ const file = join(staging, "notes.txt");
91
+ writeFileSync(file, "nope");
92
+
93
+ expect(() =>
94
+ resolveStagedImportPath(`${RESTORE_STAGING_DIRNAME}/notes.txt`, workspace),
95
+ ).toThrow(StagedImportPathError);
96
+ });
97
+
98
+ test("rejects an empty path", () => {
99
+ const { workspace } = stagingWorkspace();
100
+ expect(() => resolveStagedImportPath(" ", workspace)).toThrow(
101
+ StagedImportPathError,
102
+ );
103
+ });
104
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Resolve a caller-supplied staged-bundle path for local restore.
3
+ *
4
+ * The daemon only opens files that realpath into
5
+ * `${workspaceDir}/.restore-staging/`. Absolute host paths, `..`
6
+ * traversal, and symlink escapes are rejected.
7
+ */
8
+
9
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
10
+ import { relative, resolve, sep } from "node:path";
11
+
12
+ export const RESTORE_STAGING_DIRNAME = ".restore-staging";
13
+
14
+ export type StagedImportPathErrorCode =
15
+ | "empty"
16
+ | "not_found"
17
+ | "not_file"
18
+ | "symlink"
19
+ | "outside"
20
+ | "extension";
21
+
22
+ export class StagedImportPathError extends Error {
23
+ public readonly code: StagedImportPathErrorCode;
24
+
25
+ constructor(code: StagedImportPathErrorCode, message: string) {
26
+ super(message);
27
+ this.name = "StagedImportPathError";
28
+ this.code = code;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Resolve `requestedPath` to a regular `.vbundle` file inside the
34
+ * workspace restore-staging directory.
35
+ */
36
+ export function resolveStagedImportPath(
37
+ requestedPath: string,
38
+ workspaceDir: string,
39
+ ): string {
40
+ if (typeof requestedPath !== "string" || requestedPath.trim().length === 0) {
41
+ throw new StagedImportPathError("empty", "Staged bundle path is empty");
42
+ }
43
+ if (requestedPath.includes("\0")) {
44
+ throw new StagedImportPathError(
45
+ "empty",
46
+ "Staged bundle path is not a valid file path",
47
+ );
48
+ }
49
+
50
+ if (!existsSync(workspaceDir)) {
51
+ throw new StagedImportPathError(
52
+ "not_found",
53
+ "Staged bundle file not found",
54
+ );
55
+ }
56
+
57
+ const workspaceReal = realpathSync(workspaceDir);
58
+ const stagingRoot = resolve(workspaceReal, RESTORE_STAGING_DIRNAME);
59
+ const candidate = requestedPath.startsWith("/")
60
+ ? resolve(requestedPath)
61
+ : resolve(workspaceReal, requestedPath);
62
+
63
+ if (!existsSync(candidate)) {
64
+ throw new StagedImportPathError(
65
+ "not_found",
66
+ "Staged bundle file not found",
67
+ );
68
+ }
69
+
70
+ const linkStat = lstatSync(candidate);
71
+ if (linkStat.isSymbolicLink()) {
72
+ throw new StagedImportPathError(
73
+ "symlink",
74
+ "Staged bundle path must not be a symlink",
75
+ );
76
+ }
77
+
78
+ const realFile = realpathSync(candidate);
79
+ if (!existsSync(stagingRoot)) {
80
+ throw new StagedImportPathError(
81
+ "outside",
82
+ "Staged bundle path is not inside the restore staging directory",
83
+ );
84
+ }
85
+
86
+ const realStaging = realpathSync(stagingRoot);
87
+ const rel = relative(realStaging, realFile);
88
+ if (
89
+ rel === "" ||
90
+ rel.startsWith(`..${sep}`) ||
91
+ rel === ".." ||
92
+ rel.split(sep).includes("..")
93
+ ) {
94
+ throw new StagedImportPathError(
95
+ "outside",
96
+ "Staged bundle path is not inside the restore staging directory",
97
+ );
98
+ }
99
+
100
+ const fileStat = statSync(realFile);
101
+ if (!fileStat.isFile()) {
102
+ throw new StagedImportPathError(
103
+ "not_file",
104
+ "Staged bundle path is not a file",
105
+ );
106
+ }
107
+
108
+ if (!realFile.endsWith(".vbundle")) {
109
+ throw new StagedImportPathError(
110
+ "extension",
111
+ "Staged bundle must be a .vbundle file",
112
+ );
113
+ }
114
+
115
+ return realFile;
116
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Tests for app pinning across the app-management routes.
3
+ *
4
+ * Covers the two things the client depends on that the pin store alone cannot
5
+ * show: that a pin reaches the client on the app list rather than on a call of
6
+ * its own, and that deleting an app takes its pin with it.
7
+ */
8
+
9
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
12
+
13
+ const publishCalls: unknown[] = [];
14
+
15
+ mock.module("../../sync/resource-sync-events.js", () => ({
16
+ publishAppsChanged: (originClientId?: string) => {
17
+ publishCalls.push({ originClientId });
18
+ },
19
+ getOriginClientId: () => undefined,
20
+ }));
21
+
22
+ import { reconcileAppPins } from "../../../apps/app-pin-reconciler.js";
23
+ import { listAppPins, updateAppPin } from "../../../apps/app-pin-store.js";
24
+ import {
25
+ createApp,
26
+ deleteApp,
27
+ isPluginAppId,
28
+ } from "../../../apps/app-store.js";
29
+ import { getDb } from "../../../persistence/db-connection.js";
30
+ import { initializeDb } from "../../../persistence/db-init.js";
31
+ import { appPins } from "../../../persistence/schema/index.js";
32
+ import { getWorkspacePluginsDir } from "../../../util/platform.js";
33
+ import { ROUTES as APP_ROUTES } from "../app-management-routes.js";
34
+ import { BadRequestError } from "../errors.js";
35
+ import type { RouteDefinition, RouteHandlerArgs } from "../types.js";
36
+
37
+ function findHandler(operationId: string): RouteDefinition["handler"] {
38
+ const route = APP_ROUTES.find((r) => r.operationId === operationId);
39
+ if (!route) {
40
+ throw new Error(`Route ${operationId} not found`);
41
+ }
42
+ return route.handler;
43
+ }
44
+
45
+ const listHandler = findHandler("apps_list");
46
+ const pinHandler = findHandler("apps_pin");
47
+ const deleteHandler = findHandler("apps_delete");
48
+
49
+ await initializeDb();
50
+
51
+ interface ListedApp {
52
+ id: string;
53
+ name: string;
54
+ pinSortPosition?: number;
55
+ pinColor?: string;
56
+ }
57
+
58
+ /**
59
+ * Install a plugin that bundles one app, and return the id the daemon builds
60
+ * for it. A real directory rather than a hand-written id: `handlePinApp`
61
+ * validates against `listPluginApps()`, so a synthetic id would be rejected and
62
+ * this would stop covering plugin pinning at all.
63
+ */
64
+ function installPluginApp(pluginName: string, appDir: string): string {
65
+ const pluginRoot = join(getWorkspacePluginsDir(), pluginName);
66
+ mkdirSync(join(pluginRoot, "apps", appDir), { recursive: true });
67
+ writeFileSync(
68
+ join(pluginRoot, "package.json"),
69
+ JSON.stringify({ name: pluginName, version: "1.0.0" }),
70
+ );
71
+ const id = `plugins~${pluginName}~${appDir}`;
72
+ /* Guards the fixture, not the route: an id the daemon does not read as a
73
+ plugin app would exercise the ordinary workspace path instead. */
74
+ expect(isPluginAppId(id)).toBe(true);
75
+ return id;
76
+ }
77
+
78
+ /** Retire a plugin the way an uninstall does: its apps stop enumerating. */
79
+ function uninstallPlugin(pluginName: string): void {
80
+ rmSync(join(getWorkspacePluginsDir(), pluginName), {
81
+ recursive: true,
82
+ force: true,
83
+ });
84
+ }
85
+
86
+ function makeApp(name: string): string {
87
+ const app = createApp({
88
+ name,
89
+ schemaJson: "{}",
90
+ htmlDefinition: "<html></html>",
91
+ });
92
+ return app.id;
93
+ }
94
+
95
+ async function listApps(): Promise<ListedApp[]> {
96
+ const result = (await listHandler({} as RouteHandlerArgs)) as {
97
+ apps: ListedApp[];
98
+ };
99
+ return result.apps;
100
+ }
101
+
102
+ async function pin(
103
+ appId: string,
104
+ body: { pinned?: boolean; color?: string | null },
105
+ ) {
106
+ return (await pinHandler({
107
+ pathParams: { id: appId },
108
+ body,
109
+ } as unknown as RouteHandlerArgs)) as {
110
+ success: boolean;
111
+ appId: string;
112
+ pinSortPosition: number | null;
113
+ pinColor: string | null;
114
+ };
115
+ }
116
+
117
+ async function findListed(appId: string): Promise<ListedApp | undefined> {
118
+ return (await listApps()).find((app) => app.id === appId);
119
+ }
120
+
121
+ beforeEach(() => {
122
+ publishCalls.length = 0;
123
+ getDb().delete(appPins).run();
124
+ });
125
+
126
+ describe("apps_list", () => {
127
+ /* Absent rather than null or 0: the client tells a pinned app from an
128
+ unpinned one by whether the field is there at all. */
129
+ test("omits the pin fields on an app that is not pinned", async () => {
130
+ const appId = makeApp("Unpinned");
131
+
132
+ const listed = await findListed(appId);
133
+
134
+ expect(listed?.pinSortPosition).toBeUndefined();
135
+ expect(listed?.pinColor).toBeUndefined();
136
+ });
137
+
138
+ test("carries the pin and its colour once one is set", async () => {
139
+ const appId = makeApp("Pinned");
140
+ await pin(appId, { pinned: true });
141
+ await pin(appId, { color: "teal" });
142
+
143
+ const listed = await findListed(appId);
144
+
145
+ expect(listed?.pinSortPosition).toBeGreaterThan(0);
146
+ expect(listed?.pinColor).toBe("teal");
147
+ });
148
+
149
+ test("orders pinned apps by when they were pinned", async () => {
150
+ const first = makeApp("First");
151
+ const second = makeApp("Second");
152
+ await pin(first, { pinned: true });
153
+ await pin(second, { pinned: true });
154
+
155
+ const apps = await listApps();
156
+ const firstPosition = apps.find((app) => app.id === first)?.pinSortPosition;
157
+ const secondPosition = apps.find(
158
+ (app) => app.id === second,
159
+ )?.pinSortPosition;
160
+
161
+ expect(firstPosition).toBeDefined();
162
+ expect(secondPosition).toBeGreaterThan(firstPosition!);
163
+ });
164
+
165
+ /* The store can hold a pin for an id no app has, but such a pin has nothing
166
+ to render, so it must never reach a client. Asserted through the route
167
+ because the join is the only thing standing between the two. */
168
+ test("a pin whose app is gone reaches no client", async () => {
169
+ const appId = makeApp("Doomed");
170
+ await pin(appId, { pinned: true });
171
+ deleteApp(appId);
172
+
173
+ const apps = await listApps();
174
+
175
+ expect(apps.some((app) => app.id === appId)).toBe(false);
176
+ expect(apps.some((app) => app.pinSortPosition !== undefined)).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe("apps_pin", () => {
181
+ test("reports the resulting pin, and nulls once unpinned", async () => {
182
+ const appId = makeApp("Togglable");
183
+
184
+ expect(await pin(appId, { pinned: true })).toMatchObject({
185
+ success: true,
186
+ appId,
187
+ pinColor: null,
188
+ });
189
+ expect(await pin(appId, { pinned: false })).toMatchObject({
190
+ pinSortPosition: null,
191
+ pinColor: null,
192
+ });
193
+ });
194
+
195
+ /* Pin state is a preference about an app, not part of its content, so the
196
+ plugin-app mutation guard does not apply. Driven through the route because
197
+ that guard is what the route would otherwise impose. */
198
+ test("pins a plugin app, whose record the daemon cannot write", async () => {
199
+ const pluginAppId = installPluginApp("demo", "widget");
200
+
201
+ const result = await pin(pluginAppId, { pinned: true });
202
+
203
+ expect(result.pinSortPosition).not.toBeNull();
204
+ expect(listAppPins()[0]?.appId).toBe(pluginAppId);
205
+ });
206
+
207
+ /* The list starts from the apps that exist, so a pin for anything else could
208
+ never be read back and would sit in the table forever. */
209
+ test("refuses to pin an app that does not exist", async () => {
210
+ await expect(pin("no-such-app", { pinned: true })).rejects.toThrow(
211
+ /App not found/,
212
+ );
213
+ expect(listAppPins()).toEqual([]);
214
+ });
215
+
216
+ /* Exempt from the check above: this is how a client clears a pin left over
217
+ from an app that has since gone. */
218
+ test("still accepts an unpin for an app that does not exist", async () => {
219
+ expect(await pin("no-such-app", { pinned: false })).toMatchObject({
220
+ pinSortPosition: null,
221
+ });
222
+ });
223
+
224
+ test("rejects a request that asks for no change", async () => {
225
+ const appId = makeApp("Untouched");
226
+
227
+ await expect(pin(appId, {})).rejects.toThrow(/pinned or color is required/);
228
+ });
229
+
230
+ /*
231
+ * `RouteDefinition.requestBody` is a codegen signal and does not validate, so
232
+ * the handler is the only thing standing between a caller and the store. Both
233
+ * transports reach this handler with whatever JSON arrived, so a cast here
234
+ * would narrow nothing and a malformed value would land in the database and
235
+ * come back out in the response.
236
+ */
237
+ describe("malformed bodies", () => {
238
+ const cases: [string, unknown][] = [
239
+ ["a non-boolean pinned", { pinned: "yes" }],
240
+ ["a numeric pinned", { pinned: 1 }],
241
+ ["a non-string colour", { pinned: true, color: 7 }],
242
+ ["an array colour", { pinned: true, color: ["teal"] }],
243
+ ["a body that is not an object", "pinned"],
244
+ ];
245
+
246
+ for (const [label, body] of cases) {
247
+ test(`rejects ${label}`, async () => {
248
+ const appId = makeApp(`App for ${label}`);
249
+
250
+ await expect(
251
+ pin(appId, body as { pinned?: boolean; color?: string | null }),
252
+ ).rejects.toBeInstanceOf(BadRequestError);
253
+ expect(listAppPins()).toEqual([]);
254
+ });
255
+ }
256
+ });
257
+
258
+ /* Other windows and devices learn about a pin only through this broadcast:
259
+ nothing else tells them the app list changed. */
260
+ test("announces the app list changed", async () => {
261
+ const appId = makeApp("Broadcasting");
262
+
263
+ await pin(appId, { pinned: true });
264
+
265
+ expect(publishCalls).toHaveLength(1);
266
+ });
267
+ });
268
+
269
+ /*
270
+ * A workspace app's id is a UUID, so a pin left behind when one is deleted can
271
+ * never be adopted. A plugin app's id is a path identity, so retiring a plugin
272
+ * and installing it again rebuilds the same id: a pin left behind comes back
273
+ * with it, for an app the user could not see to unpin while it was hidden.
274
+ *
275
+ * The reconcile runs on plugin-set convergence, at startup, and on a backstop
276
+ * sweep, so these drive it directly. Nothing else happens between the retire
277
+ * and the reinstall: an intervening pin write would be a trigger the real
278
+ * sequence does not have, and the case would pass without proving anything.
279
+ */
280
+ describe("orphan pins across a plugin reinstall", () => {
281
+ test("a retired plugin's pin does not return when it is installed again", async () => {
282
+ const pluginAppId = installPluginApp("demo", "widget");
283
+ await pin(pluginAppId, { pinned: true });
284
+
285
+ uninstallPlugin("demo");
286
+ reconcileAppPins();
287
+ installPluginApp("demo", "widget");
288
+
289
+ const listed = await findListed(pluginAppId);
290
+ expect(listed).toBeDefined();
291
+ expect(listed?.pinSortPosition).toBeUndefined();
292
+ expect(listAppPins()).toEqual([]);
293
+ });
294
+
295
+ /* The sensitivity check on the case above: without the reconcile between
296
+ retire and reinstall the pin does come back, so that assertion is carried
297
+ by the reconcile and not by the fixture happening to lose the row. */
298
+ test("without the reconcile, the same sequence restores the pin", async () => {
299
+ const pluginAppId = installPluginApp("demo", "widget");
300
+ await pin(pluginAppId, { pinned: true });
301
+
302
+ uninstallPlugin("demo");
303
+ installPluginApp("demo", "widget");
304
+
305
+ expect((await findListed(pluginAppId))?.pinSortPosition).toBeDefined();
306
+ });
307
+
308
+ test("leaves the pin alone while the plugin is still installed", async () => {
309
+ const pluginAppId = installPluginApp("demo", "widget");
310
+ await pin(pluginAppId, { pinned: true });
311
+
312
+ reconcileAppPins();
313
+
314
+ expect(listAppPins().map((entry) => entry.appId)).toEqual([pluginAppId]);
315
+ });
316
+
317
+ test("drops a pin for a workspace app that was deleted outside the route", () => {
318
+ const appId = makeApp("Gone");
319
+ updateAppPin(appId, { pinned: true });
320
+ deleteApp(appId);
321
+
322
+ reconcileAppPins();
323
+
324
+ expect(listAppPins()).toEqual([]);
325
+ });
326
+
327
+ /* A pass is the one pin write with no request behind it, so nothing else
328
+ tells a connected client. Without this the daemon converges and an open
329
+ sidebar keeps rendering the pin until an unrelated app refetch lands. */
330
+ test("announces the apps list when a pass removes a pin", () => {
331
+ const appId = makeApp("Gone");
332
+ updateAppPin(appId, { pinned: true });
333
+ deleteApp(appId);
334
+ publishCalls.length = 0;
335
+
336
+ reconcileAppPins();
337
+
338
+ expect(publishCalls).toHaveLength(1);
339
+ });
340
+
341
+ /* Counted rather than merely observed: a pass that announced unconditionally
342
+ would drain every client's app list on a timer, sixty seconds apart,
343
+ forever. */
344
+ test("announces nothing when a pass removes no pin", () => {
345
+ const appId = makeApp("Still here");
346
+ updateAppPin(appId, { pinned: true });
347
+ publishCalls.length = 0;
348
+
349
+ reconcileAppPins();
350
+
351
+ expect(publishCalls).toEqual([]);
352
+ });
353
+ });
354
+
355
+ describe("apps_delete", () => {
356
+ test("takes the deleted app's pin with it", async () => {
357
+ const appId = makeApp("Deleted");
358
+ await pin(appId, { pinned: true });
359
+
360
+ await deleteHandler({
361
+ pathParams: { id: appId },
362
+ } as unknown as RouteHandlerArgs);
363
+
364
+ expect(listAppPins()).toEqual([]);
365
+ });
366
+
367
+ test("leaves the other pins alone", async () => {
368
+ const first = makeApp("First");
369
+ const second = makeApp("Second");
370
+ await pin(first, { pinned: true });
371
+ await pin(second, { pinned: true });
372
+ const survivorPosition = listAppPins().find(
373
+ (entry) => entry.appId === second,
374
+ )?.sortPosition;
375
+
376
+ await deleteHandler({
377
+ pathParams: { id: first },
378
+ } as unknown as RouteHandlerArgs);
379
+
380
+ expect(listAppPins().map((entry) => entry.appId)).toEqual([second]);
381
+ expect(listAppPins()[0]?.sortPosition).toBe(survivorPosition!);
382
+ });
383
+ });
@@ -1764,3 +1764,83 @@ describe("provider membership at the write choke point", () => {
1764
1764
  ).rejects.toThrow(/Invalid provider/);
1765
1765
  });
1766
1766
  });
1767
+
1768
+ describe("ingress URL writes through the generic config routes", () => {
1769
+ const configPatchRoute = ROUTES.find(
1770
+ (r) => r.operationId === "config_patch",
1771
+ )!;
1772
+ const configSetRoute = ROUTES.find((r) => r.operationId === "config_set")!;
1773
+
1774
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
1775
+ const OTHER_URL = "https://assistant-2.example.ts.net";
1776
+ const LAST_TUNNEL = { provider: "tailscale", publicBaseUrl: TUNNEL_URL };
1777
+
1778
+ const savedIngress = () =>
1779
+ (loadRawConfig().ingress ?? {}) as Record<string, unknown>;
1780
+
1781
+ beforeEach(() => {
1782
+ // `assistant config set ingress.publicBaseUrl <url>` is what the webhook
1783
+ // diagnostics tell users to run when their tunnel address changed, so it
1784
+ // has to leave the same clean state the settings route does.
1785
+ rawConfigFixture = {
1786
+ ingress: {
1787
+ publicBaseUrl: TUNNEL_URL,
1788
+ assistantId: "assistant-1",
1789
+ lastTunnel: LAST_TUNNEL,
1790
+ },
1791
+ };
1792
+ seedRawConfig();
1793
+ });
1794
+
1795
+ test("a config_set retarget drops the tunnel records", async () => {
1796
+ await configSetRoute.handler({
1797
+ body: { path: "ingress.publicBaseUrl", value: OTHER_URL },
1798
+ });
1799
+
1800
+ const ingress = savedIngress();
1801
+ expect(ingress.publicBaseUrl).toBe(OTHER_URL);
1802
+ expect(ingress.assistantId).toBeUndefined();
1803
+ expect(ingress.lastTunnel).toBeUndefined();
1804
+ });
1805
+
1806
+ test("a config_set of the same URL keeps them", async () => {
1807
+ await configSetRoute.handler({
1808
+ body: { path: "ingress.publicBaseUrl", value: `${TUNNEL_URL}/` },
1809
+ });
1810
+
1811
+ const ingress = savedIngress();
1812
+ expect(ingress.assistantId).toBe("assistant-1");
1813
+ expect(ingress.lastTunnel).toEqual(LAST_TUNNEL);
1814
+ });
1815
+
1816
+ test("a config_set of another ingress key keeps them", async () => {
1817
+ await configSetRoute.handler({
1818
+ body: { path: "ingress.enabled", value: false },
1819
+ });
1820
+
1821
+ const ingress = savedIngress();
1822
+ expect(ingress.assistantId).toBe("assistant-1");
1823
+ expect(ingress.lastTunnel).toEqual(LAST_TUNNEL);
1824
+ });
1825
+
1826
+ test("a config_patch retarget drops the tunnel records", async () => {
1827
+ await configPatchRoute.handler({
1828
+ body: { ingress: { publicBaseUrl: OTHER_URL } },
1829
+ });
1830
+
1831
+ const ingress = savedIngress();
1832
+ expect(ingress.publicBaseUrl).toBe(OTHER_URL);
1833
+ expect(ingress.assistantId).toBeUndefined();
1834
+ expect(ingress.lastTunnel).toBeUndefined();
1835
+ });
1836
+
1837
+ test("a config_patch that leaves the URL alone keeps them", async () => {
1838
+ await configPatchRoute.handler({
1839
+ body: { ingress: { enabled: false } },
1840
+ });
1841
+
1842
+ const ingress = savedIngress();
1843
+ expect(ingress.assistantId).toBe("assistant-1");
1844
+ expect(ingress.lastTunnel).toEqual(LAST_TUNNEL);
1845
+ });
1846
+ });