Skip to content

fix(vscode): make the extension's send path work, and close the security holes - #5987

Open
Hmbown wants to merge 2 commits into
mainfrom
pr/vscode-send-path
Open

fix(vscode): make the extension's send path work, and close the security holes#5987
Hmbown wants to merge 2 commits into
mainfrom
pr/vscode-send-path

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Closes #5834

The extension had never successfully started a turn

startTurn accepted only HTTP 200/202 while the runtime's start_thread_turn (crates/tui/src/runtime_api.rs:4613-4632) returns StatusCode::CREATED as its only success path. git log -- extensions/vscode/src/api.ts is a single commit: this was never a regression — it shipped that way and was never run end to end.

Two commits: the original chat sidebar, then the fixes.

Send path (api.ts)

  • Status handling now tests a range (isOk: >= 200 && < 300) through one ensureOk helper routed through every call site, instead of enumerating codes at eleven of them. 201 is accepted because it is 2xx, not because it is special-cased — the shape the embedded web client already used at crates/tui/src/runtime_web/app.mjs:873, which is why that client worked against the same runtime this one choked on.
  • The runtime's JSON error.message is surfaced on every route; previously only startTurn passed it through.
  • 409 is typed: a second send while a turn is live is "already running"; interrupting when nothing streams is "nothing to stop", not an error.

Security

  • SecretStorage now wins over the settings token, matching what secrets.ts, the manifest and the README all already promised. Previously a repo-local .vscode/settings.json could supply a bearer and retarget runtimeHost — opening a repo was enough. Settings values are now a one-time migration source, and runtimeHost/runtimePort/runtimeToken/commandPath are "scope": "machine".
  • The runtime token goes to the terminal via environment, not --auth-token in argv, which was visible in shell history and ps.
  • status.ts nonce uses a CSPRNG, matching chat.ts.

Chat correctness and accessibility

  • Transcript prefers detail over the 280-char summary, so reload shows the reply instead of a stub.
  • operation_key is reused on retry, so a timeout and resend no longer creates two turns; the dead SSE stream is cleared so reconnect can fire.
  • The composer keeps its text until the turn is accepted.
  • Tool paths are parsed out of metadata.tool_input and treated as untrusted. The durable fix is runtime-side (runtime_threads.rs:8818-8822) and is deliberately not taken here.
  • Focus styling and roles/labels added — there were none, so keyboard focus was invisible and the transcript unannounced.

Chrome

Chat is contributed to the secondary sidebar with an activity-bar fallback, gated on codewhale.noSecondarySidebar, set at activation from vscode.version (>= 1.106). One ChatView instance serves both view ids and reveal() focuses whichever resolved.

engines.vscode stays ^1.96.2 with the threshold enforced at runtime, matching the shipping Codex extension — raising the floor would cut off 1.90-1.105 users and make the fallback unreachable for nothing.

CI and dev loop

  • CI now runs the extension tests. Nothing ran them before, which is how a send path that could not work stayed green.
  • launch.json/tasks.json give a working F5 host. The root .gitignore's bare .vscode/ silently swallowed them, so a negation was added — without it these files exist locally and vanish on commit.

Evidence

cd extensions/vscode && npx tsc --noEmit   -> clean
cd extensions/vscode && npm test           -> tests 42, pass 42, fail 0  (baseline 25)
./scripts/release/check-versions.sh        -> Version state OK: workspace=0.9.12, lockfile in sync

Manifest/provider coherence checked by hand: every declared view id has a provider, no provider lacks a manifest entry, context key set at activation. A green typecheck cannot see either of those — an earlier pass had all six units reporting "complete" with tsc clean and no UI at all.

Not done, deliberately

The Runtime view still exists, so this is not yet a single-view Agents panel — removing it spans extension.ts, status.ts and two commands, and a half-removal is worse than either state.

A human driving one real turn in an Extension Development Host remains unproven. F5 now works, which it never did before.

Extension version stays 0.9.12 to match Cargo.toml; prepare-release.sh bumps it when 0.9.13 is cut.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D4rk4NXwyy6wmvii9Lp84P


Devin Review

Note

Medium Risk
Large new client surface (webview + runtime API) with auth/token and untrusted model/path handling, though changes mostly harden secrets and validation rather than weakening server-side controls.

Overview
Turns the Codewhale VS Code extension from a thin runtime/status scaffold into a primary chat sidebar (activity bar or secondary sidebar on VS Code ≥1.106) that talks to the local Engine Runtime over HTTP/SSE: threads, streaming turns, editor context chips, inline approvals/clarifications, steer/stop, and safe Markdown rendering with copy/insert actions.

Adds a VS Code–free runtime client (api.ts, SSE parser, transcript/markdown projection) plus ChatView webview orchestration; extension.ts wires commands (Ask, New Chat, Set Runtime Token), connection refresh, and duplicate view registration behind codewhale.noSecondarySidebar.

Send path and contract fixes: mutating routes treat any 2xx as success (fixes turns blocked on 201 Created), surface runtime error bodies, typed 409 conflicts, operation_key reuse on retry, and SSE reconnect after stream drop.

Security hardening: bearer tokens in SecretStorage (workspace settings ignored; user-level setting migrated once), machine-scoped host/port/command settings, runtime start passes token via CODEWHALE_RUNTIME_TOKEN env instead of argv, stricter webview CSP/nonces, escaped Markdown, http(s)-only links, and guarded “open file” paths from tool metadata.

Repo hygiene: CI vscode-extension job runs npm test (compile + unit tests); dev launch.json/tasks.json, .vscodeignore, README/package manifest updates (0.9.12, preview, min engine bump), brand icon swap, root .gitignore negation for extension .vscode/.

Reviewed by Cursor Bugbot for commit 6482b58. Bugbot is set up for automated code reviews on this repo. Configure here.

CodeWhale Bot and others added 2 commits September 7, 2026 00:39
Replace the attach-only scaffold with a working agent chat extension:

- Chat sidebar (codewhale.chat): create/switch/resume threads, live
  streaming over the replayable SSE contract, inline tool approvals,
  clarification questions, steer, and interrupt
- Editor context chips (selection / active file / diagnostics) assembled
  into the prompt; CodeWhale: Ask Codewhale command, ctrl+alt+c
  keybinding, and editor context menu entry
- Safe Markdown rendering (escaped subset) with per-block Copy and
  Insert-at-cursor actions
- Runtime bearer tokens move to VS Code SecretStorage
  (CodeWhale: Set Runtime Token) with settings migration
- Pure /v1 client (api.ts), SSE parser (sse.ts), and renderer
  (markdown.ts) split out of VS Code for direct testing

The runtime remains the single turn/event owner; the extension only
renders and routes. Full-feature flows (diff review, model switching,
account sign-in) stay with the runtime's embedded browser client until
their contracts land.

Proof: extensions/vscode npm test -> 25 passed, 0 failed; packaged
codewhale-vscode-0.10.0.vsix (95 KB, tests excluded); compiled client
smoke-tested against the release runtime binary (health, auth-required
detection, thread summaries, create, detail hydration, snapshots) with
no model turns executed.
…ity holes

The extension had never successfully started a turn. `startTurn` accepted
only HTTP 200/202 while the runtime's `start_thread_turn`
(crates/tui/src/runtime_api.rs:4613-4632) returns `StatusCode::CREATED` as
its ONLY success path, so every send failed. `git log -- src/api.ts` is a
single commit: this was never a regression, it shipped that way and was
never run end to end.

api.ts (send path):
- Status handling now tests a RANGE (`isOk`: >= 200 && < 300) through one
  `ensureOk` helper routed through every call site, rather than enumerating
  codes at eleven of them. 201 is accepted because it is 2xx, not because it
  is special-cased — the same shape the embedded web client already used at
  crates/tui/src/runtime_web/app.mjs:873, which is why that client worked
  against the same runtime this one choked on.
- The runtime's JSON `error.message` is surfaced on every route; previously
  only startTurn passed it through.
- 409 is typed: a second send while a turn is live is "already running", and
  interrupting when nothing streams is "nothing to stop", not an error.

Security (extension.ts, runtime.ts, secrets.ts):
- SecretStorage now wins over the settings token, matching what secrets.ts,
  the manifest and the README all already promised. Previously a repo-local
  .vscode/settings.json could supply a bearer AND retarget `runtimeHost`,
  and the token rode every request — opening a repo was enough.
- The runtime token is passed to the terminal via environment instead of
  `--auth-token` in argv, which was visible in shell history and `ps`.
- status.ts nonce uses a CSPRNG, matching chat.ts.

Chat correctness and accessibility (chat.ts, transcript.ts):
- Transcript prefers `detail` over the 280-char `summary`, so reload shows
  the reply instead of a stub.
- `operation_key` is reused on retry, so a timeout and resend no longer
  creates two turns; the dead SSE stream is cleared so reconnect can fire.
- The composer keeps its text until the turn is accepted.
- Tool paths are parsed out of `metadata.tool_input` and treated as
  untrusted. The durable fix is runtime-side and is NOT taken here.
- Focus styling and roles/labels added; there were none.

Chrome (package.json, extension.ts):
- Chat is contributed to the secondary sidebar with an activity-bar
  fallback, gated on `codewhale.noSecondarySidebar`, which is set at
  activation from `vscode.version` (>= 1.106). One ChatView instance serves
  both view ids and `reveal()` focuses whichever resolved.
- `engines.vscode` stays `^1.96.2` and the threshold is enforced at runtime,
  matching the shipping Codex extension; raising the floor would have cut
  off 1.90-1.105 users and made the fallback unreachable for nothing.

CI and dev loop (ci.yml, .gitignore, .vscode/):
- CI runs the extension tests. Nothing ran them before, which is how a send
  path that could not work stayed green.
- launch.json/tasks.json give a working F5 host. The root `.gitignore`'s
  bare `.vscode/` silently swallowed them, so a negation was added — without
  it these files exist locally and vanish on commit.

Gates:
  cd extensions/vscode && npx tsc --noEmit   -> clean
  cd extensions/vscode && npm test           -> tests 42, pass 42, fail 0
                                                (baseline before this was 25)
  manifest/provider coherence: every declared view id has a provider;
  no provider lacks a manifest entry; context key set at activation.

Not done, deliberately: the Runtime view still exists, so this is not yet a
single-view Agents panel. Removing it spans extension.ts, status.ts and two
commands; a half-removal is worse than either state. Gate item 5 — a human
driving one real turn in an Extension Development Host — remains unproven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D4rk4NXwyy6wmvii9Lp84P
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Copilot AI lite review requested due to automatic review settings September 7, 2026 07:40
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T07:57:46.945923Z 6482b58 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_4ebed471-8eaf-417b-ae82-0f8da24ffcf0)

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 2m 37s —— View job


Review of PR #5987

  • Gather context (PR description, diff, CLAUDE.md/AGENTS.md rules)
  • Review api.ts send-path / status handling changes
  • Review security changes (SecretStorage, token via env, CSPRNG nonce, settings scope)
  • Review chat correctness/accessibility changes (chat.ts, transcript.ts, sse.ts)
  • Review chrome/extension activation changes (extension.ts, context.ts, package.json)
  • Review CI/dev loop changes (.github/workflows/ci.yml, .gitignore, launch.json/tasks.json)
  • Post final review

Note: npm ci / tsc / npm test require Bash approval that wasn't granted in this sandboxed run, so this is a static review only — I could not independently reproduce the tsc/test evidence quoted in the PR description.

Summary

This is a careful, well-scoped fix. I traced the core claims against the diff and they hold up:

  • isOk (extensions/vscode/src/api.ts:~430) correctly tests the whole 2xx range through a single ensureOk/apiError path used by every call site — matches crates/tui/src/runtime_web/app.mjs's response.ok behavior and fixes the HTTP 201 bug described in the PR body.
  • ConflictError/409 handling in interruptTurn correctly reports "not-running" as a value instead of throwing; startTurn/steerTurn/etc. throw ConflictError which callers can/do distinguish via error.statusCode.
  • secrets.ts resolveToken genuinely prefers SecretStorage, only migrates a user-scoped setting value, and explicitly ignores/warns on a workspace- or folder-scoped token — closes the privilege-escalation hole described in the PR (repo settings supplying both host and bearer token).
  • runtime.ts startRuntimeTerminal now passes the token via env: { CODEWHALE_RUNTIME_TOKEN } instead of --auth-token in argv — verified no remaining call sites push the token onto the command line.
  • CSPRNG nonces: both chat.ts:904 and status.ts:191 now use crypto.randomBytes(16).toString("hex") instead of Math.random().
  • transcript.ts correctly prefers detail over summary, and extractFilePath/isInsideRoot treat metadata.tool_input as untrusted (control-char rejection, ../absolute-path containment check), and chat.ts's openFileAtPath adds a modal confirmation before opening anything outside the workspace — good defense in depth, and it's unit-tested (test/transcript.test.ts).
  • operation_key reuse in sendPrompt (chat.ts:296-337): the pending send is only cleared on success, so a failed send followed by retry reuses the same key, and composer text/chips are only dropped after acceptance — matches the "no duplicate turn on retry" and "composer keeps text" claims.
  • SSE reconnect (chat.ts:608-632): stream is explicitly nulled before scheduling the reconnect timer (with a comment explaining why), backoff is capped, and onDidDispose/selectThread both call closeStream() — no obvious leak.
  • Markdown renderer HTML-escapes before any transform, so model output can't inject markup; good test coverage for that specifically.
  • New vscode-extension CI job actually runs npm test (which runs tsc -p ./ first), closing the gap the PR describes (extension previously shipped without CI ever compiling or testing it).

Minor / non-blocking

  1. Stale comment vs. actual engines.vscode bump.github/workflows/ci.yml (new vscode-extension job) says "The extension targets VS Code >=1.90 ... Build and test on the runtime the extension actually ships against," but this same PR bumps package.json's engines.vscode from ^1.90.0 to ^1.96.2. The PR description also says engines.vscode "stays ^1.96.2," which reads as if it were unchanged, but the diff shows it moved up from 1.90.0. Worth double-checking this is the intended floor — raising it does drop support for VS Code 1.90.0–1.96.1, which isn't explicitly called out as intentional the way the 1.106 secondary-sidebar threshold is.
  2. Minor duplicationextension.ts, codewhale.openTerminal command calls readRuntimeConfig() twice (once for config, again inline for the log line). Harmless (cheap, synchronous read of settings) but could reuse config.commandPath.

Nothing found here rises to a correctness bug, a security hole, or a provider/model-routing concern (this extension doesn't do any provider/model-prefix inference — model info is just displayed, not chosen from). The security-hardening claims in the PR description are backed by the diff, and the untrusted-input handling (tool paths, markdown, SSE frames) is appropriately defensive and tested.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 11 potential issues.

Devin Review

Comment on lines +193 to +196
async selectThread(threadId: string): Promise<void> {
this.closeStream();
this.streamingTurnId = undefined;
this.interruptRequested = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Running threads lose their controls

Selecting an existing thread clears streamingTurnId without restoring the snapshot’s active turn. Stop and steer disappear while that turn continues running.

Prompt for agents
Update ChatView.selectThread in extensions/vscode/src/chat.ts to derive streamingTurnId from the loaded ThreadDetail. Find the latest turn whose status is queued or in_progress and preserve its ID so resumed threads expose Stop and Steer. Ensure terminal snapshots leave streamingTurnId unset, and cover selecting a thread that was already running before the VS Code view loaded.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1204 to +1208
const selected = new Set();
const answer = (label) => vscode.postMessage({
command: "answerInput", inputId: input.id,
answers: [{ id: question.id, label: label, value: label }],
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Multi-question replies lose later answers

Choosing one option submits only that question. submit_user_input consumes the entire request, so later questions cannot be answered.

Prompt for agents
Rework userInputCard in extensions/vscode/src/chat.ts to collect answers across every question in a PendingUserInput and submit once. Preserve single-select and multi-select rules, require an answer for each question, and send one answers array to answerInput. The Runtime's submit_user_input consumes the whole pending request on the first POST, so per-question submission cannot work for requests containing multiple questions.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +323 to +329
// Accepted: only now is it safe to drop the composer text and chips.
this.pendingSend = undefined;
this.chips = [];
this.streamingTurnId = result.turn.id;
this.interruptRequested = false;
this.addLocalUserMessage(prompt);
this.post({ type: "composerResult", ok: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Every sent prompt appears twice

Every accepted send adds a local user item. emit_claimed_turn_started already publishes the persisted item, so the transcript renders both copies.

Prompt for agents
Remove or reconcile the synthetic local user message in ChatView.sendPrompt. The Runtime emits item.started and item.completed for the persisted user item before start_turn returns, and the existing SSE stream or replay will deliver it. Keep optimistic rendering only if it uses an identity that can be replaced by the canonical item without duplication.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +197 to +200
this.items.clear();
this.itemOrder = [];
this.activeThreadId = threadId;
this.postSync();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Thread switches expose stale approvals

Switching threads keeps the previous activeDetail during loading. Its approval cards remain actionable under the newly selected thread until the request finishes.

Suggested change
this.items.clear();
this.itemOrder = [];
this.activeThreadId = threadId;
this.postSync();
this.items.clear();
this.itemOrder = [];
this.activeDetail = undefined;
this.lastSeq = 0;
this.activeThreadId = threadId;
this.postSync();
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +141 to +143
if (info.statusCode === 401) {
return { kind: "auth-required", detail: "Runtime info requires a token." };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Runtime information failures look connected

A 404 or 500 from the information endpoint falls through to connected. The extension reports a healthy Runtime although its API is unavailable.

Suggested change
if (info.statusCode === 401) {
return { kind: "auth-required", detail: "Runtime info requires a token." };
}
if (info.statusCode === 401) {
return { kind: "auth-required", detail: "Runtime info requires a token." };
}
if (!isOk(info.statusCode)) {
return { kind: "error", detail: `Runtime info returned HTTP ${info.statusCode}.` };
}
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +355 to +357
try {
await interruptTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId);
this.output.appendLine(`Interrupt requested for turn ${this.streamingTurnId}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Idle stop leaves controls stuck

When interruptTurn returns not-running, the chat retains streamingTurnId. Stop and steer remain visible although the turn has already ended.

Suggested change
try {
await interruptTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId);
this.output.appendLine(`Interrupt requested for turn ${this.streamingTurnId}`);
const result = await interruptTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId);
if (result === "not-running") {
this.streamingTurnId = undefined;
this.interruptRequested = false;
this.postSync();
}
this.output.appendLine(`Interrupt requested for turn ${this.streamingTurnId}`);
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 89 to +94
case "connected":
updateStatus("$(check) CodeWhale", state.detail);
await refreshAgentViewDetails(false);
await chatView.refreshThreads();
if (becameConnected) {
await chatView.resyncAfterConnection();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Runtime panel stays permanently stale

Successful checks refresh only chatView. The retained Runtime panel never receives thread summaries or snapshots, and its refresh button cannot update them.

Prompt for agents
Restore RuntimeStatusView data updates in extensions/vscode/src/extension.ts. On a connected check, load thread summaries and snapshots through the authenticated ApiConfig and call statusView.updateThreads and statusView.updateSnapshots. Ensure codewhale.refreshAgentView updates the Runtime panel rather than only ChatView, while avoiding duplicate network requests where practical.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +464 to +467
private async openStream(threadId: string, sinceSeq: number): Promise<void> {
this.closeStream();
this.reconnectAttempt = 0;
const config = await this.configProvider();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Reconnect backoff never advances

openStream resets reconnectAttempt before each attempt. Persistent failures therefore retry every second instead of reaching the five-second cap.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +486 to +491
private handleStreamEvent(event: RuntimeEvent): void {
if (event.seq <= this.lastSeq) {
return; // duplicate or stale replay
}
this.lastSeq = event.seq;
this.reconnectAttempt = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Event gaps lack recovery

The client ignores previousSeq and accepts any newer sequence. The embedded client detects gaps and reloads its snapshot.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +105 to +109
constructor(
private readonly extensionContext: vscode.ExtensionContext,
private readonly configProvider: () => Promise<ApiConfig>,
private readonly output: vscode.OutputChannel,
) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Unused context dependency

ChatView stores extensionContext but never reads it. Remove the parameter to keep the view’s dependency boundary minimal.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a few concrete correctness issues in new/updated logic (selection line-range computation, interrupt UI state, and redundant refresh behavior) that should be fixed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR upgrades the extensions/vscode integration from a runtime/status scaffold into a functional chat sidebar client for the Codewhale Runtime API, while hardening token handling and webview safety and ensuring the send/stream paths match the runtime’s actual HTTP/SSE contracts.

Changes:

  • Adds a VS Code–independent Runtime HTTP/SSE client, SSE parser, transcript projection, and safe Markdown rendering with node-based unit tests.
  • Introduces a full chat webview (threads, streaming turns, approvals/user-input, context chips, stop/steer) and wires it into extension activation + status refresh.
  • Improves security posture (SecretStorage token precedence/migration, machine-scoped settings, runtime token passed via env, CSP nonces via CSPRNG) and adds CI coverage for extension tests.
File summaries
File Description
extensions/vscode/src/transcript.ts Pure transcript projection + defensive file-path extraction/containment helpers.
extensions/vscode/src/test/transcript.test.ts Unit tests for transcript projection, file-path parsing, containment, and SSE status mapping.
extensions/vscode/src/test/sse.test.ts Unit tests for SSE frame parsing across chunking and CRLF boundaries.
extensions/vscode/src/test/markdown.test.ts Unit tests validating safe Markdown subset rendering (escaping, links, code actions).
extensions/vscode/src/test/api.test.ts Unit tests for HTTP status handling (2xx), error surfacing, SSE streaming, and typed 409 conflicts.
extensions/vscode/src/status.ts Switches CSP nonce generation to CSPRNG.
extensions/vscode/src/sse.ts Adds minimal dependency-free SSE parser producing runtime envelope events.
extensions/vscode/src/secrets.ts Implements SecretStorage-first token resolution with one-way migration from deprecated settings and workspace-scope defense.
extensions/vscode/src/runtime.ts Removes token-from-settings usage and starts runtime with token via env instead of argv.
extensions/vscode/src/markdown.ts Adds dependency-free safe Markdown renderer returning HTML + extracted code blocks.
extensions/vscode/src/extension.ts Activates chat view, routes connection state into chat/status views, adds commands, secondary-sidebar gating, and refresh loop updates.
extensions/vscode/src/context.ts Adds context-chip capture (selection/file/diagnostics) and prompt assembly for attaching editor context.
extensions/vscode/src/chat.ts Adds chat webview implementation (threads, streaming SSE, approvals/inputs, context chips, safe interactions).
extensions/vscode/src/api.ts Adds VS Code–free Runtime API client with centralized 2xx handling and typed errors.
extensions/vscode/README.md Updates extension documentation to reflect chat sidebar + security posture + local dev loop.
extensions/vscode/package.json Adds chat commands, menus/keybinding, secondary sidebar contribution with runtime gating, settings hardening, and test script.
extensions/vscode/media/codewhale.svg Updates the contributed icon geometry and documentation comment.
extensions/vscode/.vscodeignore Excludes sources/tests/dev artifacts from the packaged VSIX.
extensions/vscode/.vscode/tasks.json Adds compile/watch tasks for F5 Extension Development Host.
extensions/vscode/.vscode/launch.json Adds launch configs for running the extension and its node-based tests.
.gitignore Un-ignores the extension’s committed .vscode/ dev-loop configuration directory.
.github/workflows/ci.yml Adds a CI job to run the extension’s npm test suite.
Review details
  • Files reviewed: 20/23 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +351 to +361
private async interrupt(): Promise<void> {
if (!this.activeThreadId || !this.streamingTurnId) {
return;
}
try {
await interruptTurn(await this.configProvider(), this.activeThreadId, this.streamingTurnId);
this.output.appendLine(`Interrupt requested for turn ${this.streamingTurnId}`);
} catch (error) {
this.handleError("Interrupt failed", error);
}
}
Comment on lines +29 to +30
const endLine = selection.end.line + 1;
const lines = endLine - startLine + 1;
Comment on lines +91 to +94
await chatView.refreshThreads();
if (becameConnected) {
await chatView.resyncAfterConnection();
}
id: `chip-${++chipCounter}`,
kind: "diagnostics",
label: "Problems",
detail: `${entries.length} entrie${entries.length === 1 ? "" : "s"}`,

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6482b58af2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

this.items.clear();
this.itemOrder = [];
this.activeThreadId = threadId;
this.postSync();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear stale approvals before publishing a thread switch

When switching threads, this sync publishes the new activeThreadId while activeDetail still belongs to the previous thread. Until the detail request finishes—and indefinitely if it fails—the new thread displays the old thread's approval and user-input cards; clicking an old approval calls the globally addressed /v1/approvals/{id} endpoint and can authorize work in the previous thread. Clear activeDetail before posting the transitional state.

Useful? React with 👍 / 👎.

this.chips = [];
this.streamingTurnId = result.turn.id;
this.interruptRequested = false;
this.addLocalUserMessage(prompt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile optimistic user messages with durable events

Every accepted send adds a new local-* user item even though the Runtime emits durable item.started and item.completed events for its persisted user item before startTurn returns (RuntimeThreadManager::emit_claimed_turn_started). The stream is then reopened from the pre-send cursor, so even if the prior stream missed those events they are replayed under the durable item ID; because nothing removes or reconciles the local ID, normal sends display two “You” bubbles until the thread is reloaded. The steer path has the same duplication.

Useful? React with 👍 / 👎.

Comment on lines +487 to +490
if (event.seq <= this.lastSeq) {
return; // duplicate or stale replay
}
this.lastSeq = event.seq;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recover when previous_seq exposes an SSE gap

For a newer event this code advances lastSeq without checking event.previousSeq. The Runtime contract in docs/RUNTIME_API.md:1095-1100 explicitly requires comparing previous_seq with the accepted per-thread cursor, and the existing browser client re-snapshots on a mismatch. If a frame is lost, malformed, or unavailable during replay, this client instead accepts later events permanently, which can omit transcript text, a completion, or a pending approval/user-input request; detect the mismatch and refresh the thread snapshot before continuing.

Useful? React with 👍 / 👎.

Comment on lines +1205 to +1208
const answer = (label) => vscode.postMessage({
command: "answerInput", inputId: input.id,
answers: [{ id: question.id, label: label, value: label }],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Submit all clarification answers in one response

When a request_user_input request contains two or three questions, each option click immediately posts an answer containing only that one question. The Runtime settles and removes the entire pending request after the first POST, so the remaining questions cannot be answered and the model receives an incomplete response. The existing web client collects every question into one answers array and exposes a single submit action; this card needs the same request-level submission behavior.

Useful? React with 👍 / 👎.

Comment on lines +206 to +207
this.activeDetail = detail;
this.lastSeq = detail.latestSeq;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the active turn when selecting a thread

Selecting a thread always clears streamingTurnId, but loading its detail never derives the currently in-progress turn from detail.thread.latestTurnId and detail.turns. If the user switches away while a turn runs and then returns—or reconnects while it is running—the transcript continues receiving events, but streaming remains false, Stop and Steer stay hidden, and terminal events cannot match the tracked turn. Restore the in-progress turn ID from the snapshot.

Useful? React with 👍 / 👎.


private async openStream(threadId: string, sinceSeq: number): Promise<void> {
this.closeStream();
this.reconnectAttempt = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve reconnect attempts until an event succeeds

Every reconnect enters openStream and resets reconnectAttempt before the connection has produced an event. During a sustained outage, the subsequent error therefore always increments from zero and schedules another retry after one second, so the advertised capped backoff never reaches two through five seconds and the extension continually hammers the unavailable endpoint. Reset the counter only after a successfully accepted event, as handleStreamEvent already does.

Useful? React with 👍 / 👎.

Comment on lines +1061 to +1062
box.value = "";
vscode.postMessage({ command: "steer", text });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve steer text until the Runtime accepts it

The steer composer clears its value before the extension host has acknowledged the request and has no result message analogous to composerResult. If the Runtime rejects the steer because the turn just ended, the token expired, or the connection drops, ChatView.steer only displays an error and the user's steering instruction is irretrievably lost. Keep the value until success or restore it on failure.

Useful? React with 👍 / 👎.

}));
card.appendChild(confirm);
}
if (question.allowFreeText) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the custom clarification answer available

This hides the free-text response whenever allowFreeText is false or omitted, but the repository's terminal and browser surfaces intentionally keep an “Other” answer available for every clarification (crates/tui/src/tui/user_input.rs:637-651 and crates/tui/src/runtime_web/app.mjs:1676-1695). Since older/model-generated requests commonly omit the flag, a user whose answer is not among the suggested options cannot respond from VS Code even though the other supported clients can.

Useful? React with 👍 / 👎.

Comment on lines +471 to +474
const stream = openEventStream(config, threadId, sinceSeq, new SseParser());
stream.onEvent = (event) => this.handleStreamEvent(event);
stream.onError = (error) => this.handleStreamError(threadId, error);
this.stream = stream;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore errors from streams that were deliberately replaced

The error handler is keyed only by threadId, so it cannot distinguish the current stream from an older stream closed by openStream. Destroying an active Node HTTP response emits an ECONNRESET response error; if that callback runs after the replacement is assigned, handleStreamError closes this.stream—the new stream—and schedules another reconnect. This occurs on same-thread stream replacement after a send and can cascade into repeated disconnects; capture the stream identity or detach its handlers before closing it.

Useful? React with 👍 / 👎.

Comment on lines +445 to +452
(response) => {
let raw = "";
response.setEncoding("utf8");
response.on("data", (chunk: string) => {
raw += chunk;
});
response.on("end", () => {
resolve({ statusCode: response.statusCode ?? 0, body: parseJson(raw) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Settle JSON requests when the response aborts

The promise listens only for the response's end event. If the Runtime restarts, a proxy resets the socket, or the peer otherwise aborts after sending headers or a partial body, Node emits aborted/error on the response and closes the request without emitting end or the request timeout; this promise then remains pending forever. Affected sends leave the composer busy indefinitely, and an affected health check leaves autoRefreshInFlight stuck, so reject on response abort/error as well.

Useful? React with 👍 / 👎.

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

This is a large, focused VS Code extension rework that fixes the send path (2xx status handling, 201 Created), moves tokens to SecretStorage, adds a real Chat sidebar with SSE streaming, and introduces CI for extension tests. The core direction is sound and the tests are a big improvement, but several correctness and security edge cases remain.

Findings

  • [WARNING] Streamed agent text can be replaced by the truncated summary on item.completed (extensions/vscode/src/transcript.ts:71)
    In transcript.ts, a terminal agent message computes its text as detail || item.summary || streamText || existing?.summary. The runtime may omit detail and still send the 280-character summary on completion. During a live stream this discards the full streamed text and renders the truncated stub, which contradicts the PR's stated transcript behavior.
  • [WARNING] checkConnection can report connected when /v1/runtime/info returns a non-2xx other than 401 (extensions/vscode/src/api.ts:141)
    After a successful /health call, checkConnection only checks whether the info request returned 401. If the info request times out (statusCode 0), returns 500, or otherwise fails, the code falls through to connected with an empty version. Add an isOk guard for the info response.
  • [WARNING] Runtime view thread summaries and restore points are no longer populated automatically (extensions/vscode/src/extension.ts:91)
    The old refreshAgentViewDetails populated statusView.updateThreads and statusView.updateSnapshots on connection and auto-refresh. The new checkAndRefreshRuntime only calls statusView.update(state) and chatView.refreshThreads(); snapshots refresh only when the explicit codewhale.refreshSnapshots command runs. The Runtime view remains visible, so its thread/snapshot panes can be empty or stale, contrary to the README's description of that view.
  • [WARNING] 409 ConflictError is typed but the chat send path still shows a generic failure (extensions/vscode/src/chat.ts)
    startTurn throws a typed ConflictError for a second send while a turn is live, but sendPrompt catches all errors and calls handleError, which shows a generic Send failed message. The PR description says a second send while a turn is live is reported as 'already running', but that user-facing translation is not implemented for the send path.
  • [WARNING] File path containment is lexical only and can be bypassed by workspace symlinks (extensions/vscode/src/transcript.ts)
    isInsideRoot resolves paths lexically but does not resolve symlinks. A repo-local symlink inside the workspace can point outside the workspace, so a model-supplied relative path could open an editor outside the workspace without triggering the explicit outside-workspace confirmation.
  • [INFO] Security-critical token precedence and migration have no automated coverage (extensions/vscode/src/secrets.ts)
    The new secrets.ts behavior is critical to the security fix: SecretStorage wins over settings, workspace-level tokens are ignored, and the legacy user token is migrated once. None of that behavior is unit tested. Given the PR's security focus, this should be pinned with tests; the chat/send state machine is also untested.
  • [INFO] Packaged extension includes dev-only .vscode launch/tasks configuration (extensions/vscode/.vscodeignore:1)
    .vscodeignore excludes source and tests but does not exclude .vscode/**. vsce package will therefore include launch.json and tasks.json in the VSIX, even though they are only for the local F5 development loop.

Suggestions

  • extensions/vscode/src/transcript.ts:71 — Prefer the already streamed full text over a completion payload's 280-char summary when a live agent message finishes.

        const text = detail || streamText || item.summary || existing?.summary || "";
    
  • extensions/vscode/src/api.ts:141 — Check all non-2xx info responses so a successful health check followed by an info timeout or 5xx cannot be reported as connected.

      if (info.statusCode === 401) {
        return { kind: "auth-required", detail: "Runtime info requires a token." };
      }
      if (!isOk(info.statusCode)) {
        return {
          kind: info.statusCode === 0 ? "offline" : "error",
          detail:
            info.statusCode === 0
              ? "Runtime info could not be reached."
              : `Runtime info returned HTTP ${info.statusCode}.`,
        };
      }
    
  • extensions/vscode/.vscodeignore:1 — Exclude the dev-loop .vscode directory from the packaged VSIX.

    src/**
    .vscode/**
    

Assessment

Approve with changes requested. The primary send-path fix and token storage migration are valuable and well-tested in the API layer, but the streamed transcript truncation, runtime info status guard, Runtime view population regression, and remaining symlink path containment gap should be addressed before merge. The package should also exclude the dev-only .vscode configuration.


Advisory review by Codewhale (codewhale review --pr 5987 --post, head 6482b58af2203067050cc500737359038df08d59). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.


if (item.kind === "agent_message") {
// `detail` carries the full reply; `summary` is a 280-char stub on reload.
const text = detail || item.summary || streamText || existing?.summary || "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Streamed agent text can be replaced by the truncated summary on item.completed

In transcript.ts, a terminal agent message computes its text as detail || item.summary || streamText || existing?.summary. The runtime may omit detail and still send the 280-character summary on completion. During a live stream this discards the full streamed text and renders the truncated stub, which contradicts the PR's stated transcript behavior.

const info = await requestJson(`${config.baseUrl}/v1/runtime/info`, config, {
timeoutMs: HEALTH_TIMEOUT_MS,
});
if (info.statusCode === 401) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] checkConnection can report connected when /v1/runtime/info returns a non-2xx other than 401

After a successful /health call, checkConnection only checks whether the info request returned 401. If the info request times out (statusCode 0), returns 500, or otherwise fails, the code falls through to connected with an empty version. Add an isOk guard for the info response.

case "connected":
updateStatus("$(check) CodeWhale", state.detail);
await refreshAgentViewDetails(false);
await chatView.refreshThreads();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Runtime view thread summaries and restore points are no longer populated automatically

The old refreshAgentViewDetails populated statusView.updateThreads and statusView.updateSnapshots on connection and auto-refresh. The new checkAndRefreshRuntime only calls statusView.update(state) and chatView.refreshThreads(); snapshots refresh only when the explicit codewhale.refreshSnapshots command runs. The Runtime view remains visible, so its thread/snapshot panes can be empty or stale, contrary to the README's description of that view.

@@ -0,0 +1,6 @@
src/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Packaged extension includes dev-only .vscode launch/tasks configuration

.vscodeignore excludes source and tests but does not exclude .vscode/**. vsce package will therefore include launch.json and tasks.json in the VSIX, even though they are only for the local F5 development loop.


if (item.kind === "agent_message") {
// `detail` carries the full reply; `summary` is a 280-char stub on reload.
const text = detail || item.summary || streamText || existing?.summary || "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer the already streamed full text over a completion payload's 280-char summary when a live agent message finishes.

Suggested change
const text = detail || item.summary || streamText || existing?.summary || "";
const text = detail || streamText || item.summary || existing?.summary || "";

Comment on lines +141 to +143
if (info.statusCode === 401) {
return { kind: "auth-required", detail: "Runtime info requires a token." };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check all non-2xx info responses so a successful health check followed by an info timeout or 5xx cannot be reported as connected.

Suggested change
if (info.statusCode === 401) {
return { kind: "auth-required", detail: "Runtime info requires a token." };
}
if (info.statusCode === 401) {
return { kind: "auth-required", detail: "Runtime info requires a token." };
}
if (!isOk(info.statusCode)) {
return {
kind: info.statusCode === 0 ? "offline" : "error",
detail:
info.statusCode === 0
? "Runtime info could not be reached."
: `Runtime info returned HTTP ${info.statusCode}.`,
};
}

@@ -0,0 +1,6 @@
src/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude the dev-loop .vscode directory from the packaged VSIX.

Suggested change
src/**
src/**
.vscode/**

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ide): upgrade codewhale-vscode from scaffold to a full Runtime API agent client (IDE stage 2)

2 participants