@cspeach/cli 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -108
- package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
- package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
- package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
- package/dist/agent/__tests__/project-context-injection.test.js +161 -0
- package/dist/agent/__tests__/repair-partial.test.js +79 -0
- package/dist/agent/__tests__/retry-key.test.js +77 -0
- package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
- package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
- package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
- package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
- package/dist/agent/__tests__/turn-stream.test.js +100 -0
- package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
- package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
- package/dist/agent/providers/__tests__/factory.test.js +33 -0
- package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
- package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
- package/dist/approvals/__tests__/advisory-render.test.js +54 -0
- package/dist/auth/__tests__/auth-file.test.js +42 -0
- package/dist/auth/__tests__/me.test.js +43 -0
- package/dist/classifier/__tests__/client.test.js +70 -0
- package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
- package/dist/commands/__tests__/login.test.js +152 -0
- package/dist/commands/__tests__/logout.test.js +39 -0
- package/dist/commands/__tests__/project-context-impact.test.js +173 -0
- package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
- package/dist/commands/__tests__/whoami.test.js +91 -0
- package/dist/config/__tests__/llm-config.test.js +28 -0
- package/dist/config/__tests__/shell-exec-config.test.js +76 -0
- package/dist/config/__tests__/write-mode.test.js +36 -0
- package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
- package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
- package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
- package/dist/index.js +0 -0
- package/dist/project-context/__tests__/conventions.test.js +221 -0
- package/dist/project-context/__tests__/detect.test.js +177 -0
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
- package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
- package/dist/project-context/__tests__/domain-cap.test.js +168 -0
- package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
- package/dist/project-context/__tests__/git.test.js +131 -0
- package/dist/project-context/__tests__/index-files.test.js +195 -0
- package/dist/project-context/__tests__/index.test.js +133 -0
- package/dist/project-context/__tests__/render.test.js +322 -0
- package/dist/project-context/__tests__/types.test.js +73 -0
- package/dist/projects/__tests__/build.test.js +52 -0
- package/dist/projects/__tests__/canonicalize.test.js +47 -0
- package/dist/projects/__tests__/email-template.test.js +71 -0
- package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
- package/dist/projects/__tests__/extract-cca.test.js +141 -0
- package/dist/projects/__tests__/extract-design.test.js +62 -0
- package/dist/projects/__tests__/extract-estimate.test.js +58 -0
- package/dist/projects/__tests__/extract-modernize.test.js +127 -0
- package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
- package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
- package/dist/projects/__tests__/filename.test.js +32 -0
- package/dist/projects/__tests__/promote-command.test.js +170 -0
- package/dist/projects/__tests__/promote.test.js +181 -0
- package/dist/projects/__tests__/save-command.test.js +216 -0
- package/dist/projects/__tests__/save.test.js +41 -0
- package/dist/projects/__tests__/status.test.js +208 -0
- package/dist/projects/__tests__/types.test.js +41 -0
- package/dist/projects/__tests__/validate.test.js +165 -0
- package/dist/projects/__tests__/workspace.test.js +337 -0
- package/dist/repl/__tests__/current-transport.test.js +43 -0
- package/dist/repl/__tests__/diff-display.test.js +59 -0
- package/dist/repl/__tests__/file-picker.test.js +97 -0
- package/dist/repl/__tests__/rule8-detector.test.js +114 -0
- package/dist/repl/__tests__/safety-confirm.test.js +130 -0
- package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
- package/dist/sap/__tests__/system-info.test.js +344 -0
- package/dist/session/__tests__/store-discovery.test.js +114 -0
- package/dist/session/__tests__/time-ago.test.js +53 -0
- package/dist/skills/__tests__/canonical.test.js +20 -0
- package/dist/skills/__tests__/manifest-client.test.js +88 -0
- package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
- package/dist/skills/__tests__/source-managed.test.js +60 -0
- package/dist/tools/__tests__/_background-shared.test.js +69 -0
- package/dist/tools/__tests__/_command-shared.test.js +59 -0
- package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
- package/dist/tools/__tests__/_flag.test.js +66 -0
- package/dist/tools/__tests__/_helpers.js +17 -0
- package/dist/tools/__tests__/_web-shared.test.js +143 -0
- package/dist/tools/__tests__/agent_run.test.js +166 -0
- package/dist/tools/__tests__/approval-advisory.test.js +66 -0
- package/dist/tools/__tests__/background_run.test.js +113 -0
- package/dist/tools/__tests__/convention_get.test.js +53 -0
- package/dist/tools/__tests__/file-edit.test.js +72 -0
- package/dist/tools/__tests__/file-read.test.js +81 -0
- package/dist/tools/__tests__/file-write.test.js +68 -0
- package/dist/tools/__tests__/glob.test.js +128 -0
- package/dist/tools/__tests__/grep.test.js +71 -0
- package/dist/tools/__tests__/monitor_emit.test.js +96 -0
- package/dist/tools/__tests__/playbook_get.test.js +109 -0
- package/dist/tools/__tests__/project_context_get.test.js +71 -0
- package/dist/tools/__tests__/registry-category.test.js +60 -0
- package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
- package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
- package/dist/tools/__tests__/schedule_create.test.js +85 -0
- package/dist/tools/__tests__/shell_exec.test.js +135 -0
- package/dist/tools/__tests__/transport-safety.test.js +106 -0
- package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
- package/dist/tools/__tests__/web_fetch.test.js +329 -0
- package/dist/tools/__tests__/web_search.test.js +215 -0
- package/dist/tools/__tests__/write-mode.test.js +32 -0
- package/dist/ui/__tests__/login-banner.test.js +69 -0
- package/package.json +82 -83
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
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 { fileReadHandler } from '../filesystem/file-read.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-read-')); });
|
|
10
|
+
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
|
|
11
|
+
describe('file_read handler', () => {
|
|
12
|
+
it('reads a file from the project and returns content with line numbers', async () => {
|
|
13
|
+
await fs.writeFile(path.join(root, 'foo.ts'), 'line one\nline two\nline three\n', 'utf-8');
|
|
14
|
+
const r = await fileReadHandler({ path: 'foo.ts' }, ctx());
|
|
15
|
+
expect(r.is_error).toBeFalsy();
|
|
16
|
+
expect(r.content).toContain('1\tline one');
|
|
17
|
+
expect(r.content).toContain('2\tline two');
|
|
18
|
+
expect(r.content).toContain('3\tline three');
|
|
19
|
+
});
|
|
20
|
+
it('refuses paths outside the project root', async () => {
|
|
21
|
+
const r = await fileReadHandler({ path: '../../etc/passwd' }, ctx());
|
|
22
|
+
expect(r.is_error).toBe(true);
|
|
23
|
+
expect(r.content).toMatch(/outside/i);
|
|
24
|
+
});
|
|
25
|
+
it('returns an error envelope when the file does not exist', async () => {
|
|
26
|
+
const r = await fileReadHandler({ path: 'nope.ts' }, ctx());
|
|
27
|
+
expect(r.is_error).toBe(true);
|
|
28
|
+
expect(r.content).toMatch(/not found|ENOENT/i);
|
|
29
|
+
});
|
|
30
|
+
it('truncates at 2000 lines and indicates truncation', async () => {
|
|
31
|
+
const lines = Array.from({ length: 3000 }, (_, i) => `line ${i + 1}`).join('\n');
|
|
32
|
+
await fs.writeFile(path.join(root, 'big.ts'), lines, 'utf-8');
|
|
33
|
+
const r = await fileReadHandler({ path: 'big.ts' }, ctx());
|
|
34
|
+
expect(r.content).toContain('1\tline 1');
|
|
35
|
+
expect(r.content).toContain('2000\tline 2000');
|
|
36
|
+
expect(r.content).not.toContain('2001\tline 2001');
|
|
37
|
+
expect(r.content).toMatch(/truncated.*1000.*more/i);
|
|
38
|
+
});
|
|
39
|
+
it('respects optional offset + limit args (read a window)', async () => {
|
|
40
|
+
const lines = Array.from({ length: 100 }, (_, i) => `L${i + 1}`).join('\n');
|
|
41
|
+
await fs.writeFile(path.join(root, 'mid.ts'), lines, 'utf-8');
|
|
42
|
+
const r = await fileReadHandler({ path: 'mid.ts', offset: 50, limit: 5 }, ctx());
|
|
43
|
+
expect(r.content).toContain('51\tL51');
|
|
44
|
+
expect(r.content).toContain('55\tL55');
|
|
45
|
+
expect(r.content).not.toContain('56\tL56');
|
|
46
|
+
});
|
|
47
|
+
it('rejects negative offset', async () => {
|
|
48
|
+
await fs.writeFile(path.join(root, 'foo.ts'), 'line\n', 'utf-8');
|
|
49
|
+
const r = await fileReadHandler({ path: 'foo.ts', offset: -1 }, ctx());
|
|
50
|
+
expect(r.is_error).toBe(true);
|
|
51
|
+
expect(r.content).toMatch(/offset.*non-negative/i);
|
|
52
|
+
});
|
|
53
|
+
it('rejects negative limit', async () => {
|
|
54
|
+
await fs.writeFile(path.join(root, 'foo.ts'), 'line\n', 'utf-8');
|
|
55
|
+
const r = await fileReadHandler({ path: 'foo.ts', limit: -5 }, ctx());
|
|
56
|
+
expect(r.is_error).toBe(true);
|
|
57
|
+
expect(r.content).toMatch(/limit.*positive/i);
|
|
58
|
+
});
|
|
59
|
+
it('refuses to read .cspeach/* (sensitive denylist)', async () => {
|
|
60
|
+
await fs.mkdir(path.join(root, '.cspeach'), { recursive: true });
|
|
61
|
+
await fs.writeFile(path.join(root, '.cspeach', 'auth.json'), '{"password":"secret"}', 'utf-8');
|
|
62
|
+
const r = await fileReadHandler({ path: '.cspeach/auth.json' }, ctx());
|
|
63
|
+
expect(r.is_error).toBe(true);
|
|
64
|
+
expect(r.content).toMatch(/sensitive path/i);
|
|
65
|
+
expect(r.content).not.toContain('secret'); // must not leak
|
|
66
|
+
});
|
|
67
|
+
it('refuses to read .env (denylist)', async () => {
|
|
68
|
+
await fs.writeFile(path.join(root, '.env'), 'SECRET=value', 'utf-8');
|
|
69
|
+
const r = await fileReadHandler({ path: '.env' }, ctx());
|
|
70
|
+
expect(r.is_error).toBe(true);
|
|
71
|
+
expect(r.content).toMatch(/sensitive path/i);
|
|
72
|
+
});
|
|
73
|
+
it('does NOT mis-number a trailing blank line on files ending in newline', async () => {
|
|
74
|
+
// "a\nb\n" split naively produces ['a', 'b', ''] — line 3 should NOT appear.
|
|
75
|
+
await fs.writeFile(path.join(root, 'trailer.ts'), 'a\nb\n', 'utf-8');
|
|
76
|
+
const r = await fileReadHandler({ path: 'trailer.ts' }, ctx());
|
|
77
|
+
expect(r.content).toContain('1\ta');
|
|
78
|
+
expect(r.content).toContain('2\tb');
|
|
79
|
+
expect(r.content).not.toContain('3\t');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
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 { fileWriteHandler } from '../filesystem/file-write.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-write-')); });
|
|
10
|
+
afterEach(async () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); });
|
|
11
|
+
describe('file_write handler', () => {
|
|
12
|
+
it('creates a new file with the given content', async () => {
|
|
13
|
+
const r = await fileWriteHandler({ path: 'newfile.ts', content: 'hello world\n' }, ctx());
|
|
14
|
+
expect(r.is_error).toBeFalsy();
|
|
15
|
+
expect(r.content).toMatch(/wrote.*newfile\.ts/i);
|
|
16
|
+
const written = await fs.readFile(path.join(root, 'newfile.ts'), 'utf-8');
|
|
17
|
+
expect(written).toBe('hello world\n');
|
|
18
|
+
});
|
|
19
|
+
it('overwrites an existing file', async () => {
|
|
20
|
+
await fs.writeFile(path.join(root, 'existing.ts'), 'old content\n', 'utf-8');
|
|
21
|
+
const r = await fileWriteHandler({ path: 'existing.ts', content: 'new content\n' }, ctx());
|
|
22
|
+
expect(r.is_error).toBeFalsy();
|
|
23
|
+
expect(r.content).toMatch(/wrote.*existing\.ts/i);
|
|
24
|
+
const written = await fs.readFile(path.join(root, 'existing.ts'), 'utf-8');
|
|
25
|
+
expect(written).toBe('new content\n');
|
|
26
|
+
});
|
|
27
|
+
it('creates parent directories that do not exist yet', async () => {
|
|
28
|
+
const r = await fileWriteHandler({ path: 'deep/nested/dir/file.ts', content: 'content\n' }, ctx());
|
|
29
|
+
expect(r.is_error).toBeFalsy();
|
|
30
|
+
expect(r.content).toMatch(/wrote.*deep\/nested\/dir\/file\.ts/i);
|
|
31
|
+
const written = await fs.readFile(path.join(root, 'deep', 'nested', 'dir', 'file.ts'), 'utf-8');
|
|
32
|
+
expect(written).toBe('content\n');
|
|
33
|
+
});
|
|
34
|
+
it('refuses path traversal (../foo)', async () => {
|
|
35
|
+
const r = await fileWriteHandler({ path: '../../etc/evil.txt', content: 'bad' }, ctx());
|
|
36
|
+
expect(r.is_error).toBe(true);
|
|
37
|
+
expect(r.content).toMatch(/outside/i);
|
|
38
|
+
});
|
|
39
|
+
it('refuses denylisted path (.cspeach/auth.json)', async () => {
|
|
40
|
+
await fs.mkdir(path.join(root, '.cspeach'), { recursive: true });
|
|
41
|
+
const r = await fileWriteHandler({ path: '.cspeach/auth.json', content: '{"token":"hacked"}' }, ctx());
|
|
42
|
+
expect(r.is_error).toBe(true);
|
|
43
|
+
expect(r.content).toMatch(/sensitive path/i);
|
|
44
|
+
// file must not have been created/modified with the new content
|
|
45
|
+
const exists = await fs.access(path.join(root, '.cspeach', 'auth.json')).then(() => true, () => false);
|
|
46
|
+
expect(exists).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
it('refuses denylisted path (.git/config)', async () => {
|
|
49
|
+
await fs.mkdir(path.join(root, '.git'), { recursive: true });
|
|
50
|
+
await fs.writeFile(path.join(root, '.git', 'config'), '[core]\n', 'utf-8');
|
|
51
|
+
const r = await fileWriteHandler({ path: '.git/config', content: 'tampered\n' }, ctx());
|
|
52
|
+
expect(r.is_error).toBe(true);
|
|
53
|
+
expect(r.content).toMatch(/sensitive path/i);
|
|
54
|
+
// original file must be unchanged
|
|
55
|
+
const content = await fs.readFile(path.join(root, '.git', 'config'), 'utf-8');
|
|
56
|
+
expect(content).toBe('[core]\n');
|
|
57
|
+
});
|
|
58
|
+
it('atomic write — no leftover .tmp file on success', async () => {
|
|
59
|
+
const r = await fileWriteHandler({ path: 'atomic.ts', content: 'data\n' }, ctx());
|
|
60
|
+
expect(r.is_error).toBeFalsy();
|
|
61
|
+
// target file exists
|
|
62
|
+
const written = await fs.readFile(path.join(root, 'atomic.ts'), 'utf-8');
|
|
63
|
+
expect(written).toBe('data\n');
|
|
64
|
+
// no leftover .tmp
|
|
65
|
+
const tmpExists = await fs.access(path.join(root, 'atomic.ts.tmp')).then(() => true, () => false);
|
|
66
|
+
expect(tmpExists).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|