From f7bc933d4037a27d0c849b46296c508742184bec Mon Sep 17 00:00:00 2001 From: chen362 <80262496+chen362@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:30:24 -0400 Subject: [PATCH 1/3] Add workspace request serialization queue --- src/request-serialization.ts | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/request-serialization.ts diff --git a/src/request-serialization.ts b/src/request-serialization.ts new file mode 100644 index 0000000..dcaba0e --- /dev/null +++ b/src/request-serialization.ts @@ -0,0 +1,83 @@ +export type RequestSerializationAccess = "exclusive" | "shared-read"; + +interface QueuedRequest { + access: RequestSerializationAccess; + run: () => Promise | T; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export class RequestSerializationQueues { + private readonly queues = new Map(); + private readonly draining = new Set(); + + enqueue( + key: string, + access: RequestSerializationAccess, + run: () => Promise | T, + ): Promise { + return new Promise((resolve, reject) => { + const queue = this.queues.get(key) ?? []; + queue.push({ access, run, resolve, reject }); + this.queues.set(key, queue); + this.drain(key); + }); + } + + private drain(key: string): void { + if (this.draining.has(key)) return; + this.draining.add(key); + + void this.drainLoop(key).finally(() => { + this.draining.delete(key); + const queue = this.queues.get(key); + if (queue && queue.length > 0) this.drain(key); + }); + } + + private async drainLoop(key: string): Promise { + while (true) { + const batch = this.takeNextBatch(key); + if (batch.length === 0) return; + + await Promise.all( + batch.map(async (request) => { + try { + request.resolve(await request.run()); + } catch (error) { + request.reject(error); + } + }), + ); + } + } + + private takeNextBatch(key: string): QueuedRequest[] { + const queue = this.queues.get(key); + if (!queue || queue.length === 0) { + this.queues.delete(key); + return []; + } + + const first = queue.shift(); + if (!first) return []; + + if (first.access === "exclusive") { + if (queue.length === 0) this.queues.delete(key); + return [first]; + } + + const batch = [first]; + while (queue[0]?.access === "shared-read") { + const next = queue.shift(); + if (next) batch.push(next); + } + + if (queue.length === 0) this.queues.delete(key); + return batch; + } +} + +export function workspaceQueueKey(workspaceId: string): string { + return `workspace:${workspaceId}`; +} From 96fe8d50dffcf51099715a1c7a336abfa46d9a6d Mon Sep 17 00:00:00 2001 From: chen362 <80262496+chen362@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:30:47 -0400 Subject: [PATCH 2/3] Add shell execution policy checks --- src/shell-policy.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/shell-policy.ts diff --git a/src/shell-policy.ts b/src/shell-policy.ts new file mode 100644 index 0000000..6b1637b --- /dev/null +++ b/src/shell-policy.ts @@ -0,0 +1,126 @@ +import type { ShellMode } from "./config.js"; + +export interface ShellPolicyDecision { + allowed: boolean; + mode: ShellMode; + reason?: string; +} + +const READ_ONLY_COMMANDS = new Set([ + "cat", + "df", + "du", + "file", + "find", + "git", + "grep", + "head", + "ls", + "pwd", + "rg", + "stat", + "tail", + "wc", +]); + +const READ_ONLY_GIT_SUBCOMMANDS = new Set([ + "branch", + "diff", + "grep", + "log", + "ls-files", + "remote", + "rev-parse", + "show", + "status", +]); + +const SHELL_CONTROL_PATTERNS = [/&&/, /\|\|/, /;/, /\|/, />/, / DESTRUCTIVE_FIND_FLAGS.has(word)); + if (destructiveFlag) { + return deny(mode, `DEVSPACE_SHELL_MODE=read-only blocked find flag '${destructiveFlag}'.`); + } + + return allow(mode); +} + +function hasShellControlOperator(command: string): boolean { + return SHELL_CONTROL_PATTERNS.some((pattern) => pattern.test(command)); +} + +function basename(command: string): string { + return (command.split(/[\\/]/).pop() ?? command).toLowerCase(); +} + +function allow(mode: ShellMode): ShellPolicyDecision { + return { allowed: true, mode }; +} + +function deny(mode: ShellMode, reason: string): ShellPolicyDecision { + return { allowed: false, mode, reason }; +} From 4dfeb24d05efcfeaf038c77ae29805753640601f Mon Sep 17 00:00:00 2001 From: chen362 <80262496+chen362@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:31:00 -0400 Subject: [PATCH 3/3] Add shared tool result envelope types --- src/tool-result.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/tool-result.ts diff --git a/src/tool-result.ts b/src/tool-result.ts new file mode 100644 index 0000000..e53f3e6 --- /dev/null +++ b/src/tool-result.ts @@ -0,0 +1,75 @@ +export type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +export interface ToolResponse { + content: ToolContent[]; + details?: TDetails; + isError?: boolean; +} + +export interface ToolResultEnvelope, TDetails = unknown> { + ok: boolean; + tool: string; + workspaceId?: string; + path?: string; + summary?: TSummary; + content: ToolContent[]; + details?: TDetails; + diff?: string; + diagnostics?: string[]; + truncated?: boolean; + durationMs: number; +} + +export function textContent(text: string): ToolContent[] { + return [{ type: "text", text }]; +} + +export function toolError(message: string): ToolResponse { + return { content: textContent(message), isError: true }; +} + +export function contentText(content: ToolContent[]): string { + return content.map((item) => (item.type === "text" ? item.text : `[image:${item.mimeType}]`)).join("\n"); +} + +export function textSummary(text: string, maxLength = 220): string { + const compact = text.replace(/\s+/g, " ").trim(); + if (compact.length <= maxLength) return compact; + return `${compact.slice(0, maxLength - 1)}…`; +} + +export function contentLineCount(content: ToolContent[]): number | undefined { + const text = contentText(content); + if (!text) return undefined; + return text.split("\n").length; +} + +export function makeToolResultEnvelope, TDetails = unknown>(input: { + ok: boolean; + tool: string; + workspaceId?: string; + path?: string; + summary?: TSummary; + content: ToolContent[]; + details?: TDetails; + diff?: string; + diagnostics?: string[]; + truncated?: boolean; + startedAt: number; +}): ToolResultEnvelope { + return { + ok: input.ok, + tool: input.tool, + workspaceId: input.workspaceId, + path: input.path, + summary: input.summary, + content: input.content, + details: input.details, + diff: input.diff, + diagnostics: input.diagnostics, + truncated: input.truncated, + durationMs: Date.now() - input.startedAt, + }; +}