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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// --- Mocks (must be declared before importing the module under test) ---
|
|
4
|
+
|
|
5
|
+
let mockStrategy: string | undefined = undefined;
|
|
6
|
+
let mockOauthAvailable = false;
|
|
7
|
+
let mockOauthPostResult: { tweetId: string; text: string; url?: string } | null = null;
|
|
8
|
+
let mockOauthPostError: Error | null = null;
|
|
9
|
+
let mockBrowserPostResult: { tweetId: string; text: string; url: string } | null = null;
|
|
10
|
+
let mockBrowserPostError: Error | null = null;
|
|
11
|
+
|
|
12
|
+
// Mock the config loader to return a controllable strategy
|
|
13
|
+
mock.module('../config/loader.js', () => ({
|
|
14
|
+
loadRawConfig: () => {
|
|
15
|
+
if (mockStrategy !== undefined) {
|
|
16
|
+
return { twitterOperationStrategy: mockStrategy };
|
|
17
|
+
}
|
|
18
|
+
return {};
|
|
19
|
+
},
|
|
20
|
+
loadConfig: () => ({}),
|
|
21
|
+
saveConfig: () => {},
|
|
22
|
+
saveRawConfig: () => {},
|
|
23
|
+
getConfig: () => ({}),
|
|
24
|
+
invalidateConfigCache: () => {},
|
|
25
|
+
getNestedValue: () => undefined,
|
|
26
|
+
setNestedValue: () => {},
|
|
27
|
+
API_KEY_PROVIDERS: [],
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock the OAuth client
|
|
31
|
+
mock.module('../twitter/oauth-client.js', () => ({
|
|
32
|
+
oauthIsAvailable: () => mockOauthAvailable,
|
|
33
|
+
oauthSupportsOperation: (op: string) => op === 'post' || op === 'reply',
|
|
34
|
+
oauthPostTweet: async (_text: string, _opts?: { inReplyToTweetId?: string }) => {
|
|
35
|
+
if (mockOauthPostError) throw mockOauthPostError;
|
|
36
|
+
if (mockOauthPostResult) return mockOauthPostResult;
|
|
37
|
+
throw new Error('OAuth mock not configured');
|
|
38
|
+
},
|
|
39
|
+
UnsupportedOAuthOperationError: class UnsupportedOAuthOperationError extends Error {
|
|
40
|
+
public readonly suggestFallback = true;
|
|
41
|
+
public readonly fallbackPath = 'browser' as const;
|
|
42
|
+
public readonly operation: string;
|
|
43
|
+
constructor(operation: string) {
|
|
44
|
+
super(`The "${operation}" operation is not available via the OAuth API.`);
|
|
45
|
+
this.name = 'UnsupportedOAuthOperationError';
|
|
46
|
+
this.operation = operation;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// Create a SessionExpiredError class that matches the real one
|
|
52
|
+
class MockSessionExpiredError extends Error {
|
|
53
|
+
constructor(reason: string) {
|
|
54
|
+
super(reason);
|
|
55
|
+
this.name = 'SessionExpiredError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Mock the browser client
|
|
60
|
+
mock.module('../twitter/client.js', () => ({
|
|
61
|
+
postTweet: async (_text: string, _opts?: { inReplyToTweetId?: string }) => {
|
|
62
|
+
if (mockBrowserPostError) throw mockBrowserPostError;
|
|
63
|
+
if (mockBrowserPostResult) return mockBrowserPostResult;
|
|
64
|
+
throw new Error('Browser mock not configured');
|
|
65
|
+
},
|
|
66
|
+
SessionExpiredError: MockSessionExpiredError,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// Mock the logger to silence output
|
|
70
|
+
mock.module('../util/logger.js', () => ({
|
|
71
|
+
getLogger: () => ({
|
|
72
|
+
info: () => {},
|
|
73
|
+
warn: () => {},
|
|
74
|
+
error: () => {},
|
|
75
|
+
debug: () => {},
|
|
76
|
+
trace: () => {},
|
|
77
|
+
fatal: () => {},
|
|
78
|
+
child: () => ({
|
|
79
|
+
info: () => {},
|
|
80
|
+
warn: () => {},
|
|
81
|
+
error: () => {},
|
|
82
|
+
debug: () => {},
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
import { routedPostTweet } from '../twitter/router.js';
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
mockStrategy = undefined;
|
|
91
|
+
mockOauthAvailable = false;
|
|
92
|
+
mockOauthPostResult = null;
|
|
93
|
+
mockOauthPostError = null;
|
|
94
|
+
mockBrowserPostResult = null;
|
|
95
|
+
mockBrowserPostError = null;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Twitter strategy router', () => {
|
|
99
|
+
describe('auto strategy', () => {
|
|
100
|
+
test('uses OAuth when available and supported', async () => {
|
|
101
|
+
mockOauthAvailable = true;
|
|
102
|
+
mockOauthPostResult = { tweetId: '111', text: 'hello', url: 'https://x.com/u/status/111' };
|
|
103
|
+
|
|
104
|
+
const { result, pathUsed } = await routedPostTweet('hello');
|
|
105
|
+
|
|
106
|
+
expect(pathUsed).toBe('oauth');
|
|
107
|
+
expect(result.tweetId).toBe('111');
|
|
108
|
+
expect(result.text).toBe('hello');
|
|
109
|
+
expect(result.url).toBe('https://x.com/u/status/111');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('falls back to browser when OAuth is unavailable', async () => {
|
|
113
|
+
mockOauthAvailable = false;
|
|
114
|
+
mockBrowserPostResult = { tweetId: '222', text: 'hello', url: 'https://x.com/u/status/222' };
|
|
115
|
+
|
|
116
|
+
const { result, pathUsed } = await routedPostTweet('hello');
|
|
117
|
+
|
|
118
|
+
expect(pathUsed).toBe('browser');
|
|
119
|
+
expect(result.tweetId).toBe('222');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('falls back to browser when OAuth fails', async () => {
|
|
123
|
+
mockOauthAvailable = true;
|
|
124
|
+
mockOauthPostError = new Error('OAuth token expired');
|
|
125
|
+
mockBrowserPostResult = { tweetId: '333', text: 'hello', url: 'https://x.com/u/status/333' };
|
|
126
|
+
|
|
127
|
+
const { result, pathUsed } = await routedPostTweet('hello');
|
|
128
|
+
|
|
129
|
+
expect(pathUsed).toBe('browser');
|
|
130
|
+
expect(result.tweetId).toBe('333');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('constructs URL from tweetId when OAuth result has no url', async () => {
|
|
134
|
+
mockOauthAvailable = true;
|
|
135
|
+
mockOauthPostResult = { tweetId: '444', text: 'no url' };
|
|
136
|
+
|
|
137
|
+
const { result, pathUsed } = await routedPostTweet('no url');
|
|
138
|
+
|
|
139
|
+
expect(pathUsed).toBe('oauth');
|
|
140
|
+
expect(result.url).toBe('https://x.com/i/status/444');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('throws combined error when both OAuth and browser fail with SessionExpiredError', async () => {
|
|
144
|
+
mockOauthAvailable = true;
|
|
145
|
+
mockOauthPostError = new Error('OAuth failed');
|
|
146
|
+
mockBrowserPostError = new MockSessionExpiredError('Browser session expired');
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await routedPostTweet('will fail');
|
|
150
|
+
expect(true).toBe(false); // should not reach
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const e = err as Error & { pathUsed: string; oauthError?: string };
|
|
153
|
+
expect(e).toBeInstanceOf(MockSessionExpiredError);
|
|
154
|
+
expect(e.message).toBe('Browser session expired');
|
|
155
|
+
expect(e.pathUsed).toBe('auto');
|
|
156
|
+
expect(e.oauthError).toBe('OAuth failed');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('explicit oauth strategy', () => {
|
|
162
|
+
test('fails with helpful error when OAuth is not configured', async () => {
|
|
163
|
+
mockStrategy = 'oauth';
|
|
164
|
+
mockOauthAvailable = false;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await routedPostTweet('hello');
|
|
168
|
+
expect(true).toBe(false); // should not reach
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const e = err as Error & { pathUsed: string; suggestAlternative: string };
|
|
171
|
+
expect(e.message).toContain('OAuth is not configured');
|
|
172
|
+
expect(e.message).toContain('vellum x strategy set browser');
|
|
173
|
+
expect(e.pathUsed).toBe('oauth');
|
|
174
|
+
expect(e.suggestAlternative).toBe('browser');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('uses OAuth when available', async () => {
|
|
179
|
+
mockStrategy = 'oauth';
|
|
180
|
+
mockOauthAvailable = true;
|
|
181
|
+
mockOauthPostResult = { tweetId: '555', text: 'oauth post' };
|
|
182
|
+
|
|
183
|
+
const { result, pathUsed } = await routedPostTweet('oauth post');
|
|
184
|
+
|
|
185
|
+
expect(pathUsed).toBe('oauth');
|
|
186
|
+
expect(result.tweetId).toBe('555');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('explicit browser strategy', () => {
|
|
191
|
+
test('uses browser directly, ignoring OAuth availability', async () => {
|
|
192
|
+
mockStrategy = 'browser';
|
|
193
|
+
mockOauthAvailable = true; // available but should be ignored
|
|
194
|
+
mockBrowserPostResult = { tweetId: '666', text: 'browser post', url: 'https://x.com/u/status/666' };
|
|
195
|
+
|
|
196
|
+
const { result, pathUsed } = await routedPostTweet('browser post');
|
|
197
|
+
|
|
198
|
+
expect(pathUsed).toBe('browser');
|
|
199
|
+
expect(result.tweetId).toBe('666');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('preserves SessionExpiredError type with router metadata', async () => {
|
|
203
|
+
mockStrategy = 'browser';
|
|
204
|
+
mockBrowserPostError = new MockSessionExpiredError('Session expired');
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await routedPostTweet('will fail');
|
|
208
|
+
expect(true).toBe(false); // should not reach
|
|
209
|
+
} catch (err) {
|
|
210
|
+
const e = err as Error & { pathUsed: string; suggestAlternative: string };
|
|
211
|
+
expect(e).toBeInstanceOf(MockSessionExpiredError);
|
|
212
|
+
expect(e.message).toBe('Session expired');
|
|
213
|
+
expect(e.pathUsed).toBe('browser');
|
|
214
|
+
expect(e.suggestAlternative).toBe('oauth');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('re-throws non-session errors without wrapping', async () => {
|
|
219
|
+
mockStrategy = 'browser';
|
|
220
|
+
mockBrowserPostError = new Error('Network failure');
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await routedPostTweet('will fail');
|
|
224
|
+
expect(true).toBe(false); // should not reach
|
|
225
|
+
} catch (err) {
|
|
226
|
+
expect((err as Error).message).toBe('Network failure');
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('reply routing', () => {
|
|
232
|
+
test('auto strategy routes reply through OAuth when available', async () => {
|
|
233
|
+
mockOauthAvailable = true;
|
|
234
|
+
mockOauthPostResult = { tweetId: '777', text: 'reply text', url: 'https://x.com/u/status/777' };
|
|
235
|
+
|
|
236
|
+
const { result, pathUsed } = await routedPostTweet('reply text', { inReplyToTweetId: '100' });
|
|
237
|
+
|
|
238
|
+
expect(pathUsed).toBe('oauth');
|
|
239
|
+
expect(result.tweetId).toBe('777');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('browser strategy routes reply through browser', async () => {
|
|
243
|
+
mockStrategy = 'browser';
|
|
244
|
+
mockBrowserPostResult = { tweetId: '888', text: 'reply text', url: 'https://x.com/u/status/888' };
|
|
245
|
+
|
|
246
|
+
const { result, pathUsed } = await routedPostTweet('reply text', { inReplyToTweetId: '200' });
|
|
247
|
+
|
|
248
|
+
expect(pathUsed).toBe('browser');
|
|
249
|
+
expect(result.tweetId).toBe('888');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// --- Mocks (must be declared before importing the module under test) ---
|
|
4
|
+
|
|
5
|
+
let secureKeyStore: Record<string, string> = {};
|
|
6
|
+
|
|
7
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
8
|
+
getSecureKey: (account: string) => secureKeyStore[account] ?? undefined,
|
|
9
|
+
setSecureKey: (account: string, value: string) => {
|
|
10
|
+
secureKeyStore[account] = value;
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
deleteSecureKey: () => true,
|
|
14
|
+
listSecureKeys: () => Object.keys(secureKeyStore),
|
|
15
|
+
getBackendType: () => 'encrypted',
|
|
16
|
+
isDowngradedFromKeychain: () => false,
|
|
17
|
+
_resetBackend: () => {},
|
|
18
|
+
_setBackend: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// withValidToken: call the callback directly with a fake token.
|
|
22
|
+
mock.module('../security/token-manager.js', () => ({
|
|
23
|
+
withValidToken: async (_service: string, cb: (token: string) => Promise<unknown>) =>
|
|
24
|
+
cb('fake-oauth-token'),
|
|
25
|
+
TokenExpiredError: class TokenExpiredError extends Error {
|
|
26
|
+
constructor(public readonly service: string, message?: string) {
|
|
27
|
+
super(message ?? `Token expired for "${service}".`);
|
|
28
|
+
this.name = 'TokenExpiredError';
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
mock.module('../util/logger.js', () => ({
|
|
34
|
+
getLogger: () => ({
|
|
35
|
+
info: () => {},
|
|
36
|
+
warn: () => {},
|
|
37
|
+
error: () => {},
|
|
38
|
+
debug: () => {},
|
|
39
|
+
trace: () => {},
|
|
40
|
+
fatal: () => {},
|
|
41
|
+
child: () => ({
|
|
42
|
+
info: () => {},
|
|
43
|
+
warn: () => {},
|
|
44
|
+
error: () => {},
|
|
45
|
+
debug: () => {},
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
oauthPostTweet,
|
|
52
|
+
oauthIsAvailable,
|
|
53
|
+
oauthSupportsOperation,
|
|
54
|
+
UnsupportedOAuthOperationError,
|
|
55
|
+
} from '../twitter/oauth-client.js';
|
|
56
|
+
|
|
57
|
+
// --- Global fetch mock ---
|
|
58
|
+
|
|
59
|
+
const originalFetch = globalThis.fetch;
|
|
60
|
+
let _fetchMock: ReturnType<typeof mock> | null = null;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
secureKeyStore = {};
|
|
64
|
+
_fetchMock = null;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
globalThis.fetch = originalFetch;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function mockFetch(response: { ok: boolean; status: number; json?: unknown; text?: string }) {
|
|
72
|
+
const fn = mock(() =>
|
|
73
|
+
Promise.resolve({
|
|
74
|
+
ok: response.ok,
|
|
75
|
+
status: response.status,
|
|
76
|
+
json: () => Promise.resolve(response.json),
|
|
77
|
+
text: () => Promise.resolve(response.text ?? ''),
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
globalThis.fetch = fn as unknown as typeof fetch;
|
|
81
|
+
_fetchMock = fn;
|
|
82
|
+
return fn;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('Twitter OAuth client', () => {
|
|
86
|
+
describe('oauthPostTweet', () => {
|
|
87
|
+
test('successfully posts and returns tweet ID', async () => {
|
|
88
|
+
const fn = mockFetch({
|
|
89
|
+
ok: true,
|
|
90
|
+
status: 200,
|
|
91
|
+
json: { data: { id: '12345', text: 'Hello world' } },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await oauthPostTweet('Hello world');
|
|
95
|
+
|
|
96
|
+
expect(result.tweetId).toBe('12345');
|
|
97
|
+
expect(result.text).toBe('Hello world');
|
|
98
|
+
|
|
99
|
+
// Verify the request was made correctly
|
|
100
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
101
|
+
const [url, opts] = fn.mock.calls[0] as unknown as [string, RequestInit];
|
|
102
|
+
expect(url).toBe('https://api.x.com/2/tweets');
|
|
103
|
+
expect(opts.method).toBe('POST');
|
|
104
|
+
expect((opts.headers as Record<string, string>)['Authorization']).toBe('Bearer fake-oauth-token');
|
|
105
|
+
expect((opts.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
|
106
|
+
|
|
107
|
+
const body = JSON.parse(opts.body as string);
|
|
108
|
+
expect(body.text).toBe('Hello world');
|
|
109
|
+
expect(body.reply).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('with reply returns correct result', async () => {
|
|
113
|
+
const fn = mockFetch({
|
|
114
|
+
ok: true,
|
|
115
|
+
status: 200,
|
|
116
|
+
json: { data: { id: '67890', text: 'My reply' } },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const result = await oauthPostTweet('My reply', { inReplyToTweetId: '11111' });
|
|
120
|
+
|
|
121
|
+
expect(result.tweetId).toBe('67890');
|
|
122
|
+
expect(result.text).toBe('My reply');
|
|
123
|
+
|
|
124
|
+
const [, opts] = fn.mock.calls[0] as unknown as [string, RequestInit];
|
|
125
|
+
const body = JSON.parse(opts.body as string);
|
|
126
|
+
expect(body.text).toBe('My reply');
|
|
127
|
+
expect(body.reply).toEqual({ in_reply_to_tweet_id: '11111' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('throws on API error', async () => {
|
|
131
|
+
mockFetch({
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 429,
|
|
134
|
+
text: 'Rate limit exceeded',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(oauthPostTweet('will fail')).rejects.toThrow(
|
|
138
|
+
/Twitter API error \(429\)/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('attaches status to thrown error for token manager retry', async () => {
|
|
143
|
+
mockFetch({
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 401,
|
|
146
|
+
text: 'Unauthorized',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await oauthPostTweet('will fail');
|
|
151
|
+
expect(true).toBe(false); // should not reach
|
|
152
|
+
} catch (err) {
|
|
153
|
+
expect((err as Error & { status: number }).status).toBe(401);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('oauthIsAvailable', () => {
|
|
159
|
+
test('returns true when access token exists', () => {
|
|
160
|
+
secureKeyStore['credential:integration:twitter:access_token'] = 'some-token';
|
|
161
|
+
expect(oauthIsAvailable()).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('returns false when no access token', () => {
|
|
165
|
+
expect(oauthIsAvailable()).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('oauthSupportsOperation', () => {
|
|
170
|
+
test('returns true for post', () => {
|
|
171
|
+
expect(oauthSupportsOperation('post')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('returns true for reply', () => {
|
|
175
|
+
expect(oauthSupportsOperation('reply')).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('returns false for unsupported operations', () => {
|
|
179
|
+
const unsupported = [
|
|
180
|
+
'timeline',
|
|
181
|
+
'search',
|
|
182
|
+
'bookmarks',
|
|
183
|
+
'home',
|
|
184
|
+
'notifications',
|
|
185
|
+
'likes',
|
|
186
|
+
'followers',
|
|
187
|
+
'following',
|
|
188
|
+
'media',
|
|
189
|
+
'tweet',
|
|
190
|
+
];
|
|
191
|
+
for (const op of unsupported) {
|
|
192
|
+
expect(oauthSupportsOperation(op)).toBe(false);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('UnsupportedOAuthOperationError', () => {
|
|
198
|
+
test('has correct properties', () => {
|
|
199
|
+
const err = new UnsupportedOAuthOperationError('search');
|
|
200
|
+
expect(err.name).toBe('UnsupportedOAuthOperationError');
|
|
201
|
+
expect(err.operation).toBe('search');
|
|
202
|
+
expect(err.suggestFallback).toBe(true);
|
|
203
|
+
expect(err.fallbackPath).toBe('browser');
|
|
204
|
+
expect(err.message).toContain('search');
|
|
205
|
+
expect(err.message).toContain('not available via the OAuth API');
|
|
206
|
+
expect(err).toBeInstanceOf(Error);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, mkdirSync, symlinkSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { isPathWithinWorkspaceRoot, isWorkspaceScopedInvocation } from '../permissions/workspace-policy.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Temp directory scaffold for symlink / path-containment tests
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
let testDir: string;
|
|
13
|
+
let workspaceRoot: string;
|
|
14
|
+
let outsideDir: string;
|
|
15
|
+
let symlinkInside: string;
|
|
16
|
+
let symlinkToOutside: string;
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
testDir = mkdtempSync(join(tmpdir(), 'ws-policy-test-'));
|
|
20
|
+
workspaceRoot = join(testDir, 'workspace');
|
|
21
|
+
outsideDir = join(testDir, 'outside');
|
|
22
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
23
|
+
mkdirSync(join(workspaceRoot, 'src'), { recursive: true });
|
|
24
|
+
mkdirSync(outsideDir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
// Symlink inside workspace pointing to another directory inside workspace
|
|
27
|
+
symlinkInside = join(workspaceRoot, 'link-to-src');
|
|
28
|
+
symlinkSync(join(workspaceRoot, 'src'), symlinkInside);
|
|
29
|
+
|
|
30
|
+
// Symlink inside workspace pointing outside the workspace
|
|
31
|
+
symlinkToOutside = join(workspaceRoot, 'link-to-outside');
|
|
32
|
+
symlinkSync(outsideDir, symlinkToOutside);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// isPathWithinWorkspaceRoot
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe('isPathWithinWorkspaceRoot', () => {
|
|
44
|
+
test('returns true for a file directly inside the workspace', () => {
|
|
45
|
+
expect(isPathWithinWorkspaceRoot(join(workspaceRoot, 'file.txt'), workspaceRoot)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('returns true for a file in a subdirectory', () => {
|
|
49
|
+
expect(isPathWithinWorkspaceRoot(join(workspaceRoot, 'src', 'index.ts'), workspaceRoot)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('returns true for the workspace root itself', () => {
|
|
53
|
+
expect(isPathWithinWorkspaceRoot(workspaceRoot, workspaceRoot)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('returns false for a path outside the workspace', () => {
|
|
57
|
+
expect(isPathWithinWorkspaceRoot(outsideDir, workspaceRoot)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('returns false for parent traversal escaping the workspace', () => {
|
|
61
|
+
const escapedPath = join(workspaceRoot, '..', 'outside', 'secret.txt');
|
|
62
|
+
expect(isPathWithinWorkspaceRoot(escapedPath, workspaceRoot)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('returns true for a symlink that resolves inside the workspace', () => {
|
|
66
|
+
expect(isPathWithinWorkspaceRoot(symlinkInside, workspaceRoot)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('returns false for a symlink that resolves outside the workspace', () => {
|
|
70
|
+
expect(isPathWithinWorkspaceRoot(symlinkToOutside, workspaceRoot)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('returns false for empty filePath', () => {
|
|
74
|
+
expect(isPathWithinWorkspaceRoot('', workspaceRoot)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('returns false for empty workspaceRoot', () => {
|
|
78
|
+
expect(isPathWithinWorkspaceRoot('/some/file', '')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('returns false for both empty', () => {
|
|
82
|
+
expect(isPathWithinWorkspaceRoot('', '')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('handles non-existent file paths gracefully (new file write)', () => {
|
|
86
|
+
const newFile = join(workspaceRoot, 'new-dir', 'new-file.ts');
|
|
87
|
+
expect(isPathWithinWorkspaceRoot(newFile, workspaceRoot)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('rejects path that is a prefix but not a child directory', () => {
|
|
91
|
+
// e.g. /tmp/workspace-extra should NOT match /tmp/workspace
|
|
92
|
+
const sibling = `${workspaceRoot}-extra`;
|
|
93
|
+
mkdirSync(sibling, { recursive: true });
|
|
94
|
+
expect(isPathWithinWorkspaceRoot(join(sibling, 'file.txt'), workspaceRoot)).toBe(false);
|
|
95
|
+
rmSync(sibling, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// isWorkspaceScopedInvocation
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe('isWorkspaceScopedInvocation', () => {
|
|
104
|
+
// ── Path-scoped tools ──────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe('file_read / file_write / file_edit', () => {
|
|
107
|
+
test('returns true when file_path is inside workspace', () => {
|
|
108
|
+
expect(
|
|
109
|
+
isWorkspaceScopedInvocation('file_read', { file_path: join(workspaceRoot, 'foo.txt') }, workspaceRoot),
|
|
110
|
+
).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('returns true when path (alternate key) is inside workspace', () => {
|
|
114
|
+
expect(
|
|
115
|
+
isWorkspaceScopedInvocation('file_write', { path: join(workspaceRoot, 'bar.ts') }, workspaceRoot),
|
|
116
|
+
).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('returns false when file_path is outside workspace', () => {
|
|
120
|
+
expect(
|
|
121
|
+
isWorkspaceScopedInvocation('file_edit', { file_path: '/etc/passwd' }, workspaceRoot),
|
|
122
|
+
).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('returns false when file_path is missing', () => {
|
|
126
|
+
expect(
|
|
127
|
+
isWorkspaceScopedInvocation('file_read', {}, workspaceRoot),
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('returns false when file_path is not a string', () => {
|
|
132
|
+
expect(
|
|
133
|
+
isWorkspaceScopedInvocation('file_write', { file_path: 123 }, workspaceRoot),
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('resolves relative path inside workspace against workspaceRoot', () => {
|
|
138
|
+
expect(
|
|
139
|
+
isWorkspaceScopedInvocation('file_read', { path: 'src/index.ts' }, workspaceRoot),
|
|
140
|
+
).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('resolves relative path with ../ that escapes workspace as outside', () => {
|
|
144
|
+
expect(
|
|
145
|
+
isWorkspaceScopedInvocation('file_read', { file_path: '../outside/secret.txt' }, workspaceRoot),
|
|
146
|
+
).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('absolute path inside workspace still works', () => {
|
|
150
|
+
expect(
|
|
151
|
+
isWorkspaceScopedInvocation('file_edit', { file_path: join(workspaceRoot, 'src', 'main.ts') }, workspaceRoot),
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── Bash ───────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('bash', () => {
|
|
159
|
+
test('returns true (sandbox handles isolation)', () => {
|
|
160
|
+
expect(
|
|
161
|
+
isWorkspaceScopedInvocation('bash', { command: 'ls -la' }, workspaceRoot),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Network tools ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe('network tools', () => {
|
|
169
|
+
const networkTools = [
|
|
170
|
+
'web_search', 'web_fetch', 'browser_navigate', 'browser_click',
|
|
171
|
+
'browser_type', 'browser_scroll', 'browser_screenshot',
|
|
172
|
+
'browser_close', 'network_request',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const tool of networkTools) {
|
|
176
|
+
test(`${tool} is NOT workspace-scoped`, () => {
|
|
177
|
+
expect(isWorkspaceScopedInvocation(tool, {}, workspaceRoot)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── Host tools ─────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('host tools', () => {
|
|
185
|
+
const hostTools = ['host_file_read', 'host_file_write', 'host_file_edit', 'host_bash'];
|
|
186
|
+
|
|
187
|
+
for (const tool of hostTools) {
|
|
188
|
+
test(`${tool} is NOT workspace-scoped`, () => {
|
|
189
|
+
expect(isWorkspaceScopedInvocation(tool, {}, workspaceRoot)).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Always-scoped safe tools ───────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
describe('always-scoped tools', () => {
|
|
197
|
+
const safeTools = ['skill_load', 'view_image', 'memory_search', 'ui_update', 'ui_dismiss'];
|
|
198
|
+
|
|
199
|
+
for (const tool of safeTools) {
|
|
200
|
+
test(`${tool} is workspace-scoped`, () => {
|
|
201
|
+
expect(isWorkspaceScopedInvocation(tool, {}, workspaceRoot)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── Unknown tools ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe('unknown tools', () => {
|
|
209
|
+
test('defaults to NOT workspace-scoped', () => {
|
|
210
|
+
expect(isWorkspaceScopedInvocation('mystery_tool', {}, workspaceRoot)).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|