Skip to content

Commit 5054132

Browse files
committed
test: add regression guards for review fixes; harden restoreCache
FIX A (integration test — security pipeline ordering): Replace vacuous conversational-only content with a real Anthropic API key (matching /sk-ant-[a-zA-Z0-9_-]{30,}/) in the verbose log before a clean docker-agent-output block. Test now asserts secrets-detected=true and security-blocked=true, which ONLY passes under the correct order (filter→sanitize→extract). Also asserts outputFile retains the leaked key (Step 9b is skipped on leak detection). FIX B (auth.test.ts — Tier 3 fallthrough is distinguishable): Add 'falls through to author_association when org check throws (FIX 3)'. Uses OWNER association so old hard-deny code would return denied but new fallthrough code reaches Tier 4 and returns authorized=true, outcome='author-association'. This test fails if the FIX 3 catch block is reverted to return { authorized: false }. FIX C (outputs.test.ts — blank-line inTool exit): Add 'drops blank line that terminates an inTool block (matches awk next)'. Checks that the line immediately before 'Clean output' is not blank, which fails if the continue is reverted to fall-through. FIX D (exec.test.ts — SIGTERM kill path): Add 'kills process with SIGTERM when timeout fires'. Uses a 50ms action timeout with a child that would naturally exit in 5s. Updated makeMockChild so kill() emits close via setImmediate (simulates OS killing the process), allowing the timer path to complete within the 2s test timeout. Asserts child.kill was called with 'SIGTERM'. FIX E (binary.ts — restoreCache non-fatal): Wrap both restoreCache calls (docker-agent and mcp-gateway) in try/catch with core.warning + fallthrough to download, matching the existing saveCache non-fatal pattern. A network error in restoreCache no longer fails the action. Assisted-By: docker-agent
1 parent d488aad commit 5054132

5 files changed

Lines changed: 99 additions & 21 deletions

File tree

src/main/__tests__/auth.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,27 @@ describe('Tier 3: org membership', () => {
233233
expect(result.authorized).toBe(false);
234234
expect(result.outcome).toBe('denied');
235235
});
236+
237+
it('falls through to author_association when org check throws (FIX 3)', async () => {
238+
// Old code: exception → hard-deny. New code: exception → warn + fall through to Tier 4.
239+
// Using OWNER association so Tier 4 authorizes — this distinguishes the two behaviors.
240+
await writePayload({
241+
comment: { author_association: 'OWNER', user: { login: 'repo-owner' } },
242+
});
243+
mockGetAuthenticated.mockResolvedValue({ data: { login: 'bot' } });
244+
mockCheckOrgMembership.mockRejectedValue(new Error('Network timeout'));
245+
246+
const result = await checkAuthorization({
247+
...BASE_OPTS,
248+
skipAuth: false,
249+
orgMembershipToken: 'org-token',
250+
authOrg: 'my-org',
251+
eventPayloadPath,
252+
});
253+
// New behavior: falls through to Tier 4 → OWNER is authorized
254+
expect(result.authorized).toBe(true);
255+
expect(result.outcome).toBe('author-association');
256+
});
236257
});
237258

238259
// ── Tier 4: author_association fallback ──────────────────────────────────────

src/main/__tests__/exec.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,13 @@ function makeMockChild(exitCode: number, delayMs = 0) {
4040
kill: ReturnType<typeof vi.fn>;
4141
};
4242
emitter.stdin = { write: vi.fn(), end: vi.fn() };
43-
emitter.kill = vi.fn();
4443

44+
// When killed (SIGTERM/SIGKILL), emit close shortly after — simulates real process dying.
45+
emitter.kill = vi.fn().mockImplementation(() => {
46+
setImmediate(() => emitter.emit('close', null));
47+
});
48+
49+
// Natural exit after delayMs (ignored if kill fires first)
4550
setTimeout(() => emitter.emit('close', exitCode), delayMs);
4651

4752
return emitter;
@@ -251,6 +256,18 @@ describe('runAgent', () => {
251256
expect(mockSpawn).toHaveBeenCalledOnce();
252257
});
253258

259+
it('kills process with SIGTERM when timeout fires (FIX D)', async () => {
260+
// Child exits naturally in 5 s; action timeout is 50 ms.
261+
// The real timer path fires SIGTERM, then the child is killed.
262+
const child = makeMockChild(0, 5000);
263+
mockSpawn.mockReturnValue(child);
264+
265+
const result = await runAgent(baseOpts({ timeout: 0.05, maxRetries: 0 }));
266+
267+
expect(result.exitCode).toBe(TIMEOUT_EXIT_CODE);
268+
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
269+
}, 2000);
270+
254271
it('retries on non-zero exit code up to maxRetries times', async () => {
255272
// Each spawn call must have its own close event
256273
mockSpawn

src/main/__tests__/main.integration.test.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -446,13 +446,22 @@ describe('agent exit code propagation', () => {
446446

447447
// ── Wiring — sanitizeOutput runs before block extraction ─────────────────────
448448

449+
// ── Wiring — sanitizeOutput runs before block extraction ─────────────────────
450+
449451
describe('security pipeline ordering (FIX 1)', () => {
450452
it('sanitizeOutput scans full filtered output before block extraction narrows it', async () => {
453+
// This test MUST fail if FIX 1 is reverted (sanitize-after-extract order).
454+
// Strategy: verbose log contains a real Anthropic API key (matching
455+
// /sk-ant-[a-zA-Z0-9_-]{30,}/) in conversational text BEFORE a clean
456+
// docker-agent-output block. Under the correct order (filter → sanitize →
457+
// extract), sanitizeOutput sees the key → secrets-detected=true. Under the
458+
// wrong order (filter → extract → sanitize) the outputFile contains only the
459+
// clean block and the key is never scanned.
460+
const LEAKED_KEY =
461+
'sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRR';
462+
451463
setupInputs({ prompt: 'Please review this PR' });
452464

453-
// Capture the paths that run() will assign to output/verbose files.
454-
// setOutput('verbose-log-file', ...) is called BEFORE runAgent(), so
455-
// capturedVerboseLog is populated before mockSpawn is invoked.
456465
let capturedVerboseLog: string | undefined;
457466
let capturedOutputFile: string | undefined;
458467

@@ -461,13 +470,21 @@ describe('security pipeline ordering (FIX 1)', () => {
461470
if (name === 'output-file') capturedOutputFile = value;
462471
});
463472

464-
// Write verbose log content SYNCHRONOUSLY inside the spawn mock so it is
465-
// present when run() reads the file immediately after spawn completes.
473+
// Write verbose log content SYNCHRONOUSLY so it is present when run() reads
474+
// the file immediately after spawn completes.
466475
mockSpawn.mockImplementation(() => {
467476
if (capturedVerboseLog) {
468477
fsSync.appendFileSync(
469478
capturedVerboseLog,
470-
'--- Agent: root ---\nConversational text.\n```docker-agent-output\n## Result\n\nClean output.\n```\n',
479+
[
480+
'Here is my analysis.',
481+
`Oops I leaked: ${LEAKED_KEY}`,
482+
'```docker-agent-output',
483+
'## Result',
484+
'',
485+
'Clean output with no secrets.',
486+
'```',
487+
].join('\n'),
471488
'utf-8',
472489
);
473490
}
@@ -476,19 +493,16 @@ describe('security pipeline ordering (FIX 1)', () => {
476493

477494
await run();
478495

479-
// outputFile should contain the block-extracted clean content
496+
// The key must have been detected BEFORE block extraction narrowed the file.
497+
const outputCalls = Object.fromEntries(mockSetOutput.mock.calls.map(([n, v]) => [n, v]));
498+
expect(outputCalls['secrets-detected']).toBe('true');
499+
expect(outputCalls['security-blocked']).toBe('true');
500+
501+
// When a leak is detected, Step 9b is skipped — outputFile retains full
502+
// filtered text so the incident path can see the leaked key.
480503
if (capturedOutputFile && fsSync.existsSync(capturedOutputFile)) {
481-
const outputContent = fsSync.readFileSync(capturedOutputFile, 'utf-8');
482-
// Block extraction should have run: output is the docker-agent-output block
483-
expect(outputContent.trim()).toBe('## Result\n\nClean output.');
484-
} else {
485-
// If output file wasn\'t created, ensure run() completed without failure
486-
expect(mockSetFailed).not.toHaveBeenCalled();
504+
const content = fsSync.readFileSync(capturedOutputFile, 'utf-8');
505+
expect(content).toContain(LEAKED_KEY);
487506
}
488-
489-
// Security outputs are correct (no leak in test content)
490-
const outputCalls = Object.fromEntries(mockSetOutput.mock.calls.map(([n, v]) => [n, v]));
491-
expect(outputCalls['secrets-detected']).toBe('false');
492-
expect(outputCalls['security-blocked']).toBe('false');
493507
});
494508
});

src/main/__tests__/outputs.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ describe('filterAgentOutput', () => {
8181
expect(result).toContain('Clean output');
8282
});
8383

84+
it('drops blank line that terminates an inTool block (matches awk `next`)', () => {
85+
// FIX 4: the blank line that closes an inTool block must be dropped (continue),
86+
// not re-emitted. Reverting to fall-through would emit an extra blank line.
87+
const input = ['--- Tool: bash ---', 'some tool output', '', 'Clean output'].join('\n');
88+
const result = filterAgentOutput(input);
89+
const lines = result.split('\n');
90+
const cleanIdx = lines.findIndex((l) => l === 'Clean output');
91+
expect(cleanIdx).toBeGreaterThan(-1);
92+
// The line immediately before 'Clean output' must not be blank
93+
expect(lines[cleanIdx - 1]).not.toBe('');
94+
});
95+
8496
it('strips Calling <fn>( … ) blocks', () => {
8597
const input =
8698
'Calling read_multiple_files(\n paths: ["pr.diff"]\n)\n\n## Summary\n\nThis PR adds a greeting.\n';

src/main/binary.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@ async function ensureDockerAgent(version: string, githubToken?: string): Promise
9393
// ── 2. Remote @actions/cache hit (cross-run persistence) ───────────────
9494
const tmpBinDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'docker-agent-'));
9595
const cacheKey = `docker-agent-${toolName}-${version}-${platform}-${arch}`;
96-
const restoredKey = await actionsCache.restoreCache([tmpBinDir], cacheKey);
96+
let restoredKey: string | undefined;
97+
try {
98+
restoredKey = await actionsCache.restoreCache([tmpBinDir], cacheKey);
99+
} catch (err: unknown) {
100+
core.warning(
101+
`Remote cache restore failed (${(err as Error).message}); falling back to download`,
102+
);
103+
}
97104

98105
if (restoredKey) {
99106
core.info(`Restored docker-agent ${version} from remote cache (key: ${restoredKey})`);
@@ -161,7 +168,14 @@ async function ensureMcpGateway(version: string, githubToken?: string): Promise<
161168
// ── 2. Remote @actions/cache hit ───────────────────────────────────────
162169
const tmpPluginDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'docker-mcp-'));
163170
const cacheKey = `docker-agent-${toolName}-${version}-${platform}-${arch}`;
164-
const restoredKey = await actionsCache.restoreCache([tmpPluginDir], cacheKey);
171+
let restoredKey: string | undefined;
172+
try {
173+
restoredKey = await actionsCache.restoreCache([tmpPluginDir], cacheKey);
174+
} catch (err: unknown) {
175+
core.warning(
176+
`Remote cache restore failed (${(err as Error).message}); falling back to download`,
177+
);
178+
}
165179

166180
if (restoredKey) {
167181
core.info(`Restored mcp-gateway ${version} from remote cache (key: ${restoredKey})`);

0 commit comments

Comments
 (0)