@cspeach/cli 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/index.js +0 -0
  2. package/package.json +86 -82
  3. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  4. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  5. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  6. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  7. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  8. package/dist/agent/__tests__/retry-key.test.js +0 -77
  9. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  10. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  11. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  12. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  13. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  14. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  15. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  16. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  17. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  18. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  19. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  20. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  21. package/dist/auth/__tests__/auth-file.test.js +0 -42
  22. package/dist/auth/__tests__/me.test.js +0 -43
  23. package/dist/classifier/__tests__/client.test.js +0 -70
  24. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  25. package/dist/commands/__tests__/login.test.js +0 -152
  26. package/dist/commands/__tests__/logout.test.js +0 -39
  27. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  28. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  29. package/dist/commands/__tests__/whoami.test.js +0 -91
  30. package/dist/config/__tests__/llm-config.test.js +0 -28
  31. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  32. package/dist/config/__tests__/write-mode.test.js +0 -36
  33. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  34. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  35. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  36. package/dist/project-context/__tests__/conventions.test.js +0 -221
  37. package/dist/project-context/__tests__/detect.test.js +0 -177
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  40. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  41. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  42. package/dist/project-context/__tests__/git.test.js +0 -131
  43. package/dist/project-context/__tests__/index-files.test.js +0 -195
  44. package/dist/project-context/__tests__/index.test.js +0 -133
  45. package/dist/project-context/__tests__/render.test.js +0 -322
  46. package/dist/project-context/__tests__/types.test.js +0 -73
  47. package/dist/projects/__tests__/build.test.js +0 -52
  48. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  49. package/dist/projects/__tests__/email-template.test.js +0 -71
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  51. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  52. package/dist/projects/__tests__/extract-design.test.js +0 -62
  53. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  54. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  57. package/dist/projects/__tests__/filename.test.js +0 -32
  58. package/dist/projects/__tests__/promote-command.test.js +0 -170
  59. package/dist/projects/__tests__/promote.test.js +0 -181
  60. package/dist/projects/__tests__/save-command.test.js +0 -216
  61. package/dist/projects/__tests__/save.test.js +0 -41
  62. package/dist/projects/__tests__/status.test.js +0 -208
  63. package/dist/projects/__tests__/types.test.js +0 -41
  64. package/dist/projects/__tests__/validate.test.js +0 -165
  65. package/dist/projects/__tests__/workspace.test.js +0 -337
  66. package/dist/repl/__tests__/current-transport.test.js +0 -43
  67. package/dist/repl/__tests__/diff-display.test.js +0 -59
  68. package/dist/repl/__tests__/file-picker.test.js +0 -97
  69. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  70. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  71. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  72. package/dist/sap/__tests__/system-info.test.js +0 -344
  73. package/dist/session/__tests__/store-discovery.test.js +0 -114
  74. package/dist/session/__tests__/time-ago.test.js +0 -53
  75. package/dist/skills/__tests__/canonical.test.js +0 -20
  76. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  78. package/dist/skills/__tests__/source-managed.test.js +0 -60
  79. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  80. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  82. package/dist/tools/__tests__/_flag.test.js +0 -66
  83. package/dist/tools/__tests__/_helpers.js +0 -17
  84. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  85. package/dist/tools/__tests__/agent_run.test.js +0 -166
  86. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  87. package/dist/tools/__tests__/background_run.test.js +0 -113
  88. package/dist/tools/__tests__/convention_get.test.js +0 -53
  89. package/dist/tools/__tests__/file-edit.test.js +0 -72
  90. package/dist/tools/__tests__/file-read.test.js +0 -81
  91. package/dist/tools/__tests__/file-write.test.js +0 -68
  92. package/dist/tools/__tests__/glob.test.js +0 -128
  93. package/dist/tools/__tests__/grep.test.js +0 -71
  94. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  95. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  96. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  97. package/dist/tools/__tests__/registry-category.test.js +0 -60
  98. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  99. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  100. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  101. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  102. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  103. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  104. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  105. package/dist/tools/__tests__/web_search.test.js +0 -215
  106. package/dist/tools/__tests__/write-mode.test.js +0 -32
  107. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -1,143 +0,0 @@
1
- // cspeach-cli/src/tools/__tests__/_web-shared.test.ts
2
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
- import { validateUrl, isPrivateIp, BlockedHostError, BlockedProtocolError, MalformedUrlError, } from '../web/_web-shared.js';
4
- // Test helper: stub the DNS resolver to return a fixed address list.
5
- function fakeResolver(addresses) {
6
- return {
7
- resolveAll: async () => addresses,
8
- };
9
- }
10
- let origAllowPrivate;
11
- beforeEach(() => {
12
- origAllowPrivate = process.env.CSPEACH_WEB_ALLOW_PRIVATE;
13
- delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
14
- });
15
- afterEach(() => {
16
- if (origAllowPrivate === undefined)
17
- delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
18
- else
19
- process.env.CSPEACH_WEB_ALLOW_PRIVATE = origAllowPrivate;
20
- });
21
- describe('isPrivateIp', () => {
22
- it('flags IPv4 RFC1918 + loopback + link-local + multicast + reserved as private', () => {
23
- expect(isPrivateIp('0.0.0.0')).toBe(true);
24
- expect(isPrivateIp('127.0.0.1')).toBe(true);
25
- expect(isPrivateIp('10.0.0.1')).toBe(true);
26
- expect(isPrivateIp('172.16.0.1')).toBe(true);
27
- expect(isPrivateIp('172.31.255.255')).toBe(true);
28
- expect(isPrivateIp('192.168.1.1')).toBe(true);
29
- expect(isPrivateIp('169.254.169.254')).toBe(true); // AWS / Azure metadata
30
- expect(isPrivateIp('224.0.0.1')).toBe(true); // multicast
31
- expect(isPrivateIp('255.255.255.255')).toBe(true); // broadcast
32
- // 100.64.0.0/10 — CGNAT / shared address space (RFC 6598). Used by
33
- // Fargate / Cloud Run / ACI for task-local networking. CSPeach must
34
- // treat as private to avoid task-to-task SSRF inside a container host.
35
- expect(isPrivateIp('100.64.0.1')).toBe(true);
36
- expect(isPrivateIp('100.127.255.255')).toBe(true);
37
- });
38
- it('flags IPv6 loopback + ULA + link-local + unspecified as private', () => {
39
- expect(isPrivateIp('::1')).toBe(true);
40
- expect(isPrivateIp('fc00::1')).toBe(true);
41
- expect(isPrivateIp('fe80::1')).toBe(true);
42
- expect(isPrivateIp('::')).toBe(true);
43
- // Link-local boundary: fe80::/10 spans fe80–febf. fe8: / febf: must
44
- // resolve correctly — see the boundary test below for the false-positive
45
- // case the regex used to allow.
46
- expect(isPrivateIp('febf::1')).toBe(true); // link-local, top of range
47
- });
48
- it('flags IPv4-mapped IPv6 in hex form (Node URL parser normalization)', () => {
49
- // Node's URL parser normalises https://[::ffff:192.168.1.1]/ to
50
- // [::ffff:c0a8:101] (hex 16-bit-group form). Without explicit handling,
51
- // an attacker bypasses RFC1918 + loopback by URL-encoding the address.
52
- expect(isPrivateIp('::ffff:c0a8:101')).toBe(true); // 192.168.1.1 in hex
53
- expect(isPrivateIp('::ffff:7f00:1')).toBe(true); // 127.0.0.1 in hex
54
- expect(isPrivateIp('::ffff:0a00:1')).toBe(true); // 10.0.0.1 in hex
55
- expect(isPrivateIp('::ffff:8080:8080')).toBe(false); // 128.128.128.128 — public, in hex
56
- });
57
- it('does NOT flag public IPv4 + IPv6 addresses', () => {
58
- expect(isPrivateIp('8.8.8.8')).toBe(false);
59
- expect(isPrivateIp('1.1.1.1')).toBe(false);
60
- expect(isPrivateIp('172.15.0.1')).toBe(false); // just outside 172.16.0.0/12
61
- expect(isPrivateIp('172.32.0.1')).toBe(false); // just outside the other end
62
- expect(isPrivateIp('100.63.255.255')).toBe(false); // just below CGNAT
63
- expect(isPrivateIp('100.128.0.1')).toBe(false); // just above CGNAT
64
- expect(isPrivateIp('2001:db8::1')).toBe(false); // documentation prefix — counts as public for our purposes
65
- expect(isPrivateIp('2606:4700:4700::1111')).toBe(false); // Cloudflare DNS
66
- // Link-local regex must NOT false-positive on three-nibble groups.
67
- // 'fe8:' / 'feb:' are global unicast, not fe80::/10 link-local.
68
- expect(isPrivateIp('fe8::1')).toBe(false);
69
- expect(isPrivateIp('fec0::1')).toBe(false); // site-local (deprecated, not blocked)
70
- });
71
- });
72
- describe('validateUrl — protocol + scheme', () => {
73
- it('accepts a valid HTTPS URL with public-resolving hostname', async () => {
74
- const v = await validateUrl('https://example.com/foo?bar=baz', {
75
- deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
76
- });
77
- expect(v.url.hostname).toBe('example.com');
78
- expect(v.pinnedAddress).toBe('93.184.216.34');
79
- expect(typeof v.pinnedLookup).toBe('function');
80
- });
81
- it('rejects http:// (HTTPS-only)', async () => {
82
- await expect(validateUrl('http://example.com/', {
83
- deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
84
- })).rejects.toThrow(BlockedProtocolError);
85
- });
86
- it('rejects file:// and ftp:// and javascript: schemes', async () => {
87
- for (const u of ['file:///etc/passwd', 'ftp://example.com/', 'javascript:alert(1)']) {
88
- await expect(validateUrl(u, { deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]) })).rejects.toThrow(BlockedProtocolError);
89
- }
90
- });
91
- it('rejects a malformed URL with MalformedUrlError', async () => {
92
- await expect(validateUrl('not a url', { deps: fakeResolver([]) })).rejects.toThrow(MalformedUrlError);
93
- });
94
- it('pinnedLookup returns ENOTFOUND when called with a hostname other than the pinned one', async () => {
95
- // Defends against a misconfigured https.request that re-dispatches the
96
- // lookup with a hostname different to the one we validated. We MUST
97
- // refuse that call — silently honouring it re-opens DNS rebinding.
98
- const v = await validateUrl('https://example.com/', {
99
- deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
100
- });
101
- const err = await new Promise((resolve) => {
102
- v.pinnedLookup('attacker-controlled.com', {}, (e) => resolve(e));
103
- });
104
- expect(err).not.toBeNull();
105
- expect(err?.code).toBe('ENOTFOUND');
106
- });
107
- });
108
- describe('validateUrl — hostname + IP blocklists', () => {
109
- it('rejects literal localhost hostname (string match BEFORE DNS)', async () => {
110
- await expect(validateUrl('https://localhost/', {
111
- deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]), // even if DNS lies, blocked on name
112
- })).rejects.toThrow(BlockedHostError);
113
- });
114
- it('rejects .local mDNS suffixes', async () => {
115
- await expect(validateUrl('https://mymac.local/', { deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]) })).rejects.toThrow(BlockedHostError);
116
- });
117
- it('rejects metadata.google.internal by literal name match', async () => {
118
- await expect(validateUrl('https://metadata.google.internal/computeMetadata/v1/', {
119
- deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
120
- })).rejects.toThrow(BlockedHostError);
121
- });
122
- it('rejects a public-looking hostname that resolves to a private IP (DNS-rebinding defense)', async () => {
123
- await expect(validateUrl('https://attacker-controlled.example.com/', {
124
- deps: fakeResolver([{ address: '127.0.0.1', family: 4 }]),
125
- })).rejects.toThrow(BlockedHostError);
126
- });
127
- it('rejects a hostname where ANY resolved address is private (mixed result)', async () => {
128
- // Some resolvers return BOTH a public and a private IP; we must reject if EITHER is private.
129
- await expect(validateUrl('https://mixed.example.com/', {
130
- deps: fakeResolver([
131
- { address: '93.184.216.34', family: 4 },
132
- { address: '169.254.169.254', family: 4 },
133
- ]),
134
- })).rejects.toThrow(BlockedHostError);
135
- });
136
- it('accepts a private-IP-resolving URL when CSPEACH_WEB_ALLOW_PRIVATE=on is set', async () => {
137
- process.env.CSPEACH_WEB_ALLOW_PRIVATE = 'on';
138
- const v = await validateUrl('https://dev.localhost.test/', {
139
- deps: fakeResolver([{ address: '127.0.0.1', family: 4 }]),
140
- });
141
- expect(v.pinnedAddress).toBe('127.0.0.1');
142
- });
143
- });
@@ -1,166 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { agentRunHandler, MAX_AGENT_DEPTH, DEFAULT_MAX_TOKENS, HARD_MAX_TOKENS } from '../subagent/agent_run.js';
3
- import { makeCtx } from './_helpers.js';
4
- // runTurn import is hoisted-mocked below so we can inspect the args
5
- // agent_run handed it without actually invoking the LLM.
6
- const { runTurnMock } = vi.hoisted(() => ({ runTurnMock: vi.fn() }));
7
- vi.mock('../../agent/loop.js', () => ({
8
- runTurn: runTurnMock,
9
- }));
10
- function makeSkillSource(body = '# fake skill body') {
11
- return {
12
- async getSkillBody(name) {
13
- return { name, body, sha256: 'fake', signature: 'fake', signedAt: '2026-05-14T00:00:00Z' };
14
- },
15
- async listAvailableSkills() { return ['abap-review']; },
16
- describe() { return 'fake-source'; },
17
- };
18
- }
19
- function makeProvider() {
20
- return { kind: 'anthropic', name: 'fake-provider' };
21
- }
22
- let savedDepth;
23
- beforeEach(() => {
24
- runTurnMock.mockReset();
25
- savedDepth = process.env.CSPEACH_AGENT_DEPTH;
26
- delete process.env.CSPEACH_AGENT_DEPTH;
27
- });
28
- afterEach(() => {
29
- if (savedDepth === undefined)
30
- delete process.env.CSPEACH_AGENT_DEPTH;
31
- else
32
- process.env.CSPEACH_AGENT_DEPTH = savedDepth;
33
- vi.restoreAllMocks();
34
- });
35
- describe('agent_run handler', () => {
36
- it('invokes runTurn with a fresh child session and returns the child assistant text', async () => {
37
- runTurnMock.mockImplementation(async (params) => {
38
- // Simulate the child loop pushing an assistant text block.
39
- params.ctx.session.messages.push({
40
- role: 'assistant',
41
- content: [{ type: 'text', text: 'child says hi' }],
42
- });
43
- });
44
- const ctx = makeCtx(process.cwd(), {
45
- provider: makeProvider(),
46
- skillSource: makeSkillSource(),
47
- });
48
- const r = await agentRunHandler({ skill: 'abap-review', task: 'review ZCL_X' }, ctx);
49
- expect(r.is_error).toBeFalsy();
50
- expect(r.content).toContain('child says hi');
51
- expect(runTurnMock).toHaveBeenCalledOnce();
52
- const callArgs = runTurnMock.mock.calls[0][0];
53
- expect(callArgs.ctx).not.toBe(ctx);
54
- expect(callArgs.ctx.session).not.toBe(ctx.session);
55
- // I2 fix: chunkEmitter is now a null-sink EventEmitter (not undefined)
56
- // so subagent stdout is silently dropped without polluting parent UI.
57
- expect(callArgs.chunkEmitter).toBeDefined();
58
- expect(callArgs.chunkEmitter.listenerCount?.('chunk') ?? 0).toBe(0);
59
- expect(callArgs.ctx.previewHook).toBeUndefined();
60
- expect(callArgs.ctx.chunkEmitter).toBeUndefined();
61
- expect(callArgs.skill).toBe('abap-review');
62
- });
63
- it('I1 regression — child session.usage is a clone, not a shared reference', async () => {
64
- runTurnMock.mockImplementation(async () => { });
65
- const parentSession = {
66
- messages: [],
67
- usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10 },
68
- };
69
- const ctx = makeCtx(process.cwd(), {
70
- provider: makeProvider(),
71
- skillSource: makeSkillSource(),
72
- session: parentSession,
73
- });
74
- await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
75
- const callArgs = runTurnMock.mock.calls[0][0];
76
- expect(callArgs.ctx.session.usage).not.toBe(parentSession.usage);
77
- expect(callArgs.ctx.session.usage).toEqual(parentSession.usage);
78
- });
79
- it('rejects when ctx.provider is missing (not wired)', async () => {
80
- const ctx = makeCtx(process.cwd(), { skillSource: makeSkillSource() });
81
- const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
82
- expect(r.is_error).toBe(true);
83
- expect(r.content).toMatch(/provider/i);
84
- expect(runTurnMock).not.toHaveBeenCalled();
85
- });
86
- it('rejects when ctx.skillSource is missing', async () => {
87
- const ctx = makeCtx(process.cwd(), { provider: makeProvider() });
88
- const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
89
- expect(r.is_error).toBe(true);
90
- expect(r.content).toMatch(/skillSource|skill source/i);
91
- expect(runTurnMock).not.toHaveBeenCalled();
92
- });
93
- it('rejects missing skill arg', async () => {
94
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
95
- const r = await agentRunHandler({ task: 't' }, ctx);
96
- expect(r.is_error).toBe(true);
97
- expect(r.content).toMatch(/skill/i);
98
- expect(runTurnMock).not.toHaveBeenCalled();
99
- });
100
- it('rejects missing task arg', async () => {
101
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
102
- const r = await agentRunHandler({ skill: 'abap-review' }, ctx);
103
- expect(r.is_error).toBe(true);
104
- expect(r.content).toMatch(/task/i);
105
- expect(runTurnMock).not.toHaveBeenCalled();
106
- });
107
- it('refuses to dispatch when depth would exceed MAX_AGENT_DEPTH', async () => {
108
- process.env.CSPEACH_AGENT_DEPTH = String(MAX_AGENT_DEPTH);
109
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
110
- const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
111
- expect(r.is_error).toBe(true);
112
- expect(r.content).toMatch(/depth/i);
113
- expect(runTurnMock).not.toHaveBeenCalled();
114
- });
115
- it('increments CSPEACH_AGENT_DEPTH for the child before invoking runTurn', async () => {
116
- let observed;
117
- runTurnMock.mockImplementation(async () => {
118
- observed = process.env.CSPEACH_AGENT_DEPTH;
119
- });
120
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
121
- await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
122
- expect(observed).toBe('1');
123
- });
124
- it('restores CSPEACH_AGENT_DEPTH to its prior value after runTurn returns', async () => {
125
- process.env.CSPEACH_AGENT_DEPTH = '1';
126
- runTurnMock.mockResolvedValueOnce(undefined);
127
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
128
- await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
129
- expect(process.env.CSPEACH_AGENT_DEPTH).toBe('1');
130
- });
131
- it('clamps max_tokens to HARD_MAX_TOKENS', async () => {
132
- let observedMaxTokens;
133
- runTurnMock.mockImplementation(async (params) => {
134
- observedMaxTokens = params.maxTokensOverride;
135
- });
136
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
137
- await agentRunHandler({ skill: 'abap-review', task: 't', max_tokens: 999_999_999 }, ctx);
138
- expect(observedMaxTokens).toBe(HARD_MAX_TOKENS);
139
- });
140
- it('uses DEFAULT_MAX_TOKENS when max_tokens omitted', async () => {
141
- let observedMaxTokens;
142
- runTurnMock.mockImplementation(async (params) => {
143
- observedMaxTokens = params.maxTokensOverride;
144
- });
145
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
146
- await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
147
- expect(observedMaxTokens).toBe(DEFAULT_MAX_TOKENS);
148
- });
149
- it('forwards a runTurn rejection as a structured error envelope', async () => {
150
- runTurnMock.mockRejectedValueOnce(new Error('child loop crashed'));
151
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
152
- const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
153
- expect(r.is_error).toBe(true);
154
- // M5: assert BOTH wrapper AND inner message — guards against the
155
- // wrapper swallowing the underlying error text.
156
- expect(r.content).toMatch(/subagent failed/i);
157
- expect(r.content).toMatch(/child loop crashed/i);
158
- });
159
- it('validates skill name shape (defense in depth on ctx.skillSource lookup key)', async () => {
160
- const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
161
- const r = await agentRunHandler({ skill: '../etc/passwd', task: 't' }, ctx);
162
- expect(r.is_error).toBe(true);
163
- expect(r.content).toMatch(/invalid skill/i);
164
- expect(runTurnMock).not.toHaveBeenCalled();
165
- });
166
- });
@@ -1,66 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- // Mock loadConfig while preserving the rest of the module's exports so that
3
- // approval.ts's other imports (WriteMode, isValidWriteMode, etc., if any) stay
4
- // intact at import time.
5
- const mockLoadConfig = vi.fn();
6
- vi.mock('../../config/loader.js', async () => {
7
- const actual = await vi.importActual('../../config/loader.js');
8
- return { ...actual, loadConfig: mockLoadConfig };
9
- });
10
- // Mock the prompt (don't mock the renderer — it's pure).
11
- const mockPromptAdvisory = vi.fn();
12
- vi.mock('../../approvals/advisory-prompt.js', () => ({
13
- promptAdvisory: mockPromptAdvisory,
14
- }));
15
- describe('handleAdvisoryApproval', () => {
16
- const baseRequest = {
17
- summary: 'Create ZCL_FOO',
18
- risk: 'low',
19
- transport: 'DEVK900123',
20
- changes: [
21
- { op: 'create', object: 'ZCL_FOO', type: 'CLAS', diff: 'CLASS...' },
22
- ],
23
- };
24
- beforeEach(() => {
25
- mockLoadConfig.mockReset();
26
- mockPromptAdvisory.mockReset();
27
- });
28
- it('does not mint approval_ids when write_mode=advisory-only (outcome=applied)', async () => {
29
- mockLoadConfig.mockResolvedValue({
30
- write_mode: 'advisory-only',
31
- sap: { DEV: { auto_approve: 'never' } },
32
- });
33
- mockPromptAdvisory.mockResolvedValue('applied');
34
- const { handleAdvisoryApproval } = await import('../approval.js');
35
- const result = await handleAdvisoryApproval(baseRequest, { sapAlias: 'DEV' });
36
- const payload = JSON.parse(result.content);
37
- expect(payload.approval_ids).toBeUndefined();
38
- expect(payload.advisory_outcome).toBe('applied');
39
- expect(String(payload.ai_directive).toLowerCase()).toContain('propos');
40
- expect(String(payload.ai_directive).toLowerCase()).toContain('do not attempt');
41
- });
42
- it('returns outcome=skipped when developer chooses skip', async () => {
43
- mockLoadConfig.mockResolvedValue({
44
- write_mode: 'advisory-only',
45
- sap: { DEV: { auto_approve: 'never' } },
46
- });
47
- mockPromptAdvisory.mockResolvedValue('skipped');
48
- const { handleAdvisoryApproval } = await import('../approval.js');
49
- const result = await handleAdvisoryApproval(baseRequest, { sapAlias: 'DEV' });
50
- const payload = JSON.parse(result.content);
51
- expect(payload.advisory_outcome).toBe('skipped');
52
- expect(String(payload.ai_directive).toLowerCase()).toContain('skipped');
53
- });
54
- it('returns outcome=aborted when developer chooses abort', async () => {
55
- mockLoadConfig.mockResolvedValue({
56
- write_mode: 'advisory-only',
57
- sap: { DEV: { auto_approve: 'never' } },
58
- });
59
- mockPromptAdvisory.mockResolvedValue('aborted');
60
- const { handleAdvisoryApproval } = await import('../approval.js');
61
- const result = await handleAdvisoryApproval(baseRequest, { sapAlias: 'DEV' });
62
- const payload = JSON.parse(result.content);
63
- expect(payload.advisory_outcome).toBe('aborted');
64
- expect(String(payload.ai_directive).toLowerCase()).toContain('abort');
65
- });
66
- });
@@ -1,113 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { backgroundRunHandler } from '../subagent/background_run.js';
6
- import { getBackground, consumeEvents, __resetRegistryForTests, } from '../subagent/_background-shared.js';
7
- import { makeCtx } from './_helpers.js';
8
- let root;
9
- const ctx = () => makeCtx(root);
10
- beforeEach(async () => {
11
- __resetRegistryForTests();
12
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-bg-'));
13
- });
14
- afterEach(async () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); });
15
- /** Wait for the entry to be marked closed. Polls; ceiling so the test fails clearly. */
16
- // If you see flakes on slow CI, bump timeoutMs ceiling — don't tighten the poll interval.
17
- async function waitForExit(bg_id, timeoutMs = 5000) {
18
- const deadline = Date.now() + timeoutMs;
19
- while (Date.now() < deadline) {
20
- const e = getBackground(bg_id);
21
- if (e?.closed)
22
- return;
23
- await new Promise((r) => setTimeout(r, 25));
24
- }
25
- throw new Error(`background ${bg_id} did not close within ${timeoutMs}ms`);
26
- }
27
- describe('background_run handler', () => {
28
- it('spawns a safelisted command and returns a bg_id immediately', async () => {
29
- const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.stdout.write("hello"); process.exit(0)'] }, ctx());
30
- expect(r.is_error).toBeFalsy();
31
- expect(r.content).toMatch(/bg_id:\s*bg-[a-f0-9-]+/);
32
- const m = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/);
33
- expect(m).not.toBeNull();
34
- expect(getBackground(m[1])).toBeDefined();
35
- });
36
- it('records stdout events captured from the child', async () => {
37
- const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.stdout.write("line1\\nline2\\n"); process.exit(0)'] }, ctx());
38
- const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
39
- await waitForExit(bg_id);
40
- const consumed = consumeEvents(bg_id, 100);
41
- expect(consumed).not.toBeNull();
42
- const stdoutTexts = consumed.events.filter((e) => e.kind === 'stdout').map((e) => e.text);
43
- expect(stdoutTexts.join('')).toMatch(/line1[\s\S]*line2/);
44
- expect(consumed.closed).toBe(true);
45
- expect(consumed.exitCode).toBe(0);
46
- });
47
- it('records stderr events separately from stdout', async () => {
48
- const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.stderr.write("oops\\n"); process.exit(1)'] }, ctx());
49
- const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
50
- await waitForExit(bg_id);
51
- const consumed = consumeEvents(bg_id, 100);
52
- const stderrTexts = consumed.events.filter((e) => e.kind === 'stderr').map((e) => e.text);
53
- expect(stderrTexts.join('')).toMatch(/oops/);
54
- expect(consumed.exitCode).toBe(1);
55
- });
56
- it('refuses a non-safelisted command', async () => {
57
- const r = await backgroundRunHandler({ command: 'curl', args: ['https://example.com'] }, ctx());
58
- expect(r.is_error).toBe(true);
59
- expect(r.content).toMatch(/safelist/i);
60
- });
61
- it('refuses an absolute path even if basename is safelisted', async () => {
62
- const abs = process.platform === 'win32' ? 'C:\\Windows\\System32\\cmd.exe' : '/bin/sh';
63
- const r = await backgroundRunHandler({ command: abs, args: [] }, ctx());
64
- expect(r.is_error).toBe(true);
65
- expect(r.content).toMatch(/separators|absolute/i);
66
- });
67
- it('rejects argv > MAX_ARGS', async () => {
68
- const tooMany = Array.from({ length: 1001 }, () => 'x');
69
- const r = await backgroundRunHandler({ command: 'node', args: tooMany }, ctx());
70
- expect(r.is_error).toBe(true);
71
- expect(r.content).toMatch(/too many args/i);
72
- });
73
- it('refuses cwd outside the project root', async () => {
74
- const outside = process.platform === 'win32' ? 'C:\\' : '/';
75
- const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.exit(0)'], cwd: outside }, ctx());
76
- expect(r.is_error).toBe(true);
77
- expect(r.content).toMatch(/outside|cwd/i);
78
- });
79
- it('does NOT leak CSPEACH_*/ANTHROPIC_*/SAP_* env vars to the child', async () => {
80
- process.env.CSPEACH_BG_TEST_LEAK = 'must-not-leak';
81
- process.env.ANTHROPIC_BG_TEST_LEAK = 'must-not-leak';
82
- process.env.FOO_BG_TEST_LEAK_CANARY = 'must-not-leak';
83
- try {
84
- const r = await backgroundRunHandler({
85
- command: 'node',
86
- args: [
87
- '-e',
88
- 'const k = ["CSPEACH_BG_TEST_LEAK","ANTHROPIC_BG_TEST_LEAK","FOO_BG_TEST_LEAK_CANARY"].filter(x=>process.env[x]); process.stdout.write(k.join(","))',
89
- ],
90
- }, ctx());
91
- const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
92
- await waitForExit(bg_id);
93
- const consumed = consumeEvents(bg_id, 100);
94
- const text = consumed.events.map((e) => e.text).join('');
95
- expect(text).not.toContain('must-not-leak');
96
- }
97
- finally {
98
- delete process.env.CSPEACH_BG_TEST_LEAK;
99
- delete process.env.ANTHROPIC_BG_TEST_LEAK;
100
- delete process.env.FOO_BG_TEST_LEAK_CANARY;
101
- }
102
- });
103
- it('records exit event with exitCode + signal', async () => {
104
- const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.exit(7)'] }, ctx());
105
- const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
106
- await waitForExit(bg_id);
107
- const consumed = consumeEvents(bg_id, 100);
108
- const exitEv = consumed.events.find((e) => e.kind === 'exit');
109
- expect(exitEv).toBeDefined();
110
- expect(exitEv.exitCode).toBe(7);
111
- expect(consumed.exitCode).toBe(7);
112
- });
113
- });
@@ -1,53 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import * as os from 'node:os';
3
- import * as path from 'node:path';
4
- import { promises as fs } from 'node:fs';
5
- import { conventionGetHandler } from '../project/convention_get.js';
6
- import { makeCtx } from './_helpers.js';
7
- import { __resetProjectContextCacheForTests } from '../../agent/maybe-build-project-context.js';
8
- let tmpRoot;
9
- beforeEach(async () => {
10
- tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-convention-get-'));
11
- __resetProjectContextCacheForTests();
12
- });
13
- afterEach(async () => {
14
- try {
15
- await fs.rm(tmpRoot, { recursive: true, force: true });
16
- }
17
- catch { /* ignore */ }
18
- __resetProjectContextCacheForTests();
19
- vi.restoreAllMocks();
20
- });
21
- describe('convention_get', () => {
22
- it("returns the full conventions object as JSON when field='all'", async () => {
23
- const r = await conventionGetHandler({ field: 'all' }, makeCtx(tmpRoot));
24
- expect(r.is_error).toBeFalsy();
25
- const parsed = JSON.parse(r.content);
26
- expect(parsed.style).toBeDefined();
27
- expect(parsed.namingPatterns).toBeDefined();
28
- expect(typeof parsed.style.indentSize).toBe('number');
29
- });
30
- it("returns the indent size as a number when field='indentSize'", async () => {
31
- const r = await conventionGetHandler({ field: 'indentSize' }, makeCtx(tmpRoot));
32
- expect(r.is_error).toBeFalsy();
33
- expect(r.content.trim()).toMatch(/^\d+$/);
34
- });
35
- it("returns the EOL style when field='eol'", async () => {
36
- const r = await conventionGetHandler({ field: 'eol' }, makeCtx(tmpRoot));
37
- expect(r.is_error).toBeFalsy();
38
- expect(['lf', 'crlf']).toContain(r.content.trim());
39
- });
40
- it("returns the namingPatterns object as JSON when field='namingPatterns'", async () => {
41
- const r = await conventionGetHandler({ field: 'namingPatterns' }, makeCtx(tmpRoot));
42
- expect(r.is_error).toBeFalsy();
43
- const parsed = JSON.parse(r.content);
44
- expect(parsed).toHaveProperty('classes');
45
- expect(parsed).toHaveProperty('interfaces');
46
- expect(parsed).toHaveProperty('cdsViews');
47
- });
48
- it('rejects an unknown field with a structured error', async () => {
49
- const r = await conventionGetHandler({ field: 'flavor' }, makeCtx(tmpRoot));
50
- expect(r.is_error).toBe(true);
51
- expect(r.content).toMatch(/unknown field|invalid field/i);
52
- });
53
- });
@@ -1,72 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { fileEditHandler } from '../filesystem/file-edit.js';
6
- import { makeCtx } from './_helpers.js';
7
- let root;
8
- const ctx = () => makeCtx(root);
9
- beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-file-edit-')); });
10
- afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
11
- describe('file_edit handler', () => {
12
- it('happy path — replaces a unique string and returns count', async () => {
13
- await fs.writeFile(path.join(root, 'hello.ts'), 'hello world\nsecond line\n', 'utf-8');
14
- const r = await fileEditHandler({ path: 'hello.ts', old_string: 'hello world', new_string: 'goodbye world' }, ctx());
15
- expect(r.is_error).toBeFalsy();
16
- expect(r.content).toMatch(/edited.*hello\.ts.*1 replacement/i);
17
- const updated = await fs.readFile(path.join(root, 'hello.ts'), 'utf-8');
18
- expect(updated).toContain('goodbye world');
19
- expect(updated).not.toContain('hello world');
20
- expect(updated).toContain('second line');
21
- });
22
- it('errors when old_string is not found in the file', async () => {
23
- await fs.writeFile(path.join(root, 'foo.ts'), 'line one\nline two\n', 'utf-8');
24
- const r = await fileEditHandler({ path: 'foo.ts', old_string: 'does not exist', new_string: 'replacement' }, ctx());
25
- expect(r.is_error).toBe(true);
26
- expect(r.content).toMatch(/not found/i);
27
- });
28
- it('errors when old_string matches multiple times without replace_all', async () => {
29
- await fs.writeFile(path.join(root, 'dup.ts'), 'foo bar\nfoo baz\n', 'utf-8');
30
- const r = await fileEditHandler({ path: 'dup.ts', old_string: 'foo', new_string: 'qux' }, ctx());
31
- expect(r.is_error).toBe(true);
32
- expect(r.content).toMatch(/matches 2 times/i);
33
- expect(r.content).toMatch(/replace_all/i);
34
- // file must be unchanged
35
- const unchanged = await fs.readFile(path.join(root, 'dup.ts'), 'utf-8');
36
- expect(unchanged).toContain('foo bar');
37
- });
38
- it('replace_all=true replaces all occurrences', async () => {
39
- await fs.writeFile(path.join(root, 'multi.ts'), 'foo bar\nfoo baz\nfoo qux\n', 'utf-8');
40
- const r = await fileEditHandler({ path: 'multi.ts', old_string: 'foo', new_string: 'boo', replace_all: true }, ctx());
41
- expect(r.is_error).toBeFalsy();
42
- expect(r.content).toMatch(/3 replacements/i);
43
- const updated = await fs.readFile(path.join(root, 'multi.ts'), 'utf-8');
44
- expect(updated).toBe('boo bar\nboo baz\nboo qux\n');
45
- });
46
- it('refuses path traversal (../foo)', async () => {
47
- const r = await fileEditHandler({ path: '../../etc/passwd', old_string: 'root', new_string: 'nobody' }, ctx());
48
- expect(r.is_error).toBe(true);
49
- expect(r.content).toMatch(/outside/i);
50
- });
51
- it('refuses denylisted path (.cspeach/auth.json)', async () => {
52
- await fs.mkdir(path.join(root, '.cspeach'), { recursive: true });
53
- await fs.writeFile(path.join(root, '.cspeach', 'auth.json'), '{"token":"secret"}', 'utf-8');
54
- const r = await fileEditHandler({ path: '.cspeach/auth.json', old_string: 'secret', new_string: 'redacted' }, ctx());
55
- expect(r.is_error).toBe(true);
56
- expect(r.content).toMatch(/sensitive path/i);
57
- // file must be unchanged
58
- const unchanged = await fs.readFile(path.join(root, '.cspeach', 'auth.json'), 'utf-8');
59
- expect(unchanged).toContain('secret');
60
- });
61
- it('errors when old_string === new_string (no-op edit refused)', async () => {
62
- await fs.writeFile(path.join(root, 'noop.ts'), 'some content\n', 'utf-8');
63
- const r = await fileEditHandler({ path: 'noop.ts', old_string: 'some content', new_string: 'some content' }, ctx());
64
- expect(r.is_error).toBe(true);
65
- expect(r.content).toMatch(/no-op|old_string.*new_string.*identical|same/i);
66
- });
67
- it('errors when the file does not exist', async () => {
68
- const r = await fileEditHandler({ path: 'nonexistent.ts', old_string: 'foo', new_string: 'bar' }, ctx());
69
- expect(r.is_error).toBe(true);
70
- expect(r.content).toMatch(/not found|ENOENT/i);
71
- });
72
- });