feat: sidebar Write tool, stderr capture, cross-platform URL opener (#584)

Add Write to sidebar allowedTools (both sidebar-agent.ts and server.ts).
Write doesn't expand attack surface beyond what Bash already provides.
Replace empty stderr handler with buffer capture for better error
diagnostics. New bin/gstack-open-url for cross-platform URL opening.

Does NOT include Search Before Building intro flow (deferred).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-28 22:53:08 -07:00
parent 969aef41d1
commit 68dc957699
3 changed files with 35 additions and 6 deletions
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# gstack-open-url — cross-platform URL opener
#
# Usage: gstack-open-url <url>
set -euo pipefail
URL="${1:?Usage: gstack-open-url <url>}"
case "$(uname -s)" in
Darwin) open "$URL" ;;
Linux) xdg-open "$URL" 2>/dev/null || echo "$URL" ;;
MINGW*|MSYS*|CYGWIN*) start "$URL" ;;
*) echo "$URL" ;;
esac
+1 -1
View File
@@ -404,7 +404,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null): void {
const prompt = `${systemPrompt}\n\nUser: ${userMessage}`; const prompt = `${systemPrompt}\n\nUser: ${userMessage}`;
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep']; '--allowedTools', 'Bash,Read,Glob,Grep,Write'];
if (sidebarSession?.claudeSessionId) { if (sidebarSession?.claudeSessionId) {
args.push('--resume', sidebarSession.claudeSessionId); args.push('--resume', sidebarSession.claudeSessionId);
} }
+20 -5
View File
@@ -160,8 +160,10 @@ async function askClaude(queueEntry: any): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
// Build args fresh — don't trust --resume from queue (session may be stale) // Build args fresh — don't trust --resume from queue (session may be stale)
// Write doesn't expand attack surface beyond what Bash already provides.
// The security boundary is the localhost-only message path, not the tool allowlist.
let claudeArgs = ['-p', prompt, '--output-format', 'stream-json', '--verbose', let claudeArgs = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep']; '--allowedTools', 'Bash,Read,Glob,Grep,Write'];
// Validate cwd exists — queue may reference a stale worktree // Validate cwd exists — queue may reference a stale worktree
let effectiveCwd = cwd || process.cwd(); let effectiveCwd = cwd || process.cwd();
@@ -187,20 +189,30 @@ async function askClaude(queueEntry: any): Promise<void> {
} }
}); });
proc.stderr.on('data', () => {}); // Claude logs to stderr, ignore let stderrBuffer = '';
proc.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
});
proc.on('close', (code) => { proc.on('close', (code) => {
if (buffer.trim()) { if (buffer.trim()) {
try { handleStreamEvent(JSON.parse(buffer)); } catch {} try { handleStreamEvent(JSON.parse(buffer)); } catch {}
} }
sendEvent({ type: 'agent_done' }).then(() => { const doneEvent: Record<string, any> = { type: 'agent_done' };
if (code !== 0 && stderrBuffer.trim()) {
doneEvent.stderr = stderrBuffer.trim().slice(-500);
}
sendEvent(doneEvent).then(() => {
isProcessing = false; isProcessing = false;
resolve(); resolve();
}); });
}); });
proc.on('error', (err) => { proc.on('error', (err) => {
sendEvent({ type: 'agent_error', error: err.message }).then(() => { const errorMsg = stderrBuffer.trim()
? `${err.message}\nstderr: ${stderrBuffer.trim().slice(-500)}`
: err.message;
sendEvent({ type: 'agent_error', error: errorMsg }).then(() => {
isProcessing = false; isProcessing = false;
resolve(); resolve();
}); });
@@ -210,7 +222,10 @@ async function askClaude(queueEntry: any): Promise<void> {
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10); const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
setTimeout(() => { setTimeout(() => {
try { proc.kill(); } catch {} try { proc.kill(); } catch {}
sendEvent({ type: 'agent_error', error: `Timed out after ${timeoutMs / 1000}s` }).then(() => { const timeoutMsg = stderrBuffer.trim()
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
: `Timed out after ${timeoutMs / 1000}s`;
sendEvent({ type: 'agent_error', error: timeoutMsg }).then(() => {
isProcessing = false; isProcessing = false;
resolve(); resolve();
}); });