@vellumai/assistant 0.6.0 → 0.6.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 (285) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +68 -15
  3. package/Dockerfile +2 -2
  4. package/bun.lock +6 -2
  5. package/docker-entrypoint.sh +32 -1
  6. package/docs/architecture/integrations.md +1 -1
  7. package/docs/architecture/memory.md +21 -24
  8. package/openapi.yaml +538 -3
  9. package/package.json +5 -1
  10. package/src/__tests__/anthropic-provider.test.ts +160 -95
  11. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +47 -1
  13. package/src/__tests__/app-source-watcher.test.ts +159 -0
  14. package/src/__tests__/checker.test.ts +38 -6
  15. package/src/__tests__/config-schema.test.ts +5 -0
  16. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -6
  17. package/src/__tests__/conversation-agent-loop.test.ts +4 -51
  18. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  19. package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
  20. package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
  21. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
  22. package/src/__tests__/conversation-wipe.test.ts +2 -6
  23. package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
  24. package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
  25. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  26. package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
  27. package/src/__tests__/date-context.test.ts +76 -210
  28. package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
  29. package/src/__tests__/file-list-tool.test.ts +219 -0
  30. package/src/__tests__/first-greeting.test.ts +1 -1
  31. package/src/__tests__/heartbeat-service.test.ts +180 -3
  32. package/src/__tests__/identity-routes.test.ts +328 -0
  33. package/src/__tests__/injection-block.test.ts +24 -0
  34. package/src/__tests__/install-skill-routing.test.ts +7 -6
  35. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +15 -14
  36. package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
  37. package/src/__tests__/llm-context-normalization.test.ts +18 -18
  38. package/src/__tests__/llm-context-route-provider.test.ts +101 -0
  39. package/src/__tests__/llm-request-log-turn-query.test.ts +162 -0
  40. package/src/__tests__/log-export-workspace.test.ts +72 -105
  41. package/src/__tests__/mcp-abort-signal.test.ts +5 -0
  42. package/src/__tests__/mcp-client-auth.test.ts +5 -0
  43. package/src/__tests__/memory-recall-log-store.test.ts +132 -0
  44. package/src/__tests__/migration-export-streaming.test.ts +304 -0
  45. package/src/__tests__/migration-import-commit-http.test.ts +11 -10
  46. package/src/__tests__/mock-fetch.ts +87 -0
  47. package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
  48. package/src/__tests__/onboarding-template-contract.test.ts +62 -14
  49. package/src/__tests__/parser.test.ts +32 -0
  50. package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
  51. package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
  52. package/src/__tests__/permission-mode-sse.test.ts +418 -0
  53. package/src/__tests__/permission-mode-store.test.ts +277 -0
  54. package/src/__tests__/permission-mode.test.ts +101 -0
  55. package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
  56. package/src/__tests__/profiler-routes.test.ts +502 -0
  57. package/src/__tests__/profiler-run-store.test.ts +441 -0
  58. package/src/__tests__/proxy-approval-callback.test.ts +4 -75
  59. package/src/__tests__/registry.test.ts +1 -1
  60. package/src/__tests__/sandbox-host-parity.test.ts +5 -4
  61. package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
  62. package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
  63. package/src/__tests__/search-skills-unified.test.ts +4 -3
  64. package/src/__tests__/send-endpoint-busy.test.ts +42 -3
  65. package/src/__tests__/set-permission-mode.test.ts +274 -0
  66. package/src/__tests__/skill-load-feature-flag.test.ts +12 -0
  67. package/src/__tests__/skill-memory.test.ts +2 -783
  68. package/src/__tests__/strip-memory-injections.test.ts +187 -0
  69. package/src/__tests__/subagent-detail.test.ts +84 -0
  70. package/src/__tests__/subagent-disposal.test.ts +308 -0
  71. package/src/__tests__/subagent-manager-notify.test.ts +19 -10
  72. package/src/__tests__/subagent-notify-parent.test.ts +390 -0
  73. package/src/__tests__/subagent-role-registry.test.ts +108 -0
  74. package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
  75. package/src/__tests__/subagent-tools.test.ts +464 -4
  76. package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
  77. package/src/__tests__/task-memory-cleanup.test.ts +12 -12
  78. package/src/__tests__/terminal-tools.test.ts +17 -27
  79. package/src/__tests__/test-preload.ts +4 -0
  80. package/src/__tests__/tool-executor.test.ts +4 -26
  81. package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
  82. package/src/__tests__/top-level-renderer.test.ts +10 -13
  83. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +116 -2
  84. package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
  85. package/src/agent/loop.ts +6 -0
  86. package/src/approvals/guardian-request-resolvers.ts +24 -0
  87. package/src/avatar/traits-png-sync.ts +3 -3
  88. package/src/cli/__tests__/run-assistant-command.ts +29 -0
  89. package/src/cli/commands/__tests__/email-download.test.ts +245 -0
  90. package/src/cli/commands/__tests__/email-list.test.ts +192 -0
  91. package/src/cli/commands/__tests__/email-register.test.ts +186 -0
  92. package/src/cli/commands/__tests__/email-send.test.ts +291 -0
  93. package/src/cli/commands/__tests__/email-status.test.ts +181 -0
  94. package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
  95. package/src/cli/commands/__tests__/routes.test.ts +562 -0
  96. package/src/cli/commands/conversations.ts +1 -8
  97. package/src/cli/commands/email.ts +584 -835
  98. package/src/cli/commands/memory.ts +1 -34
  99. package/src/cli/commands/notifications.ts +7 -2
  100. package/src/cli/commands/oauth/connect.ts +14 -5
  101. package/src/cli/commands/routes.ts +396 -0
  102. package/src/cli/commands/skills.ts +130 -20
  103. package/src/cli/program.ts +2 -0
  104. package/src/cli.ts +1 -120
  105. package/src/config/bundled-skills/app-builder/SKILL.md +4 -1
  106. package/src/config/bundled-skills/gmail/SKILL.md +2 -2
  107. package/src/config/bundled-skills/messaging/SKILL.md +7 -0
  108. package/src/config/bundled-skills/schedule/SKILL.md +22 -2
  109. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  110. package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
  111. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
  112. package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
  113. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  114. package/src/config/bundled-skills/subagent/SKILL.md +43 -3
  115. package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
  116. package/src/config/env-registry.ts +63 -0
  117. package/src/config/feature-flag-registry.json +17 -1
  118. package/src/config/schema.ts +8 -0
  119. package/src/config/schemas/filing.ts +51 -0
  120. package/src/config/schemas/heartbeat.ts +15 -12
  121. package/src/config/schemas/memory-lifecycle.ts +12 -0
  122. package/src/config/schemas/security.ts +14 -0
  123. package/src/daemon/app-source-watcher.ts +93 -0
  124. package/src/daemon/config-watcher.ts +79 -1
  125. package/src/daemon/conversation-agent-loop-handlers.ts +20 -0
  126. package/src/daemon/conversation-agent-loop.ts +158 -65
  127. package/src/daemon/conversation-history.ts +4 -19
  128. package/src/daemon/conversation-lifecycle.ts +8 -14
  129. package/src/daemon/conversation-process.ts +13 -7
  130. package/src/daemon/conversation-runtime-assembly.ts +300 -306
  131. package/src/daemon/conversation-tool-setup.ts +44 -14
  132. package/src/daemon/conversation-workspace.ts +1 -2
  133. package/src/daemon/conversation.ts +18 -0
  134. package/src/daemon/date-context.ts +26 -53
  135. package/src/daemon/first-greeting.ts +1 -1
  136. package/src/daemon/handlers/conversations.ts +4 -7
  137. package/src/daemon/handlers/shared.test.ts +143 -0
  138. package/src/daemon/handlers/shared.ts +63 -5
  139. package/src/daemon/handlers/skills.ts +11 -18
  140. package/src/daemon/lifecycle.ts +199 -157
  141. package/src/daemon/message-types/conversations.ts +25 -6
  142. package/src/daemon/message-types/messages.ts +9 -1
  143. package/src/daemon/message-types/schedules.ts +1 -0
  144. package/src/daemon/message-types/settings.ts +6 -0
  145. package/src/daemon/profiler-run-store.ts +557 -0
  146. package/src/daemon/server.ts +89 -9
  147. package/src/daemon/shutdown-handlers.ts +5 -0
  148. package/src/daemon/tool-side-effects.ts +23 -3
  149. package/src/export/transcript-formatter.ts +148 -0
  150. package/src/filing/filing-service.ts +228 -0
  151. package/src/heartbeat/heartbeat-service.ts +96 -7
  152. package/src/mcp/client.ts +6 -0
  153. package/src/mcp/mcp-oauth-provider.ts +149 -27
  154. package/src/memory/admin.ts +33 -32
  155. package/src/memory/app-store.ts +69 -0
  156. package/src/memory/conversation-bootstrap.ts +1 -1
  157. package/src/memory/conversation-crud.ts +136 -107
  158. package/src/memory/conversation-group-migration.ts +1 -1
  159. package/src/memory/conversation-queries.ts +58 -12
  160. package/src/memory/conversation-title-service.ts +1 -0
  161. package/src/memory/db-init.ts +182 -376
  162. package/src/memory/graph/bootstrap.ts +75 -66
  163. package/src/memory/graph/capability-seed.ts +167 -15
  164. package/src/memory/graph/consolidation.ts +38 -4
  165. package/src/memory/graph/conversation-graph-memory.ts +133 -104
  166. package/src/memory/graph/extraction-job.ts +9 -4
  167. package/src/memory/graph/extraction.ts +66 -23
  168. package/src/memory/graph/graph-memory-state-store.ts +37 -0
  169. package/src/memory/graph/graph-search.ts +29 -15
  170. package/src/memory/graph/injection.ts +38 -8
  171. package/src/memory/graph/inspect.ts +12 -3
  172. package/src/memory/graph/retriever.ts +365 -262
  173. package/src/memory/graph/store.test.ts +48 -0
  174. package/src/memory/graph/store.ts +150 -11
  175. package/src/memory/graph/tool-handlers.ts +84 -209
  176. package/src/memory/graph/tools.ts +8 -52
  177. package/src/memory/graph/types.ts +24 -0
  178. package/src/memory/job-handlers/cleanup.ts +44 -1
  179. package/src/memory/jobs-store.ts +70 -60
  180. package/src/memory/jobs-worker.ts +44 -28
  181. package/src/memory/llm-request-log-store.ts +96 -12
  182. package/src/memory/memory-recall-log-store.ts +49 -5
  183. package/src/memory/migrations/203-drop-memory-items-tables.ts +33 -1
  184. package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
  185. package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
  186. package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
  187. package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
  188. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
  189. package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
  190. package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
  191. package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
  192. package/src/memory/migrations/index.ts +8 -0
  193. package/src/memory/migrations/registry.ts +8 -0
  194. package/src/memory/schema/conversations.ts +14 -0
  195. package/src/memory/schema/infrastructure.ts +8 -1
  196. package/src/memory/schema/memory-core.ts +0 -51
  197. package/src/memory/schema/memory-graph.ts +15 -0
  198. package/src/memory/task-memory-cleanup.ts +30 -11
  199. package/src/notifications/copy-composer.ts +86 -0
  200. package/src/notifications/decision-engine.ts +35 -0
  201. package/src/permissions/checker.ts +12 -1
  202. package/src/permissions/permission-mode-store.ts +180 -0
  203. package/src/permissions/permission-mode.ts +31 -0
  204. package/src/permissions/workspace-policy.ts +9 -0
  205. package/src/prompts/system-prompt.ts +59 -7
  206. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
  207. package/src/prompts/templates/BOOTSTRAP.md +70 -165
  208. package/src/prompts/templates/HEARTBEAT.md +3 -1
  209. package/src/prompts/templates/SOUL.md +25 -4
  210. package/src/prompts/templates/UPDATES.md +8 -0
  211. package/src/providers/anthropic/client.ts +107 -219
  212. package/src/runtime/auth/route-policy.ts +23 -0
  213. package/src/runtime/http-server.ts +32 -2
  214. package/src/runtime/http-types.ts +12 -1
  215. package/src/runtime/migrations/vbundle-builder.ts +389 -3
  216. package/src/runtime/migrations/vbundle-importer.ts +8 -6
  217. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
  218. package/src/runtime/routes/app-management-routes.ts +1 -11
  219. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
  220. package/src/runtime/routes/archive-utils.ts +29 -0
  221. package/src/runtime/routes/avatar-routes.ts +2 -9
  222. package/src/runtime/routes/btw-routes.ts +14 -1
  223. package/src/runtime/routes/conversation-analysis-routes.ts +173 -0
  224. package/src/runtime/routes/conversation-management-routes.ts +1 -14
  225. package/src/runtime/routes/conversation-query-routes.ts +49 -3
  226. package/src/runtime/routes/conversation-routes.ts +264 -44
  227. package/src/runtime/routes/heartbeat-routes.ts +4 -10
  228. package/src/runtime/routes/identity-routes.ts +53 -18
  229. package/src/runtime/routes/llm-context-normalization.ts +14 -10
  230. package/src/runtime/routes/log-export-routes.ts +23 -275
  231. package/src/runtime/routes/memory-item-routes.test.ts +168 -233
  232. package/src/runtime/routes/migration-routes.ts +18 -7
  233. package/src/runtime/routes/profiler-routes.ts +350 -0
  234. package/src/runtime/routes/schedule-routes.ts +27 -12
  235. package/src/runtime/routes/settings-routes.ts +95 -8
  236. package/src/runtime/routes/subagents-routes.ts +28 -7
  237. package/src/runtime/routes/user-route-dispatcher.ts +223 -0
  238. package/src/runtime/routes/user-routes.ts +41 -0
  239. package/src/runtime/routes/workspace-routes.ts +0 -1
  240. package/src/schedule/schedule-store.ts +30 -0
  241. package/src/schedule/scheduler.ts +45 -18
  242. package/src/skills/catalog-install.ts +10 -2
  243. package/src/skills/managed-store.ts +2 -2
  244. package/src/skills/skill-memory.ts +1 -293
  245. package/src/subagent/index.ts +13 -3
  246. package/src/subagent/manager.ts +308 -29
  247. package/src/subagent/types.ts +68 -0
  248. package/src/tasks/task-runner.ts +4 -4
  249. package/src/tools/apps/executors.ts +29 -4
  250. package/src/tools/filesystem/list.ts +93 -0
  251. package/src/tools/permission-checker.ts +78 -0
  252. package/src/tools/registry.ts +4 -0
  253. package/src/tools/schedule/create.ts +3 -0
  254. package/src/tools/schedule/list.ts +1 -0
  255. package/src/tools/schedule/update.ts +6 -0
  256. package/src/tools/shared/filesystem/errors.ts +5 -0
  257. package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
  258. package/src/tools/shared/filesystem/types.ts +17 -0
  259. package/src/tools/shared/shell-output.ts +31 -2
  260. package/src/tools/subagent/abort.ts +12 -2
  261. package/src/tools/subagent/message.ts +9 -2
  262. package/src/tools/subagent/notify-parent.ts +79 -0
  263. package/src/tools/subagent/read.ts +29 -8
  264. package/src/tools/subagent/resolve.ts +21 -0
  265. package/src/tools/subagent/spawn.ts +2 -0
  266. package/src/tools/subagent/status.ts +11 -1
  267. package/src/tools/system/avatar-generator.ts +3 -3
  268. package/src/tools/system/register.ts +23 -0
  269. package/src/tools/system/set-permission-mode.ts +103 -0
  270. package/src/tools/terminal/parser.ts +30 -5
  271. package/src/tools/terminal/safe-env.ts +16 -1
  272. package/src/tools/tool-manifest.ts +6 -0
  273. package/src/tools/types.ts +2 -0
  274. package/src/util/logger.ts +1 -1
  275. package/src/util/platform.ts +50 -17
  276. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
  277. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
  278. package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
  279. package/src/workspace/migrations/029-seed-pkb.ts +84 -0
  280. package/src/workspace/migrations/registry.ts +4 -0
  281. package/src/workspace/top-level-renderer.ts +5 -9
  282. package/src/__tests__/cli-memory.test.ts +0 -377
  283. package/src/__tests__/clipboard.test.ts +0 -88
  284. package/src/cli/cli-memory.ts +0 -179
  285. package/src/util/clipboard.ts +0 -34
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Tests for permission mode SSE broadcast and HTTP endpoints.
3
+ *
4
+ * Verifies:
5
+ * - SSE `permission_mode_update` event is published on mode change
6
+ * - GET /v1/permission-mode returns current state (always available)
7
+ * - PUT /v1/permission-mode updates state and broadcasts (flag on)
8
+ * - PUT /v1/permission-mode returns 404 when flag is off
9
+ */
10
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import {
13
+ afterAll,
14
+ afterEach,
15
+ beforeEach,
16
+ describe,
17
+ expect,
18
+ mock,
19
+ test,
20
+ } from "bun:test";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mocks — declared before imports that depend on platform/logger
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const WORKSPACE_DIR = process.env.VELLUM_WORKSPACE_DIR!;
27
+ const CONFIG_PATH = join(WORKSPACE_DIR, "config.json");
28
+
29
+ function ensureTestDir(): void {
30
+ const dirs = [
31
+ WORKSPACE_DIR,
32
+ join(WORKSPACE_DIR, "data"),
33
+ join(WORKSPACE_DIR, "data", "logs"),
34
+ ];
35
+ for (const dir of dirs) {
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ }
38
+ }
39
+
40
+ function makeLoggerStub(): Record<string, unknown> {
41
+ const stub: Record<string, unknown> = {};
42
+ for (const m of [
43
+ "info",
44
+ "warn",
45
+ "error",
46
+ "debug",
47
+ "trace",
48
+ "fatal",
49
+ "silent",
50
+ "child",
51
+ ]) {
52
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
53
+ }
54
+ return stub;
55
+ }
56
+
57
+ mock.module("../util/logger.js", () => ({
58
+ getLogger: () => makeLoggerStub(),
59
+ }));
60
+
61
+ let mockFeatureFlagEnabled = false;
62
+
63
+ mock.module("../config/assistant-feature-flags.js", () => ({
64
+ isAssistantFeatureFlagEnabled: () => mockFeatureFlagEnabled,
65
+ _setOverridesForTesting: () => {},
66
+ clearFeatureFlagOverridesCache: () => {},
67
+ getAssistantFeatureFlagDefaults: () => ({}),
68
+ }));
69
+
70
+ mock.module("../config/loader.js", () => ({
71
+ getConfig: () => ({
72
+ ui: {},
73
+ model: "test",
74
+ provider: "test",
75
+ memory: { enabled: false },
76
+ rateLimit: { maxRequestsPerMinute: 0 },
77
+ secretDetection: { enabled: false },
78
+ permissions: {
79
+ askBeforeActing: true,
80
+ hostAccess: false,
81
+ },
82
+ }),
83
+ loadConfig: () => ({
84
+ permissions: {
85
+ askBeforeActing: true,
86
+ hostAccess: false,
87
+ },
88
+ }),
89
+ loadRawConfig: () => ({}),
90
+ saveRawConfig: () => {},
91
+ invalidateConfigCache: () => {},
92
+ }));
93
+
94
+ // Heavy dependency stubs — prevent settings-routes.ts transitive imports from
95
+ // reaching real OAuth orchestration, secure-key decryption, tool registry, etc.
96
+ // Only the permission-mode GET/PUT handlers are exercised in this file, so these
97
+ // stubs are never called.
98
+ mock.module("../config/skills.js", () => ({
99
+ loadSkillCatalog: () => [],
100
+ }));
101
+ mock.module("../config/env.js", () => ({
102
+ getPlatformBaseUrl: () => "http://localhost",
103
+ setIngressPublicBaseUrl: () => {},
104
+ }));
105
+ mock.module("../daemon/handlers/config-ingress.js", () => ({
106
+ computeGatewayTarget: () => "",
107
+ getIngressConfigResult: () => ({}),
108
+ }));
109
+ mock.module("../daemon/handlers/config-voice.js", () => ({
110
+ normalizeActivationKey: () => ({ ok: true, value: "" }),
111
+ }));
112
+ mock.module("../oauth/connect-orchestrator.js", () => ({
113
+ orchestrateOAuthConnect: async () => ({ success: false, error: "stub" }),
114
+ }));
115
+ mock.module("../oauth/oauth-store.js", () => ({
116
+ getApp: () => undefined,
117
+ getConnectionByProvider: () => undefined,
118
+ getMostRecentAppByProvider: () => undefined,
119
+ getProvider: () => undefined,
120
+ }));
121
+ mock.module("../security/secure-keys.js", () => ({
122
+ getSecureKeyAsync: async () => undefined,
123
+ }));
124
+ mock.module("../skills/tool-manifest.js", () => ({
125
+ parseToolManifestFile: () => ({ tools: [] }),
126
+ }));
127
+ mock.module("../tools/execution-target.js", () => ({
128
+ resolveExecutionTarget: () => undefined,
129
+ }));
130
+ mock.module("../tools/registry.js", () => ({
131
+ getAllTools: () => [],
132
+ getTool: () => undefined,
133
+ }));
134
+ mock.module("../tools/schema-transforms.js", () => ({
135
+ ACTIVITY_SKIP_SET: new Set(),
136
+ injectActivityField: (defs: unknown[]) => defs,
137
+ }));
138
+ mock.module("../tools/side-effects.js", () => ({
139
+ isSideEffectTool: () => false,
140
+ }));
141
+ mock.module("../tools/system/avatar-generator.js", () => ({
142
+ generateAndSaveAvatar: async () => ({ isError: true, content: "stub" }),
143
+ }));
144
+ mock.module("../permissions/checker.js", () => ({
145
+ check: async () => ({ decision: "allow", reason: "" }),
146
+ classifyRisk: async () => "low",
147
+ generateAllowlistOptions: async () => [],
148
+ generateScopeOptions: () => [],
149
+ }));
150
+
151
+ afterAll(() => {
152
+ mock.restore();
153
+ });
154
+
155
+ import {
156
+ getMode,
157
+ onModeChanged,
158
+ resetForTesting,
159
+ setAskBeforeActing,
160
+ setHostAccess,
161
+ } from "../permissions/permission-mode-store.js";
162
+ import type { AssistantEvent } from "../runtime/assistant-event.js";
163
+ import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
164
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
165
+ import type { RouteDefinition } from "../runtime/http-router.js";
166
+ import { settingsRouteDefinitions } from "../runtime/routes/settings-routes.js";
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Route helpers — call actual route handlers
170
+ // ---------------------------------------------------------------------------
171
+
172
+ const routes = settingsRouteDefinitions();
173
+
174
+ function getRoute(method: string, endpoint: string): RouteDefinition {
175
+ const route = routes.find(
176
+ (r) => r.method === method && r.endpoint === endpoint,
177
+ );
178
+ if (!route) throw new Error(`Route not found: ${method} ${endpoint}`);
179
+ return route;
180
+ }
181
+
182
+ function callPut(
183
+ body: Record<string, unknown>,
184
+ ): Promise<Response> | Response {
185
+ const req = new Request("http://localhost/v1/permission-mode", {
186
+ method: "PUT",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify(body),
189
+ });
190
+ return getRoute("PUT", "permission-mode").handler({
191
+ req,
192
+ url: new URL(req.url),
193
+ server: null as never,
194
+ authContext: null as never,
195
+ params: {},
196
+ });
197
+ }
198
+
199
+ function callGet(): Promise<Response> | Response {
200
+ const req = new Request("http://localhost/v1/permission-mode");
201
+ return getRoute("GET", "permission-mode").handler({
202
+ req,
203
+ url: new URL(req.url),
204
+ server: null as never,
205
+ authContext: null as never,
206
+ params: {},
207
+ });
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Setup / teardown
212
+ // ---------------------------------------------------------------------------
213
+
214
+ beforeEach(() => {
215
+ ensureTestDir();
216
+ resetForTesting();
217
+ mockFeatureFlagEnabled = false;
218
+ // Remove config file to start clean
219
+ try {
220
+ rmSync(CONFIG_PATH, { force: true });
221
+ } catch {
222
+ /* noop */
223
+ }
224
+ });
225
+
226
+ afterEach(() => {
227
+ resetForTesting();
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Tests
232
+ // ---------------------------------------------------------------------------
233
+
234
+ describe("Permission mode SSE broadcast", () => {
235
+ test("publishes permission_mode_update event when askBeforeActing changes", async () => {
236
+ const hub = new AssistantEventHub();
237
+ const received: AssistantEvent[] = [];
238
+
239
+ hub.subscribe({ assistantId: DAEMON_INTERNAL_ASSISTANT_ID }, (event) => {
240
+ received.push(event);
241
+ });
242
+
243
+ // Wire up a listener that publishes to our test hub
244
+ const { buildAssistantEvent } =
245
+ await import("../runtime/assistant-event.js");
246
+ onModeChanged((mode) => {
247
+ void hub.publish(
248
+ buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, {
249
+ type: "permission_mode_update",
250
+ askBeforeActing: mode.askBeforeActing,
251
+ hostAccess: mode.hostAccess,
252
+ }),
253
+ );
254
+ });
255
+
256
+ setAskBeforeActing(false);
257
+
258
+ // Allow async publish to settle
259
+ await new Promise((resolve) => setTimeout(resolve, 10));
260
+
261
+ expect(received).toHaveLength(1);
262
+ expect(received[0].message.type).toBe("permission_mode_update");
263
+ const payload = received[0].message as {
264
+ type: "permission_mode_update";
265
+ askBeforeActing: boolean;
266
+ hostAccess: boolean;
267
+ };
268
+ expect(payload.askBeforeActing).toBe(false);
269
+ expect(payload.hostAccess).toBe(false);
270
+ });
271
+
272
+ test("publishes permission_mode_update event when hostAccess changes", async () => {
273
+ const hub = new AssistantEventHub();
274
+ const received: AssistantEvent[] = [];
275
+
276
+ hub.subscribe({ assistantId: DAEMON_INTERNAL_ASSISTANT_ID }, (event) => {
277
+ received.push(event);
278
+ });
279
+
280
+ const { buildAssistantEvent } =
281
+ await import("../runtime/assistant-event.js");
282
+ onModeChanged((mode) => {
283
+ void hub.publish(
284
+ buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, {
285
+ type: "permission_mode_update",
286
+ askBeforeActing: mode.askBeforeActing,
287
+ hostAccess: mode.hostAccess,
288
+ }),
289
+ );
290
+ });
291
+
292
+ setHostAccess(true);
293
+
294
+ await new Promise((resolve) => setTimeout(resolve, 10));
295
+
296
+ expect(received).toHaveLength(1);
297
+ expect(received[0].message.type).toBe("permission_mode_update");
298
+ const payload = received[0].message as {
299
+ type: "permission_mode_update";
300
+ askBeforeActing: boolean;
301
+ hostAccess: boolean;
302
+ };
303
+ expect(payload.askBeforeActing).toBe(true);
304
+ expect(payload.hostAccess).toBe(true);
305
+ });
306
+
307
+ test("does not publish event when value is unchanged", async () => {
308
+ const hub = new AssistantEventHub();
309
+ const received: AssistantEvent[] = [];
310
+
311
+ hub.subscribe({ assistantId: DAEMON_INTERNAL_ASSISTANT_ID }, (event) => {
312
+ received.push(event);
313
+ });
314
+
315
+ const { buildAssistantEvent } =
316
+ await import("../runtime/assistant-event.js");
317
+ onModeChanged((mode) => {
318
+ void hub.publish(
319
+ buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, {
320
+ type: "permission_mode_update",
321
+ askBeforeActing: mode.askBeforeActing,
322
+ hostAccess: mode.hostAccess,
323
+ }),
324
+ );
325
+ });
326
+
327
+ // Default is askBeforeActing=true, setting to true is a no-op
328
+ setAskBeforeActing(true);
329
+ // Default is hostAccess=false, setting to false is a no-op
330
+ setHostAccess(false);
331
+
332
+ await new Promise((resolve) => setTimeout(resolve, 10));
333
+
334
+ expect(received).toHaveLength(0);
335
+ });
336
+ });
337
+
338
+ describe("GET /v1/permission-mode", () => {
339
+ test("returns current permission mode state via route handler", async () => {
340
+ const res = await callGet();
341
+ expect(res.status).toBe(200);
342
+ const body = (await res.json()) as {
343
+ askBeforeActing: boolean;
344
+ hostAccess: boolean;
345
+ };
346
+ expect(body.askBeforeActing).toBe(true);
347
+ expect(body.hostAccess).toBe(false);
348
+ });
349
+
350
+ test("returns updated state after mutation via route handler", async () => {
351
+ setAskBeforeActing(false);
352
+ setHostAccess(true);
353
+
354
+ const res = await callGet();
355
+ expect(res.status).toBe(200);
356
+ const body = (await res.json()) as {
357
+ askBeforeActing: boolean;
358
+ hostAccess: boolean;
359
+ };
360
+ expect(body.askBeforeActing).toBe(false);
361
+ expect(body.hostAccess).toBe(true);
362
+ });
363
+ });
364
+
365
+ describe("PUT /v1/permission-mode", () => {
366
+ test("updates askBeforeActing via route handler when flag is enabled", async () => {
367
+ mockFeatureFlagEnabled = true;
368
+
369
+ const res = await callPut({ askBeforeActing: false });
370
+ expect(res.status).toBe(200);
371
+ const body = (await res.json()) as {
372
+ askBeforeActing: boolean;
373
+ hostAccess: boolean;
374
+ };
375
+ expect(body.askBeforeActing).toBe(false);
376
+ expect(body.hostAccess).toBe(false);
377
+ });
378
+
379
+ test("updates hostAccess via route handler when flag is enabled", async () => {
380
+ mockFeatureFlagEnabled = true;
381
+
382
+ const res = await callPut({ hostAccess: true });
383
+ expect(res.status).toBe(200);
384
+ const body = (await res.json()) as {
385
+ askBeforeActing: boolean;
386
+ hostAccess: boolean;
387
+ };
388
+ expect(body.askBeforeActing).toBe(true);
389
+ expect(body.hostAccess).toBe(true);
390
+ });
391
+
392
+ test("updates both axes independently via route handler", async () => {
393
+ mockFeatureFlagEnabled = true;
394
+
395
+ const res1 = await callPut({ askBeforeActing: false });
396
+ expect(res1.status).toBe(200);
397
+
398
+ const res2 = await callPut({ hostAccess: true });
399
+ expect(res2.status).toBe(200);
400
+ const body = (await res2.json()) as {
401
+ askBeforeActing: boolean;
402
+ hostAccess: boolean;
403
+ };
404
+ expect(body.askBeforeActing).toBe(false);
405
+ expect(body.hostAccess).toBe(true);
406
+ });
407
+
408
+ test("returns 404 when feature flag is off", async () => {
409
+ mockFeatureFlagEnabled = false;
410
+
411
+ const res = await callPut({ askBeforeActing: false });
412
+ expect(res.status).toBe(404);
413
+
414
+ // Verify the store was NOT mutated — askBeforeActing should remain at default
415
+ const mode = getMode();
416
+ expect(mode.askBeforeActing).toBe(true);
417
+ });
418
+ });
@@ -0,0 +1,277 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import {
10
+ afterAll,
11
+ afterEach,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ mock,
16
+ test,
17
+ } from "bun:test";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Mocks — declared before imports that depend on platform/logger
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const WORKSPACE_DIR = process.env.VELLUM_WORKSPACE_DIR!;
24
+ const CONFIG_PATH = join(WORKSPACE_DIR, "config.json");
25
+
26
+ function ensureTestDir(): void {
27
+ const dirs = [
28
+ WORKSPACE_DIR,
29
+ join(WORKSPACE_DIR, "data"),
30
+ join(WORKSPACE_DIR, "data", "logs"),
31
+ ];
32
+ for (const dir of dirs) {
33
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
+ }
35
+ }
36
+
37
+ function makeLoggerStub(): Record<string, unknown> {
38
+ const stub: Record<string, unknown> = {};
39
+ for (const m of [
40
+ "info",
41
+ "warn",
42
+ "error",
43
+ "debug",
44
+ "trace",
45
+ "fatal",
46
+ "silent",
47
+ "child",
48
+ ]) {
49
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
50
+ }
51
+ return stub;
52
+ }
53
+
54
+ mock.module("../util/logger.js", () => ({
55
+ getLogger: () => makeLoggerStub(),
56
+ }));
57
+
58
+ afterAll(() => {
59
+ mock.restore();
60
+ });
61
+
62
+ import { invalidateConfigCache } from "../config/loader.js";
63
+ import {
64
+ getMode,
65
+ initPermissionModeStore,
66
+ onModeChanged,
67
+ resetForTesting,
68
+ setAskBeforeActing,
69
+ setHostAccess,
70
+ } from "../permissions/permission-mode-store.js";
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Helpers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function writeConfig(obj: unknown): void {
77
+ ensureTestDir();
78
+ writeFileSync(CONFIG_PATH, JSON.stringify(obj, null, 2) + "\n");
79
+ }
80
+
81
+ function readConfig(): Record<string, unknown> {
82
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Setup / teardown
87
+ // ---------------------------------------------------------------------------
88
+
89
+ beforeEach(() => {
90
+ ensureTestDir();
91
+ resetForTesting();
92
+ invalidateConfigCache();
93
+ // Remove config file to start clean
94
+ try {
95
+ rmSync(CONFIG_PATH, { force: true });
96
+ } catch {
97
+ /* noop */
98
+ }
99
+ });
100
+
101
+ afterEach(() => {
102
+ resetForTesting();
103
+ invalidateConfigCache();
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Tests
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("PermissionModeStore", () => {
111
+ describe("getMode", () => {
112
+ test("returns defaults when no config exists", () => {
113
+ const mode = getMode();
114
+ expect(mode.askBeforeActing).toBe(true);
115
+ expect(mode.hostAccess).toBe(false);
116
+ });
117
+
118
+ test("reads initial state from config", () => {
119
+ writeConfig({
120
+ permissions: {
121
+ askBeforeActing: false,
122
+ hostAccess: true,
123
+ },
124
+ });
125
+
126
+ initPermissionModeStore();
127
+ const mode = getMode();
128
+ expect(mode.askBeforeActing).toBe(false);
129
+ expect(mode.hostAccess).toBe(true);
130
+ });
131
+
132
+ test("returns a defensive copy (mutations do not affect store)", () => {
133
+ const mode = getMode();
134
+ mode.askBeforeActing = false;
135
+ mode.hostAccess = true;
136
+
137
+ const fresh = getMode();
138
+ expect(fresh.askBeforeActing).toBe(true);
139
+ expect(fresh.hostAccess).toBe(false);
140
+ });
141
+ });
142
+
143
+ describe("setAskBeforeActing", () => {
144
+ test("updates in-memory state", () => {
145
+ expect(getMode().askBeforeActing).toBe(true);
146
+
147
+ setAskBeforeActing(false);
148
+ expect(getMode().askBeforeActing).toBe(false);
149
+ });
150
+
151
+ test("persists to config.json", () => {
152
+ setAskBeforeActing(false);
153
+
154
+ const raw = readConfig();
155
+ const permissions = raw.permissions as Record<string, unknown>;
156
+ expect(permissions.askBeforeActing).toBe(false);
157
+ });
158
+
159
+ test("no-op when value is unchanged", () => {
160
+ const calls: unknown[] = [];
161
+ onModeChanged((mode) => calls.push(mode));
162
+
163
+ // Default is true; setting to true should not fire
164
+ setAskBeforeActing(true);
165
+ expect(calls).toHaveLength(0);
166
+ });
167
+ });
168
+
169
+ describe("setHostAccess", () => {
170
+ test("updates in-memory state", () => {
171
+ expect(getMode().hostAccess).toBe(false);
172
+
173
+ setHostAccess(true);
174
+ expect(getMode().hostAccess).toBe(true);
175
+ });
176
+
177
+ test("persists to config.json", () => {
178
+ setHostAccess(true);
179
+
180
+ const raw = readConfig();
181
+ const permissions = raw.permissions as Record<string, unknown>;
182
+ expect(permissions.hostAccess).toBe(true);
183
+ });
184
+
185
+ test("no-op when value is unchanged", () => {
186
+ const calls: unknown[] = [];
187
+ onModeChanged((mode) => calls.push(mode));
188
+
189
+ // Default is false; setting to false should not fire
190
+ setHostAccess(false);
191
+ expect(calls).toHaveLength(0);
192
+ });
193
+ });
194
+
195
+ describe("onModeChanged", () => {
196
+ test("fires on askBeforeActing change", () => {
197
+ const received: Array<{ askBeforeActing: boolean; hostAccess: boolean }> =
198
+ [];
199
+ onModeChanged((mode) => received.push(mode));
200
+
201
+ setAskBeforeActing(false);
202
+ expect(received).toHaveLength(1);
203
+ expect(received[0].askBeforeActing).toBe(false);
204
+ expect(received[0].hostAccess).toBe(false);
205
+ });
206
+
207
+ test("fires on hostAccess change", () => {
208
+ const received: Array<{ askBeforeActing: boolean; hostAccess: boolean }> =
209
+ [];
210
+ onModeChanged((mode) => received.push(mode));
211
+
212
+ setHostAccess(true);
213
+ expect(received).toHaveLength(1);
214
+ expect(received[0].askBeforeActing).toBe(true);
215
+ expect(received[0].hostAccess).toBe(true);
216
+ });
217
+
218
+ test("unsubscribe stops notifications", () => {
219
+ const received: unknown[] = [];
220
+ const unsubscribe = onModeChanged((mode) => received.push(mode));
221
+
222
+ setAskBeforeActing(false);
223
+ expect(received).toHaveLength(1);
224
+
225
+ unsubscribe();
226
+ setHostAccess(true);
227
+ expect(received).toHaveLength(1); // no new notification
228
+ });
229
+
230
+ test("listener errors do not break other listeners", () => {
231
+ const received: unknown[] = [];
232
+ onModeChanged(() => {
233
+ throw new Error("boom");
234
+ });
235
+ onModeChanged((mode) => received.push(mode));
236
+
237
+ setAskBeforeActing(false);
238
+ expect(received).toHaveLength(1);
239
+ });
240
+ });
241
+
242
+ describe("persistence round-trip", () => {
243
+ test("state survives store reset and re-initialization", () => {
244
+ setAskBeforeActing(false);
245
+ setHostAccess(true);
246
+
247
+ // Simulate daemon restart: reset store and re-init from config
248
+ resetForTesting();
249
+ invalidateConfigCache();
250
+ initPermissionModeStore();
251
+
252
+ const mode = getMode();
253
+ expect(mode.askBeforeActing).toBe(false);
254
+ expect(mode.hostAccess).toBe(true);
255
+ });
256
+
257
+ test("preserves other config fields when persisting", () => {
258
+ writeConfig({
259
+ maxTokens: 8000,
260
+ permissions: {
261
+ mode: "strict",
262
+ askBeforeActing: true,
263
+ hostAccess: false,
264
+ },
265
+ });
266
+
267
+ initPermissionModeStore();
268
+ setHostAccess(true);
269
+
270
+ const raw = readConfig();
271
+ expect(raw.maxTokens).toBe(8000);
272
+ const permissions = raw.permissions as Record<string, unknown>;
273
+ expect(permissions.mode).toBe("strict");
274
+ expect(permissions.hostAccess).toBe(true);
275
+ });
276
+ });
277
+ });