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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- 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
|
|
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
|
-
|
|
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
|
+
});
|