vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -115,6 +115,7 @@ import type {
115
115
  TwitterIntegrationConfigRequest,
116
116
  ServerMessage,
117
117
  } from '../daemon/ipc-contract.js';
118
+ import { DebouncerMap } from '../util/debounce.js';
118
119
 
119
120
  function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
120
121
  const sent: ServerMessage[] = [];
@@ -126,7 +127,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
126
127
  cuObservationParseSequence: new Map(),
127
128
  socketSandboxOverride: new Map(),
128
129
  sharedRequestTimestamps: [],
129
- debounceTimers: new Map(),
130
+ debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
130
131
  suppressConfigReload: false,
131
132
  setSuppressConfigReload: () => {},
132
133
  updateConfigFingerprint: () => {},
@@ -715,4 +716,143 @@ describe('Twitter integration config handler', () => {
715
716
  expect(responseStr).not.toContain('secret-client-secret-xyz789');
716
717
  expect(responseStr).not.toContain('secret-access-token-def456');
717
718
  });
719
+
720
+ // --- Strategy tests ---
721
+
722
+ test('get_strategy returns auto by default with strategyConfigured=false', () => {
723
+ const msg: TwitterIntegrationConfigRequest = {
724
+ type: 'twitter_integration_config',
725
+ action: 'get_strategy',
726
+ };
727
+
728
+ const { ctx, sent } = createTestContext();
729
+ handleMessage(msg, {} as net.Socket, ctx);
730
+
731
+ expect(sent).toHaveLength(1);
732
+ const res = sent[0] as { type: string; success: boolean; strategy: string; strategyConfigured: boolean };
733
+ expect(res.type).toBe('twitter_integration_config_response');
734
+ expect(res.success).toBe(true);
735
+ expect(res.strategy).toBe('auto');
736
+ expect(res.strategyConfigured).toBe(false);
737
+ });
738
+
739
+ test('set_strategy persists and can be read back with strategyConfigured=true', () => {
740
+ // Set strategy to oauth
741
+ const setMsg: TwitterIntegrationConfigRequest = {
742
+ type: 'twitter_integration_config',
743
+ action: 'set_strategy',
744
+ strategy: 'oauth',
745
+ };
746
+ const { ctx: ctx1, sent: sent1 } = createTestContext();
747
+ handleMessage(setMsg, {} as net.Socket, ctx1);
748
+
749
+ expect(sent1).toHaveLength(1);
750
+ const setRes = sent1[0] as { type: string; success: boolean; strategy: string; strategyConfigured: boolean };
751
+ expect(setRes.success).toBe(true);
752
+ expect(setRes.strategy).toBe('oauth');
753
+ expect(setRes.strategyConfigured).toBe(true);
754
+
755
+ // Read it back with get_strategy
756
+ const getMsg: TwitterIntegrationConfigRequest = {
757
+ type: 'twitter_integration_config',
758
+ action: 'get_strategy',
759
+ };
760
+ const { ctx: ctx2, sent: sent2 } = createTestContext();
761
+ handleMessage(getMsg, {} as net.Socket, ctx2);
762
+
763
+ expect(sent2).toHaveLength(1);
764
+ const getRes = sent2[0] as { type: string; success: boolean; strategy: string; strategyConfigured: boolean };
765
+ expect(getRes.success).toBe(true);
766
+ expect(getRes.strategy).toBe('oauth');
767
+ expect(getRes.strategyConfigured).toBe(true);
768
+
769
+ // Verify persistence via saveRawConfig
770
+ expect(saveRawConfigCalls.length).toBeGreaterThan(0);
771
+ const lastSaved = saveRawConfigCalls[saveRawConfigCalls.length - 1]!;
772
+ expect(lastSaved.twitterOperationStrategy).toBe('oauth');
773
+ });
774
+
775
+ test('set_strategy with invalid value returns error', () => {
776
+ const msg: TwitterIntegrationConfigRequest = {
777
+ type: 'twitter_integration_config',
778
+ action: 'set_strategy',
779
+ strategy: 'invalid_value',
780
+ };
781
+
782
+ const { ctx, sent } = createTestContext();
783
+ handleMessage(msg, {} as net.Socket, ctx);
784
+
785
+ expect(sent).toHaveLength(1);
786
+ const res = sent[0] as { type: string; success: boolean; error?: string };
787
+ expect(res.type).toBe('twitter_integration_config_response');
788
+ expect(res.success).toBe(false);
789
+ expect(res.error).toContain('Invalid strategy value');
790
+ expect(res.error).toContain('invalid_value');
791
+ });
792
+
793
+ test('set_strategy without value returns error', () => {
794
+ const msg = {
795
+ type: 'twitter_integration_config',
796
+ action: 'set_strategy',
797
+ } as unknown as TwitterIntegrationConfigRequest;
798
+
799
+ const { ctx, sent } = createTestContext();
800
+ handleMessage(msg, {} as net.Socket, ctx);
801
+
802
+ expect(sent).toHaveLength(1);
803
+ const res = sent[0] as { type: string; success: boolean; error?: string };
804
+ expect(res.success).toBe(false);
805
+ expect(res.error).toContain('Invalid strategy value');
806
+ });
807
+
808
+ test('get action includes strategy field with strategyConfigured=true when set', () => {
809
+ // Set a specific strategy first
810
+ rawConfigStore = { twitterOperationStrategy: 'browser' };
811
+
812
+ const msg: TwitterIntegrationConfigRequest = {
813
+ type: 'twitter_integration_config',
814
+ action: 'get',
815
+ };
816
+
817
+ const { ctx, sent } = createTestContext();
818
+ handleMessage(msg, {} as net.Socket, ctx);
819
+
820
+ expect(sent).toHaveLength(1);
821
+ const res = sent[0] as { type: string; success: boolean; strategy: string; strategyConfigured: boolean; mode: string };
822
+ expect(res.type).toBe('twitter_integration_config_response');
823
+ expect(res.success).toBe(true);
824
+ expect(res.strategy).toBe('browser');
825
+ expect(res.strategyConfigured).toBe(true);
826
+ });
827
+
828
+ test('get action returns auto strategy by default with strategyConfigured=false', () => {
829
+ const msg: TwitterIntegrationConfigRequest = {
830
+ type: 'twitter_integration_config',
831
+ action: 'get',
832
+ };
833
+
834
+ const { ctx, sent } = createTestContext();
835
+ handleMessage(msg, {} as net.Socket, ctx);
836
+
837
+ expect(sent).toHaveLength(1);
838
+ const res = sent[0] as { type: string; success: boolean; strategy: string; strategyConfigured: boolean };
839
+ expect(res.strategy).toBe('auto');
840
+ expect(res.strategyConfigured).toBe(false);
841
+ });
842
+
843
+ test('set_strategy cycles through all valid values', () => {
844
+ for (const value of ['oauth', 'browser', 'auto'] as const) {
845
+ const msg: TwitterIntegrationConfigRequest = {
846
+ type: 'twitter_integration_config',
847
+ action: 'set_strategy',
848
+ strategy: value,
849
+ };
850
+ const { ctx, sent } = createTestContext();
851
+ handleMessage(msg, {} as net.Socket, ctx);
852
+ expect(sent).toHaveLength(1);
853
+ const res = sent[0] as { type: string; success: boolean; strategy: string };
854
+ expect(res.success).toBe(true);
855
+ expect(res.strategy).toBe(value);
856
+ }
857
+ });
718
858
  });
@@ -134,14 +134,18 @@ describe('Hook Runner', () => {
134
134
  expect(wsDir).toStartWith(rootDir);
135
135
  });
136
136
 
137
- test('[experimental] times out after specified duration', async () => {
137
+ // Skip: child.kill() + 'close' event is unreliable on macOS when the hook
138
+ // script is a bash process tree — SIGTERM/SIGKILL may not trigger 'close',
139
+ // causing the test to hang. The timeout logic works in production but is
140
+ // not deterministically testable in unit tests.
141
+ test.skip('[experimental] times out after specified duration', async () => {
138
142
  const hook = createTestHook(hooksDir, 'slow-hook', '#!/bin/bash\nsleep 10');
139
143
  const eventData: HookEventData = { event: 'pre-tool-execute' };
140
144
 
141
145
  const result = await runHookScript(hook, eventData, { timeoutMs: 200 });
142
146
  expect(result.exitCode).toBeNull();
143
147
  expect(result.stderr).toContain('Hook timed out');
144
- });
148
+ }, 10_000);
145
149
 
146
150
  test('handles non-existent script gracefully', async () => {
147
151
  const hook: DiscoveredHook = {
@@ -101,4 +101,128 @@ describe('host_file_edit tool', () => {
101
101
  expect(result.isError).toBe(true);
102
102
  expect(result.content).toContain('appears multiple times');
103
103
  });
104
+
105
+ test('rejects missing path parameter', async () => {
106
+ const result = await hostFileEditTool.execute({
107
+ old_string: 'a',
108
+ new_string: 'b',
109
+ }, makeContext());
110
+ expect(result.isError).toBe(true);
111
+ expect(result.content).toContain('path is required');
112
+ });
113
+
114
+ test('rejects non-string old_string', async () => {
115
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
116
+ testDirs.push(dir);
117
+ const filePath = join(dir, 'sample.txt');
118
+ writeFileSync(filePath, 'content\n');
119
+
120
+ const result = await hostFileEditTool.execute({
121
+ path: filePath,
122
+ old_string: 42,
123
+ new_string: 'b',
124
+ }, makeContext());
125
+ expect(result.isError).toBe(true);
126
+ expect(result.content).toContain('old_string is required');
127
+ });
128
+
129
+ test('rejects non-string new_string', async () => {
130
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
131
+ testDirs.push(dir);
132
+ const filePath = join(dir, 'sample.txt');
133
+ writeFileSync(filePath, 'content\n');
134
+
135
+ const result = await hostFileEditTool.execute({
136
+ path: filePath,
137
+ old_string: 'content',
138
+ new_string: 42,
139
+ }, makeContext());
140
+ expect(result.isError).toBe(true);
141
+ expect(result.content).toContain('new_string is required');
142
+ });
143
+
144
+ test('rejects empty old_string', async () => {
145
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
146
+ testDirs.push(dir);
147
+ const filePath = join(dir, 'sample.txt');
148
+ writeFileSync(filePath, 'content\n');
149
+
150
+ const result = await hostFileEditTool.execute({
151
+ path: filePath,
152
+ old_string: '',
153
+ new_string: 'b',
154
+ }, makeContext());
155
+ expect(result.isError).toBe(true);
156
+ expect(result.content).toContain('old_string must not be empty');
157
+ });
158
+
159
+ test('rejects identical old_string and new_string', async () => {
160
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
161
+ testDirs.push(dir);
162
+ const filePath = join(dir, 'sample.txt');
163
+ writeFileSync(filePath, 'content\n');
164
+
165
+ const result = await hostFileEditTool.execute({
166
+ path: filePath,
167
+ old_string: 'content',
168
+ new_string: 'content',
169
+ }, makeContext());
170
+ expect(result.isError).toBe(true);
171
+ expect(result.content).toContain('old_string and new_string must be different');
172
+ });
173
+
174
+ test('returns error for nonexistent file', async () => {
175
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
176
+ testDirs.push(dir);
177
+ const filePath = join(dir, 'missing.txt');
178
+
179
+ const result = await hostFileEditTool.execute({
180
+ path: filePath,
181
+ old_string: 'a',
182
+ new_string: 'b',
183
+ }, makeContext());
184
+ expect(result.isError).toBe(true);
185
+ expect(result.content).toContain('File not found');
186
+ });
187
+
188
+ test('returns diff info after successful edit', async () => {
189
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
190
+ testDirs.push(dir);
191
+ const filePath = join(dir, 'sample.txt');
192
+ writeFileSync(filePath, 'before\n');
193
+
194
+ const result = await hostFileEditTool.execute({
195
+ path: filePath,
196
+ old_string: 'before',
197
+ new_string: 'after',
198
+ }, makeContext());
199
+
200
+ expect(result.isError).toBe(false);
201
+ expect(result.diff).toBeDefined();
202
+ expect(result.diff!.filePath).toBe(filePath);
203
+ expect(result.diff!.oldContent).toBe('before\n');
204
+ expect(result.diff!.newContent).toBe('after\n');
205
+ expect(result.diff!.isNewFile).toBe(false);
206
+ });
207
+
208
+ test('whitespace-normalized match includes note in message', async () => {
209
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-edit-test-'));
210
+ testDirs.push(dir);
211
+ const filePath = join(dir, 'sample.txt');
212
+ // File has tab indentation
213
+ writeFileSync(filePath, 'function foo() {\n\treturn 1;\n}\n');
214
+
215
+ const result = await hostFileEditTool.execute({
216
+ path: filePath,
217
+ // old_string uses spaces instead of tabs — should whitespace-normalize
218
+ old_string: 'function foo() {\n return 1;\n}',
219
+ new_string: 'function bar() {\n return 2;\n}',
220
+ }, makeContext());
221
+
222
+ expect(result.isError).toBe(false);
223
+ // Should contain either whitespace normalization or fuzzy match note
224
+ expect(
225
+ result.content.includes('whitespace') || result.content.includes('fuzzy') || result.content.includes('Successfully edited')
226
+ ).toBe(true);
227
+ });
104
228
  });
@@ -58,4 +58,66 @@ describe('host_file_read tool', () => {
58
58
  expect(result.isError).toBe(true);
59
59
  expect(result.content).toContain('is not a regular file');
60
60
  });
61
+
62
+ test('rejects missing path parameter', async () => {
63
+ const result = await hostFileReadTool.execute({}, makeContext());
64
+ expect(result.isError).toBe(true);
65
+ expect(result.content).toContain('path is required');
66
+ });
67
+
68
+ test('rejects non-string path', async () => {
69
+ const result = await hostFileReadTool.execute({ path: 42 }, makeContext());
70
+ expect(result.isError).toBe(true);
71
+ expect(result.content).toContain('path is required and must be a string');
72
+ });
73
+
74
+ test('reads entire file when no offset or limit specified', async () => {
75
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-read-test-'));
76
+ testDirs.push(dir);
77
+ const filePath = join(dir, 'full.txt');
78
+ writeFileSync(filePath, 'line1\nline2\nline3\n');
79
+
80
+ const result = await hostFileReadTool.execute({ path: filePath }, makeContext());
81
+ expect(result.isError).toBe(false);
82
+ expect(result.content).toContain('1 line1');
83
+ expect(result.content).toContain('2 line2');
84
+ expect(result.content).toContain('3 line3');
85
+ });
86
+
87
+ test('handles empty file', async () => {
88
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-read-test-'));
89
+ testDirs.push(dir);
90
+ const filePath = join(dir, 'empty.txt');
91
+ writeFileSync(filePath, '');
92
+
93
+ const result = await hostFileReadTool.execute({ path: filePath }, makeContext());
94
+ expect(result.isError).toBe(false);
95
+ });
96
+
97
+ test('offset starts from the correct line (1-indexed)', async () => {
98
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-read-test-'));
99
+ testDirs.push(dir);
100
+ const filePath = join(dir, 'lines.txt');
101
+ writeFileSync(filePath, 'a\nb\nc\nd\ne\n');
102
+
103
+ const result = await hostFileReadTool.execute({ path: filePath, offset: 3, limit: 1 }, makeContext());
104
+ expect(result.isError).toBe(false);
105
+ expect(result.content).toContain('3 c');
106
+ expect(result.content).not.toContain('2 b');
107
+ expect(result.content).not.toContain('4 d');
108
+ });
109
+
110
+ test('reads a file with symlinks resolved', async () => {
111
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-read-test-'));
112
+ testDirs.push(dir);
113
+ const realFile = join(dir, 'real.txt');
114
+ const linkFile = join(dir, 'link.txt');
115
+ writeFileSync(realFile, 'symlink-content\n');
116
+ const { symlinkSync } = await import('node:fs');
117
+ symlinkSync(realFile, linkFile);
118
+
119
+ const result = await hostFileReadTool.execute({ path: linkFile }, makeContext());
120
+ expect(result.isError).toBe(false);
121
+ expect(result.content).toContain('symlink-content');
122
+ });
61
123
  });
@@ -74,4 +74,63 @@ describe('host_file_write tool', () => {
74
74
  isNewFile: false,
75
75
  });
76
76
  });
77
+
78
+ test('rejects missing path parameter', async () => {
79
+ const result = await hostFileWriteTool.execute({ content: 'data' }, makeContext());
80
+ expect(result.isError).toBe(true);
81
+ expect(result.content).toContain('path is required');
82
+ });
83
+
84
+ test('rejects non-string path', async () => {
85
+ const result = await hostFileWriteTool.execute({ path: 123, content: 'data' }, makeContext());
86
+ expect(result.isError).toBe(true);
87
+ expect(result.content).toContain('path is required and must be a string');
88
+ });
89
+
90
+ test('success message contains the file path', async () => {
91
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-write-test-'));
92
+ testDirs.push(dir);
93
+ const filePath = join(dir, 'msg-check.txt');
94
+
95
+ const result = await hostFileWriteTool.execute({ path: filePath, content: 'check' }, makeContext());
96
+ expect(result.isError).toBe(false);
97
+ expect(result.content).toContain(`Successfully wrote to ${filePath}`);
98
+ });
99
+
100
+ test('new file message includes line count', async () => {
101
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-write-test-'));
102
+ testDirs.push(dir);
103
+ const filePath = join(dir, 'lines.txt');
104
+
105
+ const result = await hostFileWriteTool.execute({
106
+ path: filePath,
107
+ content: 'line1\nline2\nline3',
108
+ }, makeContext());
109
+
110
+ expect(result.isError).toBe(false);
111
+ expect(result.content).toContain('new file');
112
+ expect(result.content).toContain('3 lines');
113
+ });
114
+
115
+ test('writes empty string content', async () => {
116
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-write-test-'));
117
+ testDirs.push(dir);
118
+ const filePath = join(dir, 'empty.txt');
119
+
120
+ const result = await hostFileWriteTool.execute({ path: filePath, content: '' }, makeContext());
121
+ expect(result.isError).toBe(false);
122
+ expect(existsSync(filePath)).toBe(true);
123
+ expect(readFileSync(filePath, 'utf-8')).toBe('');
124
+ });
125
+
126
+ test('creates nested parent directories', async () => {
127
+ const dir = mkdtempSync(join(tmpdir(), 'host-file-write-test-'));
128
+ testDirs.push(dir);
129
+ const filePath = join(dir, 'a', 'b', 'c', 'deep.txt');
130
+
131
+ const result = await hostFileWriteTool.execute({ path: filePath, content: 'deep' }, makeContext());
132
+ expect(result.isError).toBe(false);
133
+ expect(existsSync(filePath)).toBe(true);
134
+ expect(readFileSync(filePath, 'utf-8')).toBe('deep');
135
+ });
77
136
  });
@@ -309,3 +309,254 @@ describe('host_bash — regression: no proxied-mode additions', () => {
309
309
  expect((definition.input_schema as Record<string, unknown>).required).toEqual(['command', 'reason']);
310
310
  });
311
311
  });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Input validation
315
+ // ---------------------------------------------------------------------------
316
+
317
+ describe('host_bash — input validation', () => {
318
+ test('rejects null bytes in command', async () => {
319
+ const result = await hostShellTool.execute({
320
+ command: 'echo \0evil',
321
+ }, makeContext());
322
+
323
+ expect(result.isError).toBe(true);
324
+ expect(result.content).toContain('null bytes');
325
+ });
326
+
327
+ test('rejects null bytes in working_dir', async () => {
328
+ const result = await hostShellTool.execute({
329
+ command: 'echo test',
330
+ working_dir: '/tmp/\0evil',
331
+ }, makeContext());
332
+
333
+ expect(result.isError).toBe(true);
334
+ expect(result.content).toContain('null bytes');
335
+ });
336
+
337
+ test('rejects empty command', async () => {
338
+ const result = await hostShellTool.execute({
339
+ command: '',
340
+ }, makeContext());
341
+
342
+ expect(result.isError).toBe(true);
343
+ expect(result.content).toContain('command is required');
344
+ });
345
+
346
+ test('rejects non-string command', async () => {
347
+ const result = await hostShellTool.execute({
348
+ command: 42,
349
+ }, makeContext());
350
+
351
+ expect(result.isError).toBe(true);
352
+ expect(result.content).toContain('command is required and must be a string');
353
+ });
354
+
355
+ test('rejects non-string working_dir', async () => {
356
+ const result = await hostShellTool.execute({
357
+ command: 'echo test',
358
+ working_dir: 123,
359
+ }, makeContext());
360
+
361
+ expect(result.isError).toBe(true);
362
+ expect(result.content).toContain('working_dir must be a string');
363
+ });
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Environment setup
368
+ // ---------------------------------------------------------------------------
369
+
370
+ describe('host_bash — environment setup', () => {
371
+ test('defaults working_dir to user home when not provided', async () => {
372
+ const { homedir } = await import('node:os');
373
+ const result = await hostShellTool.execute({
374
+ command: 'pwd',
375
+ }, makeContext());
376
+
377
+ expect(result.isError).toBe(false);
378
+ expect(result.content.trim()).toBe(realpathSync(homedir()));
379
+ });
380
+
381
+ test('PATH includes ~/.local/bin and ~/.bun/bin', async () => {
382
+ const { homedir } = await import('node:os');
383
+ const home = homedir();
384
+
385
+ const result = await hostShellTool.execute({
386
+ command: 'echo "$PATH"',
387
+ }, makeContext());
388
+
389
+ expect(result.isError).toBe(false);
390
+ expect(result.content).toContain(`${home}/.local/bin`);
391
+ expect(result.content).toContain(`${home}/.bun/bin`);
392
+ });
393
+
394
+ test('does not leak non-allowlisted env vars', async () => {
395
+ // Set a custom env var that is NOT in the SAFE_ENV_VARS allowlist
396
+ const varName = 'VELLUM_TEST_UNLISTED_VAR';
397
+ const originalVal = process.env[varName];
398
+ process.env[varName] = 'should-not-appear';
399
+
400
+ try {
401
+ const result = await hostShellTool.execute({
402
+ command: 'env',
403
+ }, makeContext());
404
+
405
+ expect(result.isError).toBe(false);
406
+ expect(result.content).not.toContain(varName);
407
+ expect(result.content).not.toContain('should-not-appear');
408
+ } finally {
409
+ if (originalVal === undefined) {
410
+ delete process.env[varName];
411
+ } else {
412
+ process.env[varName] = originalVal;
413
+ }
414
+ }
415
+ });
416
+
417
+ test('includes safe env vars like HOME and TERM', async () => {
418
+ const result = await hostShellTool.execute({
419
+ command: 'echo "HOME=$HOME"',
420
+ }, makeContext());
421
+
422
+ expect(result.isError).toBe(false);
423
+ expect(result.content).toContain('HOME=');
424
+ expect(result.content.trim()).not.toBe('HOME=');
425
+ });
426
+ });
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Timeout handling
430
+ // ---------------------------------------------------------------------------
431
+
432
+ describe('host_bash — timeout handling', () => {
433
+ test('respects custom timeout_seconds', async () => {
434
+ const result = await hostShellTool.execute({
435
+ command: 'sleep 5',
436
+ timeout_seconds: 1,
437
+ }, makeContext());
438
+
439
+ expect(result.isError).toBe(true);
440
+ expect(result.content).toContain('command_timeout');
441
+ });
442
+
443
+ test('clamps timeout to at least 1 second', async () => {
444
+ // A timeout_seconds of 0 should be clamped to 1
445
+ const result = await hostShellTool.execute({
446
+ command: 'echo fast',
447
+ timeout_seconds: 0,
448
+ }, makeContext());
449
+
450
+ // Should still complete — 1 second is enough for echo
451
+ expect(result.isError).toBe(false);
452
+ expect(result.content.trim()).toBe('fast');
453
+ });
454
+
455
+ test('clamps timeout to max configured value', async () => {
456
+ // Request a timeout larger than the configured max (600)
457
+ const result = await hostShellTool.execute({
458
+ command: 'echo capped',
459
+ timeout_seconds: 9999,
460
+ }, makeContext());
461
+
462
+ expect(result.isError).toBe(false);
463
+ expect(result.content.trim()).toBe('capped');
464
+ });
465
+ });
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Streaming output and abort signal
469
+ // ---------------------------------------------------------------------------
470
+
471
+ describe('host_bash — streaming and cancellation', () => {
472
+ test('calls onOutput callback with stdout chunks', async () => {
473
+ const chunks: string[] = [];
474
+ const ctx = {
475
+ ...makeContext(),
476
+ onOutput: (chunk: string) => chunks.push(chunk),
477
+ };
478
+
479
+ const result = await hostShellTool.execute({
480
+ command: 'echo streamed-output',
481
+ }, ctx);
482
+
483
+ expect(result.isError).toBe(false);
484
+ expect(chunks.join('')).toContain('streamed-output');
485
+ });
486
+
487
+ test('calls onOutput callback with stderr chunks', async () => {
488
+ const chunks: string[] = [];
489
+ const ctx = {
490
+ ...makeContext(),
491
+ onOutput: (chunk: string) => chunks.push(chunk),
492
+ };
493
+
494
+ await hostShellTool.execute({
495
+ command: 'echo stderr-data >&2',
496
+ }, ctx);
497
+
498
+ expect(chunks.join('')).toContain('stderr-data');
499
+ });
500
+
501
+ test('kills process when abort signal fires', async () => {
502
+ const ac = new AbortController();
503
+
504
+ // Start a long-running command then abort it quickly
505
+ const promise = hostShellTool.execute({
506
+ command: 'sleep 30',
507
+ }, { ...makeContext(), signal: ac.signal });
508
+
509
+ // Give the process a moment to start
510
+ await new Promise(r => setTimeout(r, 100));
511
+ ac.abort();
512
+
513
+ const result = await promise;
514
+ // The process was killed, so it should report an error (non-zero exit)
515
+ expect(result.isError).toBe(true);
516
+ });
517
+
518
+ test('immediately kills process if signal already aborted', async () => {
519
+ const ac = new AbortController();
520
+ ac.abort();
521
+
522
+ const result = await hostShellTool.execute({
523
+ command: 'sleep 30',
524
+ }, { ...makeContext(), signal: ac.signal });
525
+
526
+ expect(result.isError).toBe(true);
527
+ });
528
+ });
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Error handling for spawn failures
532
+ // ---------------------------------------------------------------------------
533
+
534
+ describe('host_bash — spawn error handling', () => {
535
+ test('reports error when working_dir does not exist', async () => {
536
+ const result = await hostShellTool.execute({
537
+ command: 'echo test',
538
+ working_dir: '/nonexistent/path/that/does/not/exist',
539
+ }, makeContext());
540
+
541
+ expect(result.isError).toBe(true);
542
+ expect(result.content).toContain('Error spawning command');
543
+ });
544
+
545
+ test('captures both stdout and stderr in output', async () => {
546
+ const result = await hostShellTool.execute({
547
+ command: 'echo out && echo err >&2',
548
+ }, makeContext());
549
+
550
+ expect(result.content).toContain('out');
551
+ expect(result.content).toContain('err');
552
+ });
553
+
554
+ test('returns completed marker for successful empty output', async () => {
555
+ const result = await hostShellTool.execute({
556
+ command: 'true',
557
+ }, makeContext());
558
+
559
+ expect(result.isError).toBe(false);
560
+ expect(result.content).toContain('<command_completed />');
561
+ });
562
+ });