@cspeach/cli 0.6.4 → 0.7.0

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 (153) hide show
  1. package/dist/agent/intent-system-prompt.js +46 -0
  2. package/dist/agent/loop.js +180 -18
  3. package/dist/agent/parallel-write-guard.js +71 -0
  4. package/dist/agent/repair-partial.js +88 -1
  5. package/dist/agent/retry-cap.js +121 -0
  6. package/dist/agent/summarise-via-provider.js +51 -0
  7. package/dist/approvals/jwt.js +33 -12
  8. package/dist/commands/auto-compact.js +94 -0
  9. package/dist/commands/compact.js +265 -0
  10. package/dist/commands/cost.js +56 -0
  11. package/dist/commands/help.js +21 -14
  12. package/dist/config/loader.js +39 -0
  13. package/dist/cost/cost-log.js +113 -0
  14. package/dist/cost/pricing.js +91 -0
  15. package/dist/index.js +0 -0
  16. package/dist/one-shot.js +1 -1
  17. package/dist/renderer/markdown.js +7 -1
  18. package/dist/renderer/status-footer.js +37 -14
  19. package/dist/renderer/thinking-heartbeat.js +15 -3
  20. package/dist/renderer/tool-widget.js +6 -1
  21. package/dist/renderer/tty.js +27 -0
  22. package/dist/repl/cspeach-shell-detect.js +33 -0
  23. package/dist/repl/post-turn-status.js +68 -0
  24. package/dist/repl/slash-completer.js +2 -0
  25. package/dist/repl/slash-picker.js +2 -0
  26. package/dist/repl.js +371 -46
  27. package/dist/sap-errors/parse-adt-exception.js +149 -0
  28. package/dist/session/schema.js +2 -1
  29. package/dist/session/store.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +9 -1
  31. package/dist/tools/ask-question.js +19 -0
  32. package/dist/tools/sap-read.js +56 -4
  33. package/dist/tools/sap-write.js +19 -0
  34. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  35. package/dist/ui/alt-screen.js +120 -0
  36. package/dist/ui/app.js +39 -15
  37. package/dist/ui/ask-question-emitter.js +13 -0
  38. package/dist/ui/body.js +39 -23
  39. package/dist/ui/coaching-picker-classic.js +8 -1
  40. package/dist/ui/footer.js +6 -1
  41. package/dist/ui/header.js +19 -4
  42. package/dist/ui/sap-state-store.js +13 -1
  43. package/dist/ui/sidebar.js +5 -3
  44. package/dist/ui/status-emitter.js +19 -0
  45. package/dist/ui/status-row.js +30 -4
  46. package/dist/ui/widgets/ask-question-modal.js +132 -0
  47. package/dist/ui/widgets/coaching-picker.js +55 -8
  48. package/package.json +86 -82
  49. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  50. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  51. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  52. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  53. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  54. package/dist/agent/__tests__/retry-key.test.js +0 -77
  55. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  56. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  57. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  58. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  59. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  60. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  61. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  62. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  63. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  64. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  65. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  66. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  67. package/dist/auth/__tests__/auth-file.test.js +0 -42
  68. package/dist/auth/__tests__/me.test.js +0 -43
  69. package/dist/classifier/__tests__/client.test.js +0 -70
  70. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  71. package/dist/commands/__tests__/login.test.js +0 -152
  72. package/dist/commands/__tests__/logout.test.js +0 -39
  73. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  74. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  75. package/dist/commands/__tests__/whoami.test.js +0 -91
  76. package/dist/config/__tests__/llm-config.test.js +0 -28
  77. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  78. package/dist/config/__tests__/write-mode.test.js +0 -36
  79. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  80. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  81. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  82. package/dist/project-context/__tests__/conventions.test.js +0 -221
  83. package/dist/project-context/__tests__/detect.test.js +0 -177
  84. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  85. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  86. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  87. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  88. package/dist/project-context/__tests__/git.test.js +0 -131
  89. package/dist/project-context/__tests__/index-files.test.js +0 -195
  90. package/dist/project-context/__tests__/index.test.js +0 -133
  91. package/dist/project-context/__tests__/render.test.js +0 -322
  92. package/dist/project-context/__tests__/types.test.js +0 -73
  93. package/dist/projects/__tests__/build.test.js +0 -52
  94. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  95. package/dist/projects/__tests__/email-template.test.js +0 -71
  96. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  97. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  98. package/dist/projects/__tests__/extract-design.test.js +0 -62
  99. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  100. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  101. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  102. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  103. package/dist/projects/__tests__/filename.test.js +0 -32
  104. package/dist/projects/__tests__/promote-command.test.js +0 -170
  105. package/dist/projects/__tests__/promote.test.js +0 -181
  106. package/dist/projects/__tests__/save-command.test.js +0 -216
  107. package/dist/projects/__tests__/save.test.js +0 -41
  108. package/dist/projects/__tests__/status.test.js +0 -208
  109. package/dist/projects/__tests__/types.test.js +0 -41
  110. package/dist/projects/__tests__/validate.test.js +0 -165
  111. package/dist/projects/__tests__/workspace.test.js +0 -337
  112. package/dist/repl/__tests__/current-transport.test.js +0 -43
  113. package/dist/repl/__tests__/diff-display.test.js +0 -59
  114. package/dist/repl/__tests__/file-picker.test.js +0 -97
  115. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  116. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  117. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  118. package/dist/sap/__tests__/system-info.test.js +0 -344
  119. package/dist/session/__tests__/store-discovery.test.js +0 -114
  120. package/dist/session/__tests__/time-ago.test.js +0 -53
  121. package/dist/skills/__tests__/canonical.test.js +0 -20
  122. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  123. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  124. package/dist/skills/__tests__/source-managed.test.js +0 -60
  125. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  126. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  127. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  128. package/dist/tools/__tests__/_flag.test.js +0 -66
  129. package/dist/tools/__tests__/_helpers.js +0 -17
  130. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  131. package/dist/tools/__tests__/agent_run.test.js +0 -166
  132. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  133. package/dist/tools/__tests__/background_run.test.js +0 -113
  134. package/dist/tools/__tests__/convention_get.test.js +0 -53
  135. package/dist/tools/__tests__/file-edit.test.js +0 -72
  136. package/dist/tools/__tests__/file-read.test.js +0 -81
  137. package/dist/tools/__tests__/file-write.test.js +0 -68
  138. package/dist/tools/__tests__/glob.test.js +0 -128
  139. package/dist/tools/__tests__/grep.test.js +0 -71
  140. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  141. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  142. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  143. package/dist/tools/__tests__/registry-category.test.js +0 -60
  144. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  145. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  146. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  147. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  148. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  149. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  150. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  151. package/dist/tools/__tests__/web_search.test.js +0 -215
  152. package/dist/tools/__tests__/write-mode.test.js +0 -32
  153. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -1,329 +0,0 @@
1
- // cspeach-cli/src/tools/__tests__/web_fetch.test.ts
2
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
- import { EventEmitter } from 'node:events';
4
- /**
5
- * `node:https`'s exported `request` function is a non-configurable own
6
- * property of the module namespace (Object.getOwnPropertyDescriptor
7
- * shows `configurable: false`), so `vi.spyOn(https, 'request')` throws
8
- * "Cannot redefine property". We use `vi.mock('node:https', ...)` —
9
- * hoisted by vitest before module resolution — so the implementation's
10
- * `import * as https from 'node:https'` resolves to a controllable
11
- * mock instead. Each test resets `mockRequest`'s impl via the
12
- * `setupHttpsMock(responder)` helper.
13
- */
14
- const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() }));
15
- vi.mock('node:https', () => ({
16
- default: { request: mockRequest },
17
- request: mockRequest,
18
- }));
19
- import { webFetchHandler, USER_AGENT } from '../web/web_fetch.js';
20
- import { makeCtx } from './_helpers.js';
21
- function setupHttpsMock(responder) {
22
- const recorded = [];
23
- let callIndex = 0;
24
- mockRequest.mockImplementation((...rawArgs) => {
25
- // https.request can be called as (opts, cb) OR (url, opts, cb) OR (url, cb).
26
- let opts;
27
- let cb;
28
- if (rawArgs.length === 1) {
29
- opts = rawArgs[0];
30
- }
31
- else if (rawArgs.length === 2) {
32
- if (typeof rawArgs[1] === 'function') {
33
- opts = rawArgs[0];
34
- cb = rawArgs[1];
35
- }
36
- else {
37
- opts = { ...rawArgs[0], ...rawArgs[1] };
38
- }
39
- }
40
- else {
41
- opts = { ...rawArgs[0], ...rawArgs[1] };
42
- cb = rawArgs[2];
43
- }
44
- const spec = responder(opts, callIndex);
45
- callIndex++;
46
- recorded.push({ options: opts, spec });
47
- const req = new EventEmitter();
48
- req.setTimeout = (ms, fn) => {
49
- if (spec.kind === 'timeout' && fn)
50
- setTimeout(fn, 0);
51
- };
52
- req.destroy = (err) => { req.emit('close'); if (err)
53
- req.emit('error', err); };
54
- req.end = () => {
55
- if (spec.kind === 'response' && cb) {
56
- const res = new EventEmitter();
57
- res.statusCode = spec.statusCode;
58
- res.headers = spec.headers;
59
- // Real Node streams' destroy() emits 'close' but NOT 'end'.
60
- // The mock must mirror that contract: once destroy() has been
61
- // called, no further 'data' or 'end' events fire. Otherwise the
62
- // mock papers over production code that relies on 'end' firing
63
- // after destroy() — which it doesn't, in real Node.
64
- let destroyed = false;
65
- res.destroy = () => { destroyed = true; };
66
- setImmediate(() => {
67
- cb(res);
68
- setImmediate(() => {
69
- if (spec.bodyChunks) {
70
- for (const chunk of spec.bodyChunks) {
71
- if (destroyed)
72
- break;
73
- res.emit('data', chunk);
74
- }
75
- }
76
- else {
77
- if (!destroyed)
78
- res.emit('data', Buffer.isBuffer(spec.body) ? spec.body : Buffer.from(spec.body, 'utf-8'));
79
- }
80
- if (!destroyed)
81
- res.emit('end');
82
- });
83
- });
84
- }
85
- else if (spec.kind === 'error') {
86
- setImmediate(() => req.emit('error', spec.error));
87
- }
88
- // 'timeout' kind: setTimeout fired via req.setTimeout above
89
- };
90
- return req;
91
- });
92
- return { recorded };
93
- }
94
- const fakeDeps = (addresses) => ({
95
- resolveAll: async () => addresses,
96
- });
97
- const ctx = () => makeCtx(process.cwd());
98
- beforeEach(() => {
99
- delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
100
- mockRequest.mockReset();
101
- });
102
- afterEach(() => {
103
- delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
104
- mockRequest.mockReset();
105
- });
106
- describe('web_fetch — happy path', () => {
107
- it('fetches a public HTTPS URL and returns its body + status + headers', async () => {
108
- setupHttpsMock(() => ({
109
- kind: 'response',
110
- statusCode: 200,
111
- headers: { 'content-type': 'text/plain; charset=utf-8' },
112
- body: 'hello world',
113
- }));
114
- const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
115
- expect(r.is_error).toBeFalsy();
116
- expect(r.content).toContain('status: 200');
117
- expect(r.content).toContain('hello world');
118
- });
119
- it('sends our User-Agent header on every request', async () => {
120
- const { recorded } = setupHttpsMock(() => ({
121
- kind: 'response',
122
- statusCode: 200,
123
- headers: { 'content-type': 'text/plain' },
124
- body: 'ok',
125
- }));
126
- await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
127
- const sentUa = recorded[0].options.headers?.['User-Agent'] ?? recorded[0].options.headers?.['user-agent'];
128
- expect(sentUa).toBe(USER_AGENT);
129
- expect(USER_AGENT).toMatch(/^cspeach\/\d+\.\d+ \(\+https:\/\/cspeach\.dev\)$/);
130
- });
131
- });
132
- describe('web_fetch — URL / protocol / IP guards', () => {
133
- it('refuses http:// (HTTPS-only)', async () => {
134
- const r = await webFetchHandler({ url: 'http://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
135
- expect(r.is_error).toBe(true);
136
- expect(r.content).toMatch(/protocol|https/i);
137
- });
138
- it('refuses literal localhost hostname', async () => {
139
- const r = await webFetchHandler({ url: 'https://localhost/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
140
- expect(r.is_error).toBe(true);
141
- expect(r.content).toMatch(/blocked|localhost/i);
142
- });
143
- it('refuses a 10.x.x.x literal IP', async () => {
144
- const r = await webFetchHandler({ url: 'https://10.0.0.1/' }, ctx(), { _deps: fakeDeps([]) });
145
- expect(r.is_error).toBe(true);
146
- expect(r.content).toMatch(/private|reserved/i);
147
- });
148
- it('refuses a public hostname that resolves to 169.254.169.254 (cloud metadata)', async () => {
149
- const r = await webFetchHandler({ url: 'https://attacker.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '169.254.169.254', family: 4 }]) });
150
- expect(r.is_error).toBe(true);
151
- expect(r.content).toMatch(/private|metadata/i);
152
- });
153
- it('refuses a public hostname that resolves to 127.0.0.1 (DNS-rebinding)', async () => {
154
- const r = await webFetchHandler({ url: 'https://innocent.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '127.0.0.1', family: 4 }]) });
155
- expect(r.is_error).toBe(true);
156
- expect(r.content).toMatch(/private|loopback/i);
157
- });
158
- it('allows a private IP when CSPEACH_WEB_ALLOW_PRIVATE=on', async () => {
159
- process.env.CSPEACH_WEB_ALLOW_PRIVATE = 'on';
160
- setupHttpsMock(() => ({
161
- kind: 'response',
162
- statusCode: 200,
163
- headers: { 'content-type': 'text/plain' },
164
- body: 'dev server',
165
- }));
166
- const r = await webFetchHandler({ url: 'https://dev.local.test/' }, ctx(), { _deps: fakeDeps([{ address: '127.0.0.1', family: 4 }]) });
167
- expect(r.is_error).toBeFalsy();
168
- expect(r.content).toContain('dev server');
169
- });
170
- it('passes a pinnedLookup that returns the validated address, not a fresh DNS query', async () => {
171
- const { recorded } = setupHttpsMock(() => ({
172
- kind: 'response',
173
- statusCode: 200,
174
- headers: { 'content-type': 'text/plain' },
175
- body: 'ok',
176
- }));
177
- await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
178
- const lookup = recorded[0].options.lookup;
179
- expect(typeof lookup).toBe('function');
180
- const result = await new Promise((resolve, reject) => {
181
- lookup('example.com', {}, (err, address, family) => {
182
- if (err)
183
- reject(err);
184
- else
185
- resolve({ addr: address, fam: family });
186
- });
187
- });
188
- expect(result.addr).toBe('93.184.216.34');
189
- expect(result.fam).toBe(4);
190
- });
191
- });
192
- describe('web_fetch — redirects', () => {
193
- it('follows a single 302 redirect to a different public host', async () => {
194
- let call = 0;
195
- setupHttpsMock((opts) => {
196
- call++;
197
- if (call === 1) {
198
- return {
199
- kind: 'response',
200
- statusCode: 302,
201
- headers: { 'content-type': 'text/plain', location: 'https://target.example.com/final' },
202
- body: '',
203
- };
204
- }
205
- return {
206
- kind: 'response',
207
- statusCode: 200,
208
- headers: { 'content-type': 'text/plain' },
209
- body: 'final page',
210
- };
211
- });
212
- const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
213
- expect(r.is_error).toBeFalsy();
214
- expect(r.content).toContain('final page');
215
- expect(r.content).toMatch(/redirects: 1/i);
216
- });
217
- it('refuses a redirect that points at a private IP', async () => {
218
- let call = 0;
219
- setupHttpsMock(() => {
220
- call++;
221
- if (call === 1) {
222
- return {
223
- kind: 'response',
224
- statusCode: 302,
225
- headers: { 'content-type': 'text/plain', location: 'https://10.0.0.1/admin' },
226
- body: '',
227
- };
228
- }
229
- return { kind: 'response', statusCode: 200, headers: { 'content-type': 'text/plain' }, body: 'should never reach' };
230
- });
231
- const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
232
- expect(r.is_error).toBe(true);
233
- expect(r.content).toMatch(/private|redirect/i);
234
- expect(call).toBe(1); // we never made the second request
235
- });
236
- it('refuses a redirect that downgrades to http://', async () => {
237
- setupHttpsMock(() => ({
238
- kind: 'response',
239
- statusCode: 302,
240
- headers: { 'content-type': 'text/plain', location: 'http://example.com/insecure' },
241
- body: '',
242
- }));
243
- const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
244
- expect(r.is_error).toBe(true);
245
- expect(r.content).toMatch(/protocol|https/i);
246
- });
247
- it('aborts after 5 hops with a structured error', async () => {
248
- let call = 0;
249
- setupHttpsMock(() => {
250
- call++;
251
- return {
252
- kind: 'response',
253
- statusCode: 302,
254
- headers: { 'content-type': 'text/plain', location: `https://hop${call}.example.com/` },
255
- body: '',
256
- };
257
- });
258
- const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
259
- expect(r.is_error).toBe(true);
260
- expect(r.content).toMatch(/redirect.*limit|too many redirects/i);
261
- // 1 initial + 5 redirect hops attempted, then refused — exact-count
262
- // is the invariant; loosening to <=6 would mask a regression that
263
- // aborts at hop 3.
264
- expect(call).toBe(6);
265
- });
266
- });
267
- describe('web_fetch — response handling', () => {
268
- it('refuses application/octet-stream and other non-allowlisted content-types', async () => {
269
- setupHttpsMock(() => ({
270
- kind: 'response',
271
- statusCode: 200,
272
- headers: { 'content-type': 'application/octet-stream' },
273
- body: Buffer.from([0x00, 0x01, 0x02]),
274
- }));
275
- const r = await webFetchHandler({ url: 'https://example.com/data.bin' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
276
- expect(r.is_error).toBe(true);
277
- expect(r.content).toMatch(/content.?type|allowed/i);
278
- });
279
- it('accepts text/html, application/json, application/xml', async () => {
280
- for (const ct of ['text/html; charset=utf-8', 'application/json', 'application/xml']) {
281
- setupHttpsMock(() => ({ kind: 'response', statusCode: 200, headers: { 'content-type': ct }, body: 'ok' }));
282
- const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
283
- expect(r.is_error).toBeFalsy();
284
- }
285
- });
286
- it('truncates a response that exceeds 1 MB and appends a marker', async () => {
287
- const giant = 'A'.repeat(2_000_000);
288
- setupHttpsMock(() => ({
289
- kind: 'response',
290
- statusCode: 200,
291
- headers: { 'content-type': 'text/plain' },
292
- bodyChunks: [Buffer.from(giant.slice(0, 500_000)), Buffer.from(giant.slice(500_000, 1_000_000)), Buffer.from(giant.slice(1_000_000))],
293
- body: '',
294
- }));
295
- const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
296
- expect(r.is_error).toBeFalsy();
297
- expect(r.content).toMatch(/\[truncated/i);
298
- expect(r.content.length).toBeLessThan(1_200_000);
299
- });
300
- it('reports a 10s timeout as a structured error', async () => {
301
- setupHttpsMock(() => ({ kind: 'timeout' }));
302
- const r = await webFetchHandler({ url: 'https://slow.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]), _timeoutMs: 50 });
303
- expect(r.is_error).toBe(true);
304
- expect(r.content).toMatch(/timeout|timed out/i);
305
- });
306
- it('rejects responses with Content-Encoding other than identity', async () => {
307
- setupHttpsMock(() => ({
308
- kind: 'response',
309
- statusCode: 200,
310
- headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' },
311
- body: Buffer.from([0x1f, 0x8b, 0x08]), // gzip magic header, just to be realistic
312
- }));
313
- const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
314
- expect(r.is_error).toBe(true);
315
- expect(r.content).toMatch(/content.?encoding|gzip|identity/i);
316
- });
317
- it('surfaces non-2xx status + body WITHOUT marking is_error (the agent sees the 404)', async () => {
318
- setupHttpsMock(() => ({
319
- kind: 'response',
320
- statusCode: 404,
321
- headers: { 'content-type': 'text/html' },
322
- body: '<h1>Not Found</h1>',
323
- }));
324
- const r = await webFetchHandler({ url: 'https://example.com/missing' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
325
- expect(r.is_error).toBeFalsy();
326
- expect(r.content).toMatch(/status:\s*404/);
327
- expect(r.content).toContain('Not Found');
328
- });
329
- });
@@ -1,215 +0,0 @@
1
- // cspeach-cli/src/tools/__tests__/web_search.test.ts
2
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
- import { EventEmitter } from 'node:events';
4
- import { webSearchHandler } from '../web/web_search.js';
5
- import { makeCtx } from './_helpers.js';
6
- const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() }));
7
- vi.mock('node:https', () => ({
8
- default: { request: mockRequest },
9
- request: mockRequest,
10
- }));
11
- function setupHttpsMock(responder) {
12
- const recorded = [];
13
- let callIndex = 0;
14
- mockRequest.mockImplementation((...rawArgs) => {
15
- let opts;
16
- let cb;
17
- if (rawArgs.length === 1)
18
- opts = rawArgs[0];
19
- else if (rawArgs.length === 2) {
20
- if (typeof rawArgs[1] === 'function') {
21
- opts = rawArgs[0];
22
- cb = rawArgs[1];
23
- }
24
- else {
25
- opts = { ...rawArgs[0], ...rawArgs[1] };
26
- }
27
- }
28
- else {
29
- opts = { ...rawArgs[0], ...rawArgs[1] };
30
- cb = rawArgs[2];
31
- }
32
- const spec = responder(opts, callIndex);
33
- callIndex++;
34
- recorded.push({ options: opts, spec });
35
- const req = new EventEmitter();
36
- let destroyed = false;
37
- req.setTimeout = (_ms, fn) => {
38
- if (spec.kind === 'timeout' && fn)
39
- setTimeout(fn, 0);
40
- };
41
- req.destroy = () => { destroyed = true; req.emit('close'); };
42
- req.end = () => {
43
- if (spec.kind === 'response' && cb) {
44
- const res = new EventEmitter();
45
- res.statusCode = spec.statusCode;
46
- res.headers = spec.headers;
47
- let resDestroyed = false;
48
- res.destroy = () => { resDestroyed = true; };
49
- setImmediate(() => {
50
- cb(res);
51
- setImmediate(() => {
52
- if (spec.bodyChunks) {
53
- for (const chunk of spec.bodyChunks) {
54
- if (resDestroyed)
55
- break;
56
- res.emit('data', chunk);
57
- }
58
- }
59
- else {
60
- if (!resDestroyed) {
61
- res.emit('data', Buffer.isBuffer(spec.body) ? spec.body : Buffer.from(spec.body, 'utf-8'));
62
- }
63
- }
64
- if (!resDestroyed)
65
- res.emit('end');
66
- });
67
- });
68
- }
69
- else if (spec.kind === 'error') {
70
- setImmediate(() => req.emit('error', spec.error));
71
- }
72
- };
73
- return req;
74
- });
75
- return { recorded };
76
- }
77
- const fakeDeps = (addresses) => ({
78
- resolveAll: async () => addresses,
79
- });
80
- const ctx = () => makeCtx(process.cwd());
81
- const VALID_API_KEY = 'BSA-test-key-1234567890';
82
- let origKey;
83
- beforeEach(() => {
84
- origKey = process.env.CSPEACH_BRAVE_SEARCH_KEY;
85
- process.env.CSPEACH_BRAVE_SEARCH_KEY = VALID_API_KEY;
86
- vi.clearAllMocks();
87
- });
88
- afterEach(() => {
89
- if (origKey === undefined)
90
- delete process.env.CSPEACH_BRAVE_SEARCH_KEY;
91
- else
92
- process.env.CSPEACH_BRAVE_SEARCH_KEY = origKey;
93
- vi.clearAllMocks();
94
- });
95
- describe('web_search — happy path', () => {
96
- it('issues a request to BRAVE_ENDPOINT with API key header and parses results', async () => {
97
- const fakeResponse = {
98
- web: {
99
- results: [
100
- { title: 'SAP RAP Tutorial', url: 'https://help.sap.com/rap', description: 'Restful Application Programming' },
101
- { title: 'CDS Views', url: 'https://help.sap.com/cds', description: 'Core Data Services overview' },
102
- ],
103
- },
104
- };
105
- const { recorded } = setupHttpsMock(() => ({
106
- kind: 'response',
107
- statusCode: 200,
108
- headers: { 'content-type': 'application/json' },
109
- body: JSON.stringify(fakeResponse),
110
- }));
111
- const r = await webSearchHandler({ query: 'sap rap tutorial' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
112
- expect(r.is_error).toBeFalsy();
113
- expect(r.content).toContain('SAP RAP Tutorial');
114
- expect(r.content).toContain('https://help.sap.com/rap');
115
- expect(r.content).toContain('Restful Application Programming');
116
- expect(r.content).toContain('CDS Views');
117
- expect(recorded[0].options.headers?.['X-Subscription-Token']).toBe(VALID_API_KEY);
118
- expect(recorded[0].options.hostname).toBe('api.search.brave.com');
119
- });
120
- it('respects count parameter and caps it at 20', async () => {
121
- const results = Array.from({ length: 25 }, (_, i) => ({
122
- title: `Result ${i}`,
123
- url: `https://example.com/${i}`,
124
- description: `Description ${i}`,
125
- }));
126
- const { recorded } = setupHttpsMock(() => ({
127
- kind: 'response',
128
- statusCode: 200,
129
- headers: { 'content-type': 'application/json' },
130
- body: JSON.stringify({ web: { results } }),
131
- }));
132
- await webSearchHandler({ query: 'test', count: 99 }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
133
- // The URL the handler builds for Brave must include count=20 (clamped)
134
- const sentPath = recorded[0].options.path;
135
- expect(sentPath).toMatch(/count=20/);
136
- });
137
- it('returns empty-results envelope cleanly when Brave returns 0 results', async () => {
138
- setupHttpsMock(() => ({
139
- kind: 'response',
140
- statusCode: 200,
141
- headers: { 'content-type': 'application/json' },
142
- body: JSON.stringify({ web: { results: [] } }),
143
- }));
144
- const r = await webSearchHandler({ query: 'asdfqwerzxcv-zero-results-expected' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
145
- expect(r.is_error).toBeFalsy();
146
- expect(r.content).toMatch(/0 results|no results/i);
147
- });
148
- });
149
- describe('web_search — input validation + API key handling', () => {
150
- it('returns a structured error when CSPEACH_BRAVE_SEARCH_KEY is unset', async () => {
151
- delete process.env.CSPEACH_BRAVE_SEARCH_KEY;
152
- const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
153
- expect(r.is_error).toBe(true);
154
- expect(r.content).toMatch(/CSPEACH_BRAVE_SEARCH_KEY/);
155
- expect(r.content).not.toContain(VALID_API_KEY); // sanity — no leak when key IS set but elsewhere referenced
156
- });
157
- it('does NOT include the API key value in any error envelope', async () => {
158
- // Embed the key in the 401 body so the scrub is what makes the assertion
159
- // pass. Without scrub the key would land in the returned content and
160
- // the not.toContain would fail. Simulates a misbehaving proxy that
161
- // echoes the X-Subscription-Token header into its error response body.
162
- setupHttpsMock(() => ({
163
- kind: 'response',
164
- statusCode: 401,
165
- headers: { 'content-type': 'application/json' },
166
- body: JSON.stringify({ error: 'invalid api key', debug: VALID_API_KEY }),
167
- }));
168
- const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
169
- expect(r.is_error).toBe(true);
170
- expect(r.content).not.toContain(VALID_API_KEY);
171
- // Positive assertion that the scrub fired — the masked marker MUST appear
172
- // in the returned content (since the mock body contained the key).
173
- expect(r.content).toContain('***');
174
- });
175
- it('rejects empty query with structured error', async () => {
176
- const r = await webSearchHandler({ query: '' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
177
- expect(r.is_error).toBe(true);
178
- expect(r.content).toMatch(/query.*required|empty/i);
179
- });
180
- it('rejects whitespace-only query', async () => {
181
- const r = await webSearchHandler({ query: ' \t\n ' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
182
- expect(r.is_error).toBe(true);
183
- expect(r.content).toMatch(/query.*required|empty/i);
184
- });
185
- });
186
- describe('web_search — error handling', () => {
187
- it('surfaces a 429 rate-limit response as structured error', async () => {
188
- setupHttpsMock(() => ({
189
- kind: 'response',
190
- statusCode: 429,
191
- headers: { 'content-type': 'application/json' },
192
- body: JSON.stringify({ error: 'rate limit exceeded' }),
193
- }));
194
- const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
195
- expect(r.is_error).toBe(true);
196
- // The handler emits `status 429` (no colon); match the actual format.
197
- expect(r.content).toMatch(/status 429|rate limit/i);
198
- });
199
- it('returns structured error for malformed JSON response', async () => {
200
- setupHttpsMock(() => ({
201
- kind: 'response',
202
- statusCode: 200,
203
- headers: { 'content-type': 'application/json' },
204
- body: '{ not valid json',
205
- }));
206
- const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
207
- expect(r.is_error).toBe(true);
208
- expect(r.content).toMatch(/json|parse|malformed/i);
209
- });
210
- it('rejects when api.search.brave.com resolves to a private IP (DNS-hijack defense)', async () => {
211
- const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '127.0.0.1', family: 4 }]) });
212
- expect(r.is_error).toBe(true);
213
- expect(r.content).toMatch(/blocked|private|loopback/i);
214
- });
215
- });
@@ -1,32 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { isWriteTool, WRITE_TOOLS } from '../write-mode.js';
3
- describe('isWriteTool', () => {
4
- it('returns true for mutating SAP tools', () => {
5
- expect(isWriteTool('sap_create_object')).toBe(true);
6
- expect(isWriteTool('sap_set_source')).toBe(true);
7
- expect(isWriteTool('sap_update_method')).toBe(true);
8
- expect(isWriteTool('sap_delete_object')).toBe(true);
9
- expect(isWriteTool('sap_activate')).toBe(true);
10
- expect(isWriteTool('sap_message_maintain')).toBe(true);
11
- expect(isWriteTool('sap_service_binding_publish')).toBe(true);
12
- expect(isWriteTool('sap_transport_release')).toBe(true);
13
- });
14
- it('returns false for read-only SAP tools', () => {
15
- expect(isWriteTool('sap_get_source')).toBe(false);
16
- expect(isWriteTool('sap_search_object')).toBe(false);
17
- expect(isWriteTool('sap_atc_run')).toBe(false);
18
- expect(isWriteTool('sap_syntax_check')).toBe(false);
19
- });
20
- it('returns false for control tools (lock/unlock/snapshot)', () => {
21
- expect(isWriteTool('sap_lock')).toBe(false);
22
- expect(isWriteTool('sap_unlock')).toBe(false);
23
- expect(isWriteTool('sap_snapshot')).toBe(false);
24
- });
25
- it('returns false for unknown tool names', () => {
26
- expect(isWriteTool('unknown_tool')).toBe(false);
27
- expect(isWriteTool('')).toBe(false);
28
- });
29
- it('WRITE_TOOLS is non-empty and contains the canonical eight', () => {
30
- expect(WRITE_TOOLS.size).toBe(8);
31
- });
32
- });
@@ -1,69 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { formatLoginBanner } from '../login-banner.js';
3
- const base = {
4
- email: 'a@b.com', company: null, plan: 'solo',
5
- trial_mode: null, trial_ends_at: null,
6
- trial_token_cap_input: null, trial_token_cap_output: null,
7
- usage_input_tokens: 0, usage_output_tokens: 0,
8
- entitlements: { enabled_skills: [], agent_mode: 'skill', enabled_features: [] },
9
- };
10
- describe('formatLoginBanner', () => {
11
- it('non-trial: just shows email + plan', () => {
12
- const { line, warningLine } = formatLoginBanner({ ...base, plan: 'solo' });
13
- expect(line).toMatch(/Signed in as a@b\.com/);
14
- expect(line).toMatch(/solo/);
15
- expect(warningLine).toBeNull();
16
- });
17
- it('managed-capped trial: shows days left + token progress', () => {
18
- // +1 h buffer so floor division stays at 6 even if a few ms elapse between
19
- // test setup and banner render.
20
- const ends = new Date(Date.now() + 6 * 86400_000 + 3600_000).toISOString();
21
- const { line } = formatLoginBanner({
22
- ...base, plan: 'trial-managed', trial_mode: 'managed-capped',
23
- trial_ends_at: ends, trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
24
- usage_input_tokens: 47_000, usage_output_tokens: 8_000,
25
- });
26
- expect(line).toMatch(/trial/i);
27
- expect(line).toMatch(/6\s*days?/i);
28
- expect(line).toMatch(/47/);
29
- expect(line).toMatch(/200/);
30
- });
31
- it('byok trial: omits token budget (customer pays Anthropic directly)', () => {
32
- const ends = new Date(Date.now() + 6 * 86400_000).toISOString();
33
- const { line } = formatLoginBanner({
34
- ...base, plan: 'trial-byok', trial_mode: 'byok',
35
- trial_ends_at: ends,
36
- });
37
- expect(line).toMatch(/byok/i);
38
- expect(line).toMatch(/6\s*days?/i);
39
- expect(line).not.toMatch(/\/200/);
40
- });
41
- it('warning fires when token usage ≥ 80% of input cap', () => {
42
- const ends = new Date(Date.now() + 6 * 86400_000).toISOString();
43
- const { warningLine } = formatLoginBanner({
44
- ...base, plan: 'trial-managed', trial_mode: 'managed-capped',
45
- trial_ends_at: ends, trial_token_cap_input: 100_000, trial_token_cap_output: 25_000,
46
- usage_input_tokens: 82_000, usage_output_tokens: 5_000,
47
- });
48
- expect(warningLine).not.toBeNull();
49
- expect(warningLine).toMatch(/80%|budget|cap/i);
50
- });
51
- it('warning fires when trial expiry < 24 hours away', () => {
52
- const ends = new Date(Date.now() + 12 * 3600_000).toISOString();
53
- const { warningLine } = formatLoginBanner({
54
- ...base, plan: 'trial-managed', trial_mode: 'managed-capped',
55
- trial_ends_at: ends, trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
56
- });
57
- expect(warningLine).not.toBeNull();
58
- expect(warningLine).toMatch(/expires|24 hours|hours? left/i);
59
- });
60
- it('expired trial: warning says expired', () => {
61
- const ends = new Date(Date.now() - 86400_000).toISOString();
62
- const { warningLine } = formatLoginBanner({
63
- ...base, plan: 'trial-managed', trial_mode: 'managed-capped',
64
- trial_ends_at: ends, trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
65
- });
66
- expect(warningLine).not.toBeNull();
67
- expect(warningLine).toMatch(/expired/i);
68
- });
69
- });