-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(daemon): server-side shell command execution for ! (bang) prefix #4576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,10 @@ import type { ApprovalMode } from '@qwen-code/qwen-code-core'; | |
| import { | ||
| TrustGateError, | ||
| getCurrentGeminiMdFilename, | ||
| ShellExecutionService, | ||
| type ShellOutputEvent, | ||
| } from '@qwen-code/qwen-code-core'; | ||
| import type { ShellCommandResult } from './bridgeTypes.js'; | ||
| import type { AcpChannel } from './channel.js'; | ||
| import { EventBus, DEFAULT_RING_SIZE, type BridgeEvent } from './eventBus.js'; | ||
| import { | ||
|
|
@@ -444,6 +447,8 @@ const MCP_RESTART_TIMEOUT_MS = 300_000; | |
| * disconnect cancellation in v1 (see server.ts route comment). | ||
| */ | ||
| const SESSION_RECAP_TIMEOUT_MS = 60_000; | ||
| const SHELL_COMMAND_TIMEOUT_MS = 120_000; | ||
| const MAX_SHELL_OUTPUT_FOR_HISTORY = 10_000; | ||
| const DEFAULT_MAX_SESSIONS = 20; | ||
| /** | ||
| * Soft upper bound on `BridgeOptions.eventRingSize` to catch operator | ||
|
|
@@ -3155,6 +3160,133 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { | |
| }; | ||
| }, | ||
|
|
||
| async executeShellCommand( | ||
| sessionId, | ||
| command, | ||
| signal, | ||
| context, | ||
| ): Promise<ShellCommandResult> { | ||
| const entry = byId.get(sessionId); | ||
| if (!entry) throw new SessionNotFoundError(sessionId); | ||
| const originatorClientId = resolveTrustedClientId( | ||
| entry, | ||
| context?.clientId, | ||
| ); | ||
|
|
||
| if (signal?.aborted) { | ||
| return { exitCode: null, output: '', aborted: true }; | ||
| } | ||
|
|
||
| const cwd = entry.workspaceCwd; | ||
|
|
||
| entry.events.publish({ | ||
| type: 'user_shell_command', | ||
| data: { sessionId, command, cwd }, | ||
| ...(originatorClientId ? { originatorClientId } : {}), | ||
| }); | ||
|
|
||
| const outputChunks: string[] = []; | ||
| const abort = new AbortController(); | ||
| const onSignalAbort = () => abort.abort(); | ||
| signal?.addEventListener('abort', onSignalAbort, { once: true }); | ||
|
|
||
| try { | ||
|
doudouOUC marked this conversation as resolved.
|
||
| const handle = await ShellExecutionService.execute( | ||
| command, | ||
| cwd, | ||
| (event: ShellOutputEvent) => { | ||
| if (event.type === 'data') { | ||
| const chunk = | ||
| typeof event.chunk === 'string' | ||
| ? event.chunk | ||
| : event.chunk | ||
| .map((line: Array<{ text: string }>) => | ||
| line.map((t) => t.text).join(''), | ||
| ) | ||
| .join('\n'); | ||
| outputChunks.push(chunk); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Major] Unbounded output buffer — potential OOM for long-running commands
Consider capping the buffer (e.g. keep first/last 500KB) or switching to a ring buffer, similar to how the CLI already handles shell output limits. The 10KB cap for history is good, but the full output buffer should also have a ceiling. This review was generated by QoderWork AI |
||
| entry.events.publish({ | ||
| type: 'session_update', | ||
| data: { | ||
| sessionId, | ||
| update: { | ||
| sessionUpdate: 'shell_output', | ||
| output: chunk, | ||
| _meta: { serverTimestamp: Date.now(), source: 'user-shell' }, | ||
| }, | ||
| }, | ||
| ...(originatorClientId ? { originatorClientId } : {}), | ||
| }); | ||
| } | ||
| }, | ||
| abort.signal, | ||
| false, | ||
| { terminalWidth: 120, terminalHeight: 40 }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor] Timeout starts after The timeout const timeoutId = setTimeout(() => abort.abort(), SHELL_COMMAND_TIMEOUT_MS);
timeoutId.unref();
try {
const handle = await ShellExecutionService.execute(...);
const result = await handle.result;
} finally {
clearTimeout(timeoutId);
}This review was generated by QoderWork AI |
||
| { streamStdout: true }, | ||
| ); | ||
|
|
||
| const timeoutId = setTimeout( | ||
| () => abort.abort(), | ||
| SHELL_COMMAND_TIMEOUT_MS, | ||
| ); | ||
| timeoutId.unref(); | ||
|
|
||
| const result = await handle.result; | ||
| clearTimeout(timeoutId); | ||
|
|
||
| const exitCode = result.exitCode; | ||
| const aborted = result.aborted; | ||
| const output = outputChunks.join('') || result.output; | ||
|
|
||
| entry.events.publish({ | ||
| type: 'user_shell_result', | ||
| data: { | ||
| sessionId, | ||
| exitCode, | ||
| signal: result.signal, | ||
| aborted, | ||
| _meta: { serverTimestamp: Date.now() }, | ||
| }, | ||
| ...(originatorClientId ? { originatorClientId } : {}), | ||
| }); | ||
|
|
||
| const historyOutput = | ||
| output.length > MAX_SHELL_OUTPUT_FOR_HISTORY | ||
| ? output.substring(0, MAX_SHELL_OUTPUT_FOR_HISTORY) + | ||
| '\n... (truncated)' | ||
| : output; | ||
|
|
||
| try { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor] The shell command result is injected as a If the Gemini API supports This review was generated by QoderWork AI |
||
| await entry.connection.extMethod( | ||
| SERVE_CONTROL_EXT_METHODS.sessionShellHistory, | ||
| { sessionId, command, output: historyOutput, exitCode }, | ||
| ); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor] History injection template vulnerable to backtick escaping
Apply the same dynamic backtick fencing that const longestRun = Math.max(0, ...Array.from(outputText.matchAll(/`+/g), m => m[0].length));
const fence = '`'.repeat(Math.max(3, longestRun + 1));
geminiClient.addHistory({
role: 'user',
parts: [{ text: `I ran the following shell command:\n${fence}sh\n${command}\n${fence}\n\nThis produced the following result:\n${fence}\n${outputText}\n${fence}` }],
});This review was generated by QoderWork AI |
||
| } catch (err) { | ||
| writeServeDebugLine( | ||
| `shell history injection failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| } | ||
|
|
||
| return { exitCode, output, aborted }; | ||
| } catch (err) { | ||
| entry.events.publish({ | ||
| type: 'user_shell_result', | ||
| data: { | ||
| sessionId, | ||
| exitCode: null, | ||
| signal: null, | ||
| aborted: false, | ||
| error: err instanceof Error ? err.message : String(err), | ||
| _meta: { serverTimestamp: Date.now() }, | ||
| }, | ||
| ...(originatorClientId ? { originatorClientId } : {}), | ||
| }); | ||
| throw err; | ||
| } finally { | ||
| signal?.removeEventListener('abort', onSignalAbort); | ||
| } | ||
| }, | ||
|
|
||
| async setWorkspaceToolEnabled(toolName, enabled, originatorClientId) { | ||
| // #4175 Wave 4 PR 17. Pure file IO + event fan-out — no ACP | ||
| // roundtrip. The settings file is the source of truth; live | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -270,6 +270,46 @@ export abstract class ChannelBase { | |
| this.config.cwd, | ||
| ); | ||
|
|
||
| // 3.5. Bang (!) shell command — direct execution, no LLM | ||
| if (envelope.text.startsWith('!')) { | ||
| const cmd = envelope.text.slice(1).trim(); | ||
| const bridgeShellCommand = (this.bridge as unknown as Record<string, unknown>)['shellCommand']; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Major] Unsafe This cast to Consider either:
interface ShellCapableBridge {
shellCommand(sessionId: string, command: string): Promise<...>;
}
function hasShellCommand(bridge: unknown): bridge is ShellCapableBridge {
return typeof (bridge as ShellCapableBridge)?.shellCommand === 'function';
}
Also note: when the bridge doesn't support This review was generated by QoderWork AI |
||
| if (cmd && typeof bridgeShellCommand === 'function') { | ||
| try { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor] Silent fall-through when When
The user typed if (cmd && typeof bridgeShellCommand === 'function') {
// ... existing logic
} else if (envelope.text.startsWith('!')) {
await this.sendMessage(
envelope.chatId,
cmd ? 'Shell command execution is not supported in this mode' : 'Usage: ! <command>',
);
return;
}This review was generated by QoderWork AI |
||
| const result = (await bridgeShellCommand(sessionId, cmd)) as { | ||
| exitCode: number | null; | ||
| output: string; | ||
| aborted: boolean; | ||
| }; | ||
| const longestRun = Math.max( | ||
| 0, | ||
| ...Array.from( | ||
| (result.output || '').matchAll(/`+/g), | ||
| (m) => m[0].length, | ||
| ), | ||
| ); | ||
| const fence = '`'.repeat(Math.max(3, longestRun + 1)); | ||
| const output = result.output | ||
| ? `${fence}\n${result.output}\n${fence}` | ||
| : '(no output)'; | ||
| const exitLine = | ||
| result.exitCode !== null && result.exitCode !== 0 | ||
| ? `\nExit code: ${result.exitCode}` | ||
| : ''; | ||
| await this.sendMessage( | ||
| envelope.chatId, | ||
| `$ ${cmd}\n${output}${exitLine}`, | ||
| ); | ||
| } catch (error) { | ||
| await this.sendMessage( | ||
| envelope.chatId, | ||
| `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, | ||
| ); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // Prepend referenced (quoted) message text for reply context | ||
| let promptText = envelope.text; | ||
| if (envelope.referencedText) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2441,6 +2441,36 @@ class QwenAgent implements Agent { | |
| ); | ||
| return { sessionId, recap }; | ||
| } | ||
| case SERVE_CONTROL_EXT_METHODS.sessionShellHistory: { | ||
| const sessionId = params['sessionId']; | ||
| if (typeof sessionId !== 'string' || sessionId.length === 0) { | ||
| throw RequestError.invalidParams( | ||
| undefined, | ||
| 'Invalid or missing sessionId', | ||
| ); | ||
| } | ||
| const command = params['command']; | ||
| if (typeof command !== 'string') { | ||
| throw RequestError.invalidParams( | ||
| undefined, | ||
| 'Invalid or missing command', | ||
| ); | ||
| } | ||
| const session = this.sessionOrThrow(sessionId); | ||
| const config = session.getConfig(); | ||
| const geminiClient = config.getGeminiClient()!; | ||
| const outputText = | ||
| typeof params['output'] === 'string' ? params['output'] : ''; | ||
| geminiClient.addHistory({ | ||
| role: 'user', | ||
| parts: [ | ||
| { | ||
| text: `I ran the following shell command:\n\`\`\`sh\n${command}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${outputText}\n\`\`\``, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor] History injection output not fenced against backtick content Same concern as bridge.ts — the template literal wraps Use dynamic backtick fencing here too (calculate the longest backtick run in This review was generated by QoderWork AI |
||
| }, | ||
| ], | ||
| }); | ||
| return { sessionId, injected: true }; | ||
| } | ||
| case 'deleteSession': { | ||
| const sessionId = params['sessionId'] as string; | ||
| if (!sessionId || !SESSION_ID_RE.test(sessionId)) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1873,6 +1873,51 @@ export function createServeApp( | |
| } | ||
| }); | ||
|
|
||
| app.post('/session/:id/shell', mutate(), async (req, res) => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks — won't take this one. The shell route deliberately uses non-strict
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] The PR adds ~327 lines of new code across 16 source files with 0 test files. Critical untested paths include:
Every comparable route/method in the codebase has dedicated test coverage. Consider adding tests for at least the server route, bridge method, and channel handler. — qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed the test coverage gap is real. Deferring to a follow-up PR — this PR is already 16 files and adding tests for bridge/server/normalizer would double the scope. |
||
| const sessionId = req.params['id']; | ||
| const body = safeBody(req); | ||
| const command = body['command']; | ||
| if (typeof command !== 'string' || command.trim().length === 0) { | ||
| res.status(400).json({ | ||
| error: '`command` is required and must be a non-empty string', | ||
| }); | ||
| return; | ||
| } | ||
| const abort = new AbortController(); | ||
| const onResClose = () => { | ||
| if (!res.writableEnded) abort.abort(); | ||
| }; | ||
| res.once('close', onResClose); | ||
| const clientId = parseClientIdHeader(req, res); | ||
| if (clientId === null) { | ||
| res.off('close', onResClose); | ||
| return; | ||
| } | ||
| try { | ||
| const result = await bridge.executeShellCommand( | ||
| sessionId, | ||
| command.trim(), | ||
| abort.signal, | ||
| clientId !== undefined ? { clientId } : undefined, | ||
| ); | ||
| res.status(200).json(result); | ||
| } catch (err) { | ||
| if ( | ||
| err instanceof DOMException && | ||
| err.name === 'AbortError' && | ||
| abort.signal.aborted | ||
| ) { | ||
| return; | ||
| } | ||
| sendBridgeError(res, err, { | ||
| route: 'POST /session/:id/shell', | ||
| sessionId, | ||
| }); | ||
| } finally { | ||
| res.off('close', onResClose); | ||
| } | ||
| }); | ||
|
|
||
| app.post( | ||
| '/session/:id/approval-mode', | ||
| mutate({ strict: true }), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks — won't take this one. The 120s
SHELL_COMMAND_TIMEOUT_MSprovides the bound — same pattern as the CLI'sShellExecutionServiceusage which also accumulates unboundedly until timeout. Capping mid-stream would silently truncate output the user explicitly asked to see.