mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(core): headless hook trust requires the folder-trust feature The reporter PoC runs headless (--output-format stream-json with a one-shot prompt), so the unconditional headless exemption left the reported execution path open on a fresh default environment. Require the operator to have enabled security.folderTrust.enabled before the workspace choice counts as implicit trust; with the feature off, project hooks are withheld in every mode. Embedders enable the feature in their managed home settings and keep working via their trust store. Verified locally against the reporter scenario: default fresh home -> marker absent; feature on + trust-store grant -> marker written. Refs #14746 Co-authored-by: Qwen-Coder <[email protected]> * refactor(core): unify the project-hook gate on explicit trust The headless exemption branch never changed an observable outcome: the CLI settings layer already drops workspace settings for an undecided folder once the feature is enabled, and with the feature off the explicit predicate is false anyway. Replace both branches with the single explicit-trust rule so interactive and non-interactive callers share one contract. Behavior is unchanged; verified against the reporter PoC (default fresh home withheld, trusted path executes). Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): address review findings on the unified hook trust gate R1-1: the headless feature-off test wrote folderTrust into workspace settings, where WORKSPACE_RESTRICTED_SETTINGS strips it — the rig user scope stayed enabled and the test never reached the named gate. Write the feature-off decision into the user-scope home file instead. R1-2: the docs sentence overclaimed; IDE-granted trust loads hooks with the feature off. State the predicate as shipped. R1-3: headless withholding was silent for the newly gated population. Emit one stderr line from the non-interactive entry and a debug-log warn from the registry when the trust gate is why the scope is absent (getTrustWithheldProjectHooks, diagnostics only). Verified: reporter PoC withheld with the stderr line present; core 1052 passed; tool-hook integration 14 passed. Co-authored-by: Qwen-Coder <[email protected]> * feat(web-shell): surface trust-withheld hooks in the workspace overview The hooks listing already marks why a project hook is disabled, but the ACP status serializer dropped the reason, so the web-shell overview only showed a count while trust-withheld hooks silently did not run. Carry disabledReason through ServeHookEntry/DaemonHookEntry (optional, older daemons unaffected), count untrusted hooks in the summary, flag the facet, and name the remedy in the tooltip (en + zh). Co-authored-by: Qwen-Coder <[email protected]> * test(web-shell): pin the trust-withheld hooks tooltip wording Component-level witness for the untrusted-hooks facet: the row shows the withheld count and the title names the remedy. The sidebar hooks facet is opt-in for embedding hosts, so the jsdom suite is the reachability-independent pin. Co-authored-by: Qwen-Coder <[email protected]> * test(cli): align fixtures with the surfaced hook state The workspace-hooks status test now expects the disabledReason the serializer carries, and the headless-warnings config stub provides the new getTrustWithheldProjectHooks accessor. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): tolerate partial config stubs in the headless hook notice The resumed-transcript and stream-json housekeeping tests use minimal config stubs; optional-chain the two accessors the notice reads, and provide the accessor on the remaining isInteractive stubs. All 124 llm.test.tsx cases pass. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): make the withheld-hooks surfaces reachable and equivalent Review findings on the status surfaces: the listing never carried disabledReason untrusted for settings project hooks (the trust gate keeps them out of the registry, and only agent/session rows were trustGated), so the web-shell hint was unreachable; buildHooksListing now appends the diagnostics block as disabled (untrusted) rows, with a real-listing test. The headless stderr notice now mirrors the TUI predicate (raw workspace hooks non-empty, exclude disableAllHooks and safe/bare), covering the feature-on undecided case the Config accessor cannot see. The facet value stays language-neutral; the localized withheld count and remedy live in the tooltip. Core 1035, llm 124, web-shell sidebar 51 tests pass. Co-authored-by: Qwen-Coder <[email protected]> * test(web-shell): browser witness for the withheld-hooks status surface Real Chromium e2e: open Daemon Status via the rail More popover, switch to Diagnostics, and assert the workspace.hooks section renders with the withheld count from a report shaped like the real daemon output. Screenshot retained as test output. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): fold the deferred approval follow-ups into this PR Per review direction, land the #16295 items here instead of a follow-up: - Explicit projectHooksApproved state: a stored approval keeps execution authority inside a command-line --yolo session (previously the trust gate withheld it because the pending flag is undefined while exempt). The flag never doubles as nothing-to-approve; mid-session decisions update it. Tests pin yolo+approved executes, yolo+unapproved withholds. - Display-only raw workspace block: with the feature on and the folder untrusted the settings layer drops the workspace scope, so the listing could not show the withheld hooks. projectHooksDisplayOnly feeds only getTrustWithheldProjectHooks, never the registry; test pins both halves. - Variable-source wording: the dialog hint now says environment (project .env/settings.env or shell), not only the approved project env. Per-hook approval granularity stays out: it is a feature iteration, not a correctness fix. Core 1017+1053, cli config 565, approvals 50, llm 127 pass. Co-authored-by: Qwen-Coder <[email protected]> * style: prettier the reworded dialog hint Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): /hooks list shows withheld project hooks in ACP/web-shell The non-interactive /hooks list read only the registry, so an ACP session (web-shell) with unapproved project hooks answered \"No hooks configured\" — the user could not see what was withheld or why. The list now includes the approval/trust diagnostics blocks, marked disabled (not approved / rejected / folder not trusted). Verified against a real ACP session: the withheld SessionStart hook is listed with its reason. Tests pin all three reasons. Co-authored-by: Qwen-Coder <[email protected]> * feat(web-shell): explicit project-hooks approval prompt for withheld hooks Web Shell previously had no interactive surface for the project-hooks approval gate: users had to run /hooks manually. The daemon now reports the pending decision (with sanitized hook lines) on /workspace/hooks, including on a cold daemon and when folder trust withholds the workspace scope, and a dedicated POST /workspace/hooks/approve route (plus the workspace-qualified twin) records the same hash-bound decision the TUI dialog writes. The Web Shell renders an Approve / Keep-disabled banner from that state. This is deliberately not session/request_permission: automation clients auto-answer permission prompts, a trust decision must be an explicit user act, and approving never grants folder trust. Co-authored-by: Qwen-Coder <[email protected]> * refactor(web-shell): render hooks approval with the shared ToolApproval card Replace the custom full-width alert banner with the existing ToolApproval card in the standard approval slot above the composer, so the hooks trust decision looks and behaves like every other approval: same panel, option list, keyboard handling, and safe-default focus. The card supplies a project_hooks request with decision-specific option labels; ToolApproval localizes its heading and question for that tool name. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): hooks approval probe reads once and polls only while pending The approval card polled /workspace/hooks unconditionally, adding background metadata reads the workspace-overview quiet-period contract forbids. Read once per mount/workspace/settings change, poll only while a decision is pending, and let the E2E quiet-period test settle the initial probe before counting sidebar traffic. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): apply the persisted decision once and to every live session Local review + ponytail over the PR diff: - The ACP child no longer re-derives and re-writes the approval hash on the approve control. The daemon persists the decision for the exact block the user reviewed; a second write could bind it to a block that changed since the review. The child only applies the decision, to every live session config, not just the bootstrap one. - The raw-workspace review subject hashes the project env files regardless of current folder trust, so a decision recorded while untrusted still matches after the folder is trusted. - The headless trust warning no longer doubles with the approval-gate warning for a pending block; the registry debug-log duplicate and its interface member are removed (the stderr line and listings already report the withheld state). - Drop dead test stubs for an accessor llm.tsx never reads, a redundant type member, and a stray blank-line deletion. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): scope the approve decision to the admission-bound workspace The per-session fan-out matched every live config in the child. Key it on the admission-bound workspace root (the same root the settings handlers use) against the request's cwd instead: session/cd moves the live target dir, and a decision recorded for one workspace must not clear or set the gate on a session admitted under another. Regression test covers same-workspace, cd-moved, and foreign-workspace configs. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): bind the project-hooks approval to the reviewed block The daemon's approve route re-read .qwen/settings.json at click time and persisted the hash of that fresh read, while the ACP child applied the decision to the block it rendered at startup. A file change inside that window (git pull, another session, the workspace's own agent) recorded an approval for hooks the user never saw, and left the card pending because the stored hash matched neither side. The child now persists against the same read that produced the status cell it rendered, so the reviewed block and the recorded block are the same object. The daemon only falls back to its own read when no child answered, which is the cold-daemon case where that read is what the client saw. Also unbreaks the web-shell lane: App.test.tsx's daemon-react-sdk mock had no useOptionalWorkspace export, so every App render threw and 1320 tests failed; and the workspace-overview facet counts no longer include the per-mount /hooks probe, which broke the exact facet-set assertion. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw2kfno0dl * fix(cli): explicit folder trust alone runs project hooks The startup gate demanded a stored approval even when the folder was explicitly trusted, contradicting the documented either-or authority: core's readProjectHooks accepts approval OR explicit trust, but the pending flag short-circuited the accessor before trust was consulted. A platform that pre-trusts its managed directories (folderTrust enabled plus a TRUST_FOLDER/TRUST_PARENT entry) saw every project hook withheld headless. Clear the pending gate for a trusted folder unless the exact block was explicitly rejected — a recorded 'no' still wins over trust. Verified headless on a real fixture: fresh home withholds; trust config alone fires the hook; a rejected block stays withheld in the same trusted folder. Gate unit tests cover pending/rejected/approved under explicit trust. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): bind the approval decision to the rendered block hash end to end The status cell now publishes pendingApprovalHash, the Web Shell card echoes it with the decision, and both the daemon route and the ACP child verify it: the daemon rejects a decision whose hash names a block other than the current file (409 project_hooks_changed, nothing persisted), and the child applies a hash-bound decision only to configs holding that exact startup subject (projectHooksApprovalSubject), leaving any config with a different block pending. Closes the cold-daemon re-read window: an approval can no longer bind commands the user never saw. Verified on a real daemon: render hash -> edit the hook file -> POST with the rendered hash returns 409 and persists nothing; re-render -> approve with the fresh hash persists and enables the hook; a real browser session then fires it. Card tests 5/5 (including hash echo), approve-control tests 2/2, gate tests 20/20, build + bundle clean. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): close the remaining approval-gate review findings - Escape on the Web Shell trust card defers without recording a decision (TUI semantics): ToolApproval gains escapeDismisses/onDismiss, and the card hides until the block hash changes instead of persisting a rejection for a habitual keypress. - The card's options are *_once kinds so the shared safe-default logic pre-selects the reject side; a stray Enter on the security gate can never grant hook execution. Decision-specific labels stay via the option-id i18n keys. - The hooks-trust card reports into approvalOverlayActive, so the composer is dormant while the card owns the keyboard, exactly like a tool approval. - Under command-line YOLO, replacing the hooks block drops the old approval authority: an edited block cannot execute on the previous block's approval while the gate is exempt. - The trust-withheld listing surfaces the display-only block even when the empty execution block reads the gate as cleared (feature on, folder untrusted) — the state the copy exists for. Tests: core config + hooks-listing 1055/1055, hooks reload 38/38, ToolApproval + card 99/99 including Escape-defer and reject-default regressions; build + bundle clean. Co-authored-by: Qwen-Coder <[email protected]> * chore: record merge provenance The main merge (952e0633) refreshed the lint gate; this marker commit carries the required co-author trailer for the batch. Co-authored-by: Qwen-Coder <[email protected]> * fix(hooks): propagate trust authority to every gate re-application path Review round at2a00fb4d: the startup gate honored explicit trust, but the auxiliary paths re-read only the approval store and re-pended hooks that trust already authorized. - applyProjectHooksApproval (the /hooks reload and YOLO-exit path) now applies the same either-or rule as startup: explicit trust clears a pending block; only an explicit rejection of the exact block wins. - The startup gate mirrors Config.isExplicitlyTrustedFolder(): an IDE-reported trusted workspace counts even with the feature off. - The headless trust warning stays quiet when the execution accessor actually serves the block (approved or trust-authorized), matching the TUI predicate; the test mock now carries workspace.settings.hooks. - The live status cell and the cold-daemon fallback stop prompting when the hooks are not actually withheld (explicit trust / served block), so a trusted workspace never sees a card whose reject would stop running hooks. - The card's Escape defer is keyed by workspace identity read at dismiss time (no reset effect can race it), clears on workspace switch, and a failed decision POST renders an explicit retry notice. - The withheld /hooks listing filters non-event keys and null entries like the registry path; the YOLO replacement comment now states the fail-closed behavior instead of promising a re-apply that never runs. Tests: daemon facade covers cold-daemon pending+hash, trusted no-prompt, hash-match persist, 409 changed-block persists nothing, not-pending; gate re-apply under trust; IDE trust with feature off; trusted reload keeps hooks / untrusted edit withheld. cli 1879/1879, core 1055/1055, web-shell card+ToolApproval 99/99. Real browser: trusted dir runs hooks with no card and no approval record; untrusted dir card -> Escape defer (nothing persisted) -> approve -> hook fires. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: yiliang114 <[email protected]>
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* A real PreToolUse command hook's `updatedInput` replaces the tool input:
|
|
* the tool runs once with the replacement, permission checks and approval
|
|
* prompts see the replacement, and the model's own function call is kept.
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createInterface } from 'node:readline';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
applyContainerSandboxNoProxy,
|
|
fakeServerHostOptions,
|
|
TestRig,
|
|
} from '../test-helper.js';
|
|
import {
|
|
fakeToolCall,
|
|
startFakeOpenAIServer,
|
|
type FakeOpenAIServer,
|
|
} from '../fake-openai-server.js';
|
|
import {
|
|
exitInteractive,
|
|
fakeModelLaunchArgs,
|
|
makeWaitFor,
|
|
} from '../helpers/gated-skill-fixture.js';
|
|
import { ACP_HOME_PREFIX, removeScratchDir } from '../scratch-dir.js';
|
|
import { approveProjectHooks } from '../project-hook-approval.js';
|
|
|
|
const HOOK_SCRIPT = 'rewrite-hook.mjs';
|
|
const HITS_LOG = 'hook-hits.jsonl';
|
|
// Per-tool PreToolUse output, read from the (volume-mounted) test directory
|
|
// rather than the environment, which a container sandbox does not forward.
|
|
const OUTPUT_FILE = 'pre-output.json';
|
|
|
|
const hookSource = `
|
|
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
const event = input.hook_event_name;
|
|
appendFileSync(${JSON.stringify(HITS_LOG)}, JSON.stringify({ event, call: input.tool_call_id, tool: input.tool_name, input: input.tool_input }) + '\\n');
|
|
const outputs = existsSync(${JSON.stringify(OUTPUT_FILE)})
|
|
? JSON.parse(readFileSync(${JSON.stringify(OUTPUT_FILE)}, 'utf8'))
|
|
: {};
|
|
const out = { hookEventName: event, ...(event === 'PreToolUse' ? outputs[input.tool_name] : undefined) };
|
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
|
|
`;
|
|
|
|
type Message = {
|
|
role: string;
|
|
tool_call_id?: string;
|
|
content?: unknown;
|
|
tool_calls?: Array<{ id: string; function: { arguments: string } }>;
|
|
};
|
|
type HookHit = {
|
|
event: string;
|
|
call: string;
|
|
tool: string;
|
|
input: Record<string, unknown>;
|
|
};
|
|
|
|
describe('PreToolUse updatedInput', () => {
|
|
let rig: TestRig;
|
|
let fakeServer: FakeOpenAIServer | undefined;
|
|
let restoreNoProxy: (() => void) | undefined;
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
restoreNoProxy?.();
|
|
restoreNoProxy = undefined;
|
|
await fakeServer?.close();
|
|
fakeServer = undefined;
|
|
await rig?.cleanup();
|
|
});
|
|
|
|
async function setup(
|
|
testName: string,
|
|
toolName: string,
|
|
callId: string,
|
|
buildArgs: (dir: string) => Record<string, unknown>,
|
|
outputs: (dir: string) => Record<string, unknown>,
|
|
extraSettings: Record<string, unknown> = {},
|
|
): Promise<FakeOpenAIServer> {
|
|
rig = new TestRig();
|
|
await rig.setup(testName, {
|
|
settings: {
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: '*',
|
|
hooks: [{ type: 'command', command: `node ${HOOK_SCRIPT}` }],
|
|
},
|
|
],
|
|
},
|
|
...extraSettings,
|
|
},
|
|
});
|
|
writeFileSync(join(rig.testDir!, HOOK_SCRIPT), hookSource);
|
|
writeFileSync(
|
|
join(rig.testDir!, OUTPUT_FILE),
|
|
JSON.stringify(outputs(rig.testDir!)),
|
|
);
|
|
rig.createFile('A.txt', 'ORIGINAL_A\n');
|
|
rig.createFile('B.txt', 'REWRITTEN_B\n');
|
|
|
|
let streaming = 0;
|
|
const server = await startFakeOpenAIServer(({ body }) => {
|
|
if (body['stream'] !== true) return { content: '{}' };
|
|
return streaming++ === 0
|
|
? {
|
|
toolCalls: [
|
|
fakeToolCall(toolName, buildArgs(rig.testDir!), callId),
|
|
],
|
|
}
|
|
: { content: 'done' };
|
|
}, fakeServerHostOptions());
|
|
fakeServer = server;
|
|
|
|
vi.stubEnv('OPENAI_API_KEY', 'fake-key');
|
|
vi.stubEnv('OPENAI_BASE_URL', server.baseUrl);
|
|
vi.stubEnv('OPENAI_MODEL', 'fake-model');
|
|
vi.stubEnv('QWEN_HOME', join(rig.testDir!, '.qwen-home'));
|
|
vi.stubEnv('QWEN_RUNTIME_DIR', join(rig.testDir!, '.qwen-home'));
|
|
restoreNoProxy = applyContainerSandboxNoProxy();
|
|
return server;
|
|
}
|
|
|
|
function followUpMessages(server: FakeOpenAIServer): Message[] {
|
|
const followUp = server.requests.find(
|
|
(request) =>
|
|
request.body['stream'] === true &&
|
|
(request.body['messages'] as Message[]).some(
|
|
(message) => message.role === 'tool',
|
|
),
|
|
);
|
|
return (followUp?.body['messages'] ?? []) as Message[];
|
|
}
|
|
|
|
/** The tool message for `callId` in the model request that follows it. */
|
|
function toolResultFor(server: FakeOpenAIServer, callId: string): string {
|
|
const toolMessage = followUpMessages(server).find(
|
|
(message) => message.role === 'tool' && message.tool_call_id === callId,
|
|
);
|
|
expect(toolMessage, 'no paired tool result was sent').toBeDefined();
|
|
return JSON.stringify(toolMessage!.content);
|
|
}
|
|
|
|
/** The model's own arguments for `callId`, as replayed to the model. */
|
|
function modelArgsFor(
|
|
server: FakeOpenAIServer,
|
|
callId: string,
|
|
): Record<string, unknown> {
|
|
const call = followUpMessages(server)
|
|
.flatMap((message) => message.tool_calls ?? [])
|
|
.find((toolCall) => toolCall.id === callId);
|
|
expect(call, 'the model tool call was not replayed').toBeDefined();
|
|
return JSON.parse(call!.function.arguments);
|
|
}
|
|
|
|
function hookHits(): HookHit[] {
|
|
const log = join(rig.testDir!, HITS_LOG);
|
|
if (!existsSync(log)) return [];
|
|
return readFileSync(log, 'utf8')
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
function file(name: string): string | undefined {
|
|
const path = join(rig.testDir!, name);
|
|
return existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
}
|
|
|
|
it('headless: reads the replacement file and keeps the model call', async () => {
|
|
const server = await setup(
|
|
'updated input headless read',
|
|
'read_file',
|
|
'call_read',
|
|
(dir) => ({ file_path: join(dir, 'A.txt') }),
|
|
(dir) => ({
|
|
read_file: { updatedInput: { file_path: join(dir, 'B.txt') } },
|
|
}),
|
|
);
|
|
await rig.run('read the file', ...fakeModelLaunchArgs(server));
|
|
|
|
const hits = hookHits();
|
|
expect(hits).toHaveLength(1);
|
|
expect(hits[0]).toMatchObject({
|
|
event: 'PreToolUse',
|
|
call: 'call_read',
|
|
tool: 'read_file',
|
|
input: { file_path: join(rig.testDir!, 'A.txt') },
|
|
});
|
|
const result = toolResultFor(server, 'call_read');
|
|
expect(result).toContain('REWRITTEN_B');
|
|
expect(result).not.toContain('ORIGINAL_A');
|
|
expect(modelArgsFor(server, 'call_read')).toEqual({
|
|
file_path: join(rig.testDir!, 'A.txt'),
|
|
});
|
|
});
|
|
|
|
it('headless: checks deny rules against the replacement command', async () => {
|
|
const server = await setup(
|
|
'updated input headless deny',
|
|
'run_shell_command',
|
|
'call_shell',
|
|
() => ({ command: 'echo safe-original' }),
|
|
() => ({
|
|
run_shell_command: {
|
|
updatedInput: { command: 'touch denied-sentinel.txt' },
|
|
},
|
|
}),
|
|
{ permissions: { deny: ['Bash(touch *)'] } },
|
|
);
|
|
await rig.run('run the command', ...fakeModelLaunchArgs(server));
|
|
|
|
expect(hookHits()).toHaveLength(1);
|
|
expect(file('denied-sentinel.txt')).toBeUndefined();
|
|
const result = toolResultFor(server, 'call_shell');
|
|
expect(result).not.toContain('safe-original');
|
|
expect(result).toMatch(/denied|declined/i);
|
|
});
|
|
|
|
it('headless: rejects a non-object updatedInput without running the tool', async () => {
|
|
const server = await setup(
|
|
'updated input headless invalid',
|
|
'write_file',
|
|
'call_invalid',
|
|
(dir) => ({ file_path: join(dir, 'C.txt'), content: 'ORIGINAL_C' }),
|
|
() => ({ write_file: { updatedInput: ['not', 'an', 'object'] } }),
|
|
);
|
|
await rig.run('write the file', ...fakeModelLaunchArgs(server));
|
|
|
|
expect(hookHits()).toHaveLength(1);
|
|
expect(file('C.txt')).toBeUndefined();
|
|
expect(toolResultFor(server, 'call_invalid')).toContain('updatedInput');
|
|
});
|
|
|
|
it('headless: rewrites a nested exec call', async () => {
|
|
const server = await setup(
|
|
'updated input headless nested exec',
|
|
'exec',
|
|
'call_exec',
|
|
(dir) => ({
|
|
source: `const r = await tools.read_file({ file_path: ${JSON.stringify(join(dir, 'A.txt'))} }); text(JSON.stringify(r))`,
|
|
}),
|
|
(dir) => ({
|
|
read_file: { updatedInput: { file_path: join(dir, 'B.txt') } },
|
|
}),
|
|
{ tools: { codeModeOnly: true } },
|
|
);
|
|
await rig.run('run the script', ...fakeModelLaunchArgs(server));
|
|
|
|
expect(hookHits().filter((hit) => hit.tool === 'read_file')).toEqual([
|
|
expect.objectContaining({ call: 'call_exec:code:1' }),
|
|
]);
|
|
const result = toolResultFor(server, 'call_exec');
|
|
expect(result).toContain('REWRITTEN_B');
|
|
expect(result).not.toContain('ORIGINAL_A');
|
|
});
|
|
|
|
it('interactive: a PreToolUse ask confirms and writes the replacement', async () => {
|
|
const server = await setup(
|
|
'updated input interactive ask',
|
|
'write_file',
|
|
'call_ask',
|
|
(dir) => ({ file_path: join(dir, 'ask-a.txt'), content: 'ORIGINAL' }),
|
|
(dir) => ({
|
|
write_file: {
|
|
permissionDecision: 'ask',
|
|
permissionDecisionReason: 'p02b1 review',
|
|
updatedInput: {
|
|
file_path: join(dir, 'ask-b.txt'),
|
|
content: 'REWRITTEN',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const { ptyProcess } = rig.runInteractive(...fakeModelLaunchArgs(server));
|
|
let output = '';
|
|
ptyProcess.onData((data) => {
|
|
output += data;
|
|
});
|
|
const waitFor = makeWaitFor(rig, () => output);
|
|
await waitFor('the CLI to start', () =>
|
|
output.includes('Type your message'),
|
|
);
|
|
|
|
const prompt = 'write the file';
|
|
ptyProcess.write(prompt);
|
|
await waitFor('the prompt to echo', () => output.includes(prompt));
|
|
ptyProcess.write('\r');
|
|
// 'ask-b.txt' already paints with the executing tool row, before the
|
|
// ask dialog exists; the hook's reason renders only inside the dialog,
|
|
// so waiting for it is what makes the approval below land.
|
|
await waitFor(
|
|
'the confirmation for the replacement',
|
|
() => output.includes('ask-b.txt') && output.includes('p02b1 review'),
|
|
);
|
|
expect(output).not.toContain('ask-a.txt');
|
|
// The first option approves once.
|
|
ptyProcess.write('\r');
|
|
await waitFor('the follow-up model request', () =>
|
|
server.requests.some(
|
|
(request) =>
|
|
request.body['stream'] === true &&
|
|
JSON.stringify(request.body['messages']).includes('"role":"tool"'),
|
|
),
|
|
);
|
|
await exitInteractive(ptyProcess, waitFor, () => output);
|
|
|
|
expect(hookHits()).toHaveLength(1);
|
|
expect(file('ask-a.txt')).toBeUndefined();
|
|
expect(file('ask-b.txt')).toBe('REWRITTEN');
|
|
}, 360_000); // CI: 5 waits at the 60s default + 60s setup slack.
|
|
|
|
it('ACP: asks for and writes the replacement once', async () => {
|
|
const server = await setup(
|
|
'updated input acp write',
|
|
'write_file',
|
|
'call_acp',
|
|
(dir) => ({ file_path: join(dir, 'acp-a.txt'), content: 'ORIGINAL' }),
|
|
(dir) => ({
|
|
write_file: {
|
|
updatedInput: {
|
|
file_path: join(dir, 'acp-b.txt'),
|
|
content: 'REWRITTEN',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const permissionRequests: Array<{
|
|
toolCall?: { rawInput?: Record<string, unknown> };
|
|
}> = [];
|
|
// The agent keeps writing under QWEN_HOME briefly after it exits, so keep
|
|
// it out of rig.testDir, whose teardown would otherwise race those writes.
|
|
const qwenHome = mkdtempSync(join(tmpdir(), ACP_HOME_PREFIX));
|
|
// Both authorities: enable the trust feature (the inherited rig trust
|
|
// store grants this directory) and record the approval an interactive
|
|
// user would give, so the ACP child loads the fixture's project hooks.
|
|
writeFileSync(
|
|
join(qwenHome, 'settings.json'),
|
|
JSON.stringify({ security: { folderTrust: { enabled: true } } }),
|
|
);
|
|
approveProjectHooks(qwenHome, rig.testDir!);
|
|
const child = spawn(
|
|
'node',
|
|
[
|
|
rig.bundlePath,
|
|
'--acp',
|
|
'--no-chat-recording',
|
|
...fakeModelLaunchArgs(server),
|
|
],
|
|
{
|
|
cwd: rig.testDir!,
|
|
env: {
|
|
...process.env,
|
|
QWEN_HOME: qwenHome,
|
|
QWEN_RUNTIME_DIR: qwenHome,
|
|
},
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
const closed = new Promise<void>((resolve) =>
|
|
child.once('close', () => resolve()),
|
|
);
|
|
const pending = new Map<
|
|
number,
|
|
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
|
>();
|
|
let nextId = 0;
|
|
let disposed = false;
|
|
const stderr: string[] = [];
|
|
child.stderr!.on('data', (chunk) => stderr.push(chunk.toString()));
|
|
child.once('close', (code, signal) => {
|
|
if (disposed) return;
|
|
const tail = stderr.join('').trimEnd().slice(-500);
|
|
for (const [id, { reject }] of pending) {
|
|
reject(
|
|
new Error(
|
|
`ACP request ${id} failed: agent exited (code=${code} signal=${signal})` +
|
|
(tail ? `\nlast agent stderr:\n${tail}` : ''),
|
|
),
|
|
);
|
|
}
|
|
pending.clear();
|
|
});
|
|
createInterface({ input: child.stdout! }).on('line', (line) => {
|
|
let message: {
|
|
id?: number;
|
|
method?: string;
|
|
params?: unknown;
|
|
result?: unknown;
|
|
};
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (message.id === undefined) return;
|
|
if (message.method === undefined) {
|
|
pending.get(message.id)?.resolve(message.result);
|
|
pending.delete(message.id);
|
|
return;
|
|
}
|
|
if (message.method === 'session/request_permission') {
|
|
permissionRequests.push(
|
|
message.params as (typeof permissionRequests)[number],
|
|
);
|
|
}
|
|
child.stdin!.write(
|
|
JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
result:
|
|
message.method === 'session/request_permission'
|
|
? { outcome: { outcome: 'selected', optionId: 'proceed_once' } }
|
|
: null,
|
|
}) + '\n',
|
|
);
|
|
});
|
|
const request = <T>(method: string, params: unknown) =>
|
|
new Promise<T>((resolve, reject) => {
|
|
const id = ++nextId;
|
|
pending.set(id, {
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
});
|
|
child.stdin!.write(
|
|
JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
|
);
|
|
});
|
|
|
|
try {
|
|
await request('initialize', {
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: false, writeTextFile: false },
|
|
},
|
|
});
|
|
const session = await request<{ sessionId: string }>('session/new', {
|
|
cwd: rig.testDir!,
|
|
mcpServers: [],
|
|
});
|
|
// ACP sessions start in AUTO mode, which approves workspace writes.
|
|
await request('session/set_mode', {
|
|
sessionId: session.sessionId,
|
|
modeId: 'default',
|
|
});
|
|
await request('session/prompt', {
|
|
sessionId: session.sessionId,
|
|
prompt: [{ type: 'text', text: 'write the file' }],
|
|
});
|
|
} finally {
|
|
disposed = true;
|
|
child.kill();
|
|
await closed;
|
|
await removeScratchDir(qwenHome);
|
|
}
|
|
|
|
expect(hookHits()).toHaveLength(1);
|
|
expect(permissionRequests).toHaveLength(1);
|
|
expect(permissionRequests[0]?.toolCall?.rawInput).toEqual({
|
|
file_path: join(rig.testDir!, 'acp-b.txt'),
|
|
content: 'REWRITTEN',
|
|
});
|
|
expect(file('acp-a.txt')).toBeUndefined();
|
|
expect(file('acp-b.txt')).toBe('REWRITTEN');
|
|
expect(modelArgsFor(server, 'call_acp')).toEqual({
|
|
file_path: join(rig.testDir!, 'acp-a.txt'),
|
|
content: 'ORIGINAL',
|
|
});
|
|
});
|
|
});
|