From 7810fb263f5a92b9d438d1eabf408d2ac705e944 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 13:47:51 -0700 Subject: [PATCH 1/7] Add new GitHub user 'yordis' to VOUCHED.td (#12546) --- .github/VOUCHED.td | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 2f9faba635e3..08e5cfb6cc06 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -80,3 +80,5 @@ github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 +github:yordis + From f9e8f578fa0fb1ea25e458334a28bbb4fe22302d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 14:50:12 -0700 Subject: [PATCH 2/7] chore: vouch cestercian (#12638) Co-authored-by: Claude Fable 5 --- .github/VOUCHED.td | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 08e5cfb6cc06..6781356cf58d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -18,6 +18,7 @@ github:bil0000 github:binbandit github:Brechard github:btsouth +github:cestercian github:chrisdeeming github:chuks-qua github:cursoragent @@ -81,4 +82,3 @@ github:Yash-Singh1 github:yashranaway github:Ymit24 github:yordis - From dcf8942304cfef382af55c0068cb2f04c86757fc Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:38:53 +0530 Subject: [PATCH 3/7] fix(web): keep desktop annotation screenshots under CSP (#12636) --- .../components/preview/PreviewView.test.tsx | 12 +-- .../src/components/preview/PreviewView.tsx | 2 +- apps/web/src/lib/previewAnnotation.test.ts | 74 +++++++------------ apps/web/src/lib/previewAnnotation.ts | 45 ++++------- 4 files changed, 44 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index ea28a93235ee..e5b40c907b14 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => ({ openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), pickElement: vi.fn(), - capturePreviewAnnotationScreenshot: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), toggleAnnotation: null as (() => void) | null, @@ -90,10 +89,6 @@ vi.mock("~/composerDraftStore", () => ({ }), })); -vi.mock("~/lib/previewAnnotation", () => ({ - capturePreviewAnnotationScreenshot: mocks.capturePreviewAnnotationScreenshot, -})); - vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(), })); @@ -342,8 +337,6 @@ describe("PreviewView navigation", () => { mocks.openPictureInPicture.mockClear(); mocks.closePictureInPicture.mockClear(); mocks.pickElement.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" }); mocks.addPreviewAnnotation.mockClear(); vi.mocked(toastManager.add).mockClear(); mocks.addImage.mockClear(); @@ -585,7 +578,7 @@ describe("PreviewView navigation", () => { expect(toastManager.add).toHaveBeenCalledTimes(1); }); - it("still sends when the picked element's crop cannot be captured", async () => { + it("still sends annotation text when the picked element's crop is malformed", async () => { const annotation = { id: "annotation-2", pageUrl: "https://example.com/dashboard", @@ -596,7 +589,7 @@ describe("PreviewView navigation", () => { strokes: [], styleChanges: [], screenshot: { - dataUrl: "data:image/png;base64,c2NyZWVuc2hvdA==", + dataUrl: "data:image/png;base64,%%%", width: 10, height: 10, cropRect: { x: 0, y: 0, width: 10, height: 10 }, @@ -605,7 +598,6 @@ describe("PreviewView navigation", () => { }; const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" }); renderToStaticMarkup( { afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("returns the crop when the fetch resolves", async () => { - vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" }))); - const capture = await capturePreviewAnnotationScreenshot(annotation); + it("preserves the PNG bytes, MIME type, and filename without fetching", async () => { + const fetch = vi.fn().mockRejectedValue(new TypeError("Blocked by connect-src")); + vi.stubGlobal("fetch", fetch); + const capture = capturePreviewAnnotationScreenshot(annotation); expect(capture.status).toBe("captured"); + if (capture.status !== "captured") throw new Error("Expected a screenshot file"); + expect(capture.file.name).toBe("preview-annotation-annotation_1.png"); + expect(capture.file.type).toBe("image/png"); + expect(Buffer.from(await capture.file.arrayBuffer()).toString("hex")).toBe( + "89504e470d0a1a0a0000000d4948445200000001000000010804000000b51c0c020000000b4944415478da63fcff1f0003030200efa2a75b0000000049454e44ae426082", + ); + expect(fetch).not.toHaveBeenCalled(); }); - it("reports none when the annotation carries no crop", async () => { - const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); + it("reports none when the annotation carries no crop", () => { + const capture = capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); expect(capture).toEqual({ status: "none" }); }); - it("fails instead of hanging when the crop never arrives", async () => { - vi.useFakeTimers(); - vi.stubGlobal("fetch", () => new Promise(() => {})); - const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000); - await vi.advanceTimersByTimeAsync(1_000); - expect(await capturePromise).toEqual({ status: "failed" }); - }); - - it("fails when the crop fetch throws", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("data url unreadable"); - }); - expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" }); - }); + it.each(["data:image/jpeg;base64,AA==", "data:image/png;base64,", "data:image/png;base64,%%%"])( + "reports a malformed screenshot as failed: %s", + (dataUrl) => { + const picked = { ...annotation, screenshot: { ...annotation.screenshot!, dataUrl } }; + expect(capturePreviewAnnotationScreenshot(picked)).toEqual({ status: "failed" }); + }, + ); }); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 1b4a449e5098..e3dda16f121f 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -1,49 +1,30 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; -async function previewAnnotationScreenshotFile( - annotation: PreviewAnnotationPayload, -): Promise { - if (!annotation.screenshot) return null; - const response = await fetch(annotation.screenshot.dataUrl); - const blob = await response.blob(); - return new File([blob], `preview-annotation-${annotation.id}.png`, { - type: blob.type || "image/png", - }); -} - -/** Upper bound on turning a picked element's crop into a composer attachment. */ -const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +import { dataUrlToFile } from "./imageCompression"; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ | { readonly status: "captured"; readonly file: File } /** The pick carried no crop, which is normal for comment-only annotations. */ | { readonly status: "none" } - /** The crop stalled or threw. Send the annotation without it. */ + /** The crop could not be decoded. Send the annotation without it. */ | { readonly status: "failed" }; -/** - * Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the - * composer while this runs, so it must always settle: a stalled crop resolves as - * `failed` instead of leaving the caller waiting. - */ -export async function capturePreviewAnnotationScreenshot( +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; + +/** Decode Electron's PNG crop locally; fetching a data URL violates desktop connect-src. */ +export function capturePreviewAnnotationScreenshot( annotation: PreviewAnnotationPayload, - timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS, -): Promise { +): PreviewAnnotationCapture { if (!annotation.screenshot) return { status: "none" }; - let timer: ReturnType | undefined; try { - const file = await Promise.race([ - previewAnnotationScreenshotFile(annotation), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs); - }), - ]); - return file ? { status: "captured", file } : { status: "failed" }; + const { dataUrl } = annotation.screenshot; + if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + return { status: "failed" }; + } + const file = dataUrlToFile(dataUrl, `preview-annotation-${annotation.id}.png`, "image/png"); + return file.size > 0 ? { status: "captured", file } : { status: "failed" }; } catch { return { status: "failed" }; - } finally { - clearTimeout(timer); } } From e36725682bd2935f1bcfdf81660cb569b5f3df29 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:39:00 +0530 Subject: [PATCH 4/7] fix(web): keep typed text when a question option is clicked (#12577) --- apps/web/src/components/ChatView.tsx | 14 +++++++++ apps/web/src/components/chat/ChatComposer.tsx | 22 +++++++++----- apps/web/src/composer-logic.test.ts | 30 +++++++++++++++++++ apps/web/src/composer-logic.ts | 12 ++++++++ apps/web/src/pendingUserInput.test.ts | 20 +++++++++++++ apps/web/src/pendingUserInput.ts | 22 ++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79d393fc8d1f..667eb0e11f6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,6 +148,7 @@ import { } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId @@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + composerDraftTarget, composerRef, + pendingUserInputAnswersByRequestId, + setComposerDraftPrompt, ], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8f91f08c7093..255f61a0c752 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -72,6 +72,7 @@ import { type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + // The question is gone and the editor shows the thread draft again. The + // ref still holds the last answer text, and Send reads the ref. Place + // the caret at the end so the next keystroke appends. + if (lastSyncedPendingInputRef.current !== null) { + promptRef.current = prompt; + const { cursor, trigger } = composerStateAtPromptEnd(prompt); + setComposerCursor(cursor); + setComposerTrigger(trigger); + } lastSyncedPendingInputRef.current = null; return; } @@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); + const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); + setComposerCursor(cursor); + setComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index d4509516cba7..83f143a546fd 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -18,6 +19,7 @@ import { parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; +import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; import { formatTerminalContextReference } from "./lib/terminalContext"; const terminalReference = formatTerminalContextReference({ @@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => { }); }); +describe("composerStateAtPromptEnd", () => { + it("puts the caret at the end of a restored parked draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half"); + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: null, + }); + }); + + it("collapses mention chips so the next keystroke lands after the draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please"); + + expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length); + }); + + it("keeps a trailing mention trigger when the restored draft ends with @", () => { + const prompt = "look at @"; + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length }, + }); + }); +}); + describe("collapseExpandedComposerCursor", () => { it("keeps cursor unchanged when no mention segment is present", () => { expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index a2ed12b86b9e..ff638a6d2e46 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -265,6 +265,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +/** Caret and trigger after replacing composer text and continuing at the end. */ +export function composerStateAtPromptEnd(text: string): { + cursor: number; + trigger: ComposerTrigger | null; +} { + const cursor = collapseExpandedComposerCursor(text, text.length); + return { + cursor, + trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + }; +} + export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | null { diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index f2f093a5c826..350087c3aaff 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, resolvePendingUserInputAnswer, @@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => { }), ).toBeNull(); }); + +describe("carryDisplacedCustomAnswerIntoPrompt", () => { + it("keeps the thread draft when nothing was typed into the answer", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft"); + expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft"); + }); + + it("moves the typed answer into an empty thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe( + "also rename the flag", + ); + }); + + it("appends the typed answer after an existing thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe( + "first half\n\nsecond half", + ); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 5ac004649a27..5d22e56105aa 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer( }; } +const DISPLACED_ANSWER_SEPARATOR = "\n\n"; + +/** + * Selecting an option replaces the custom answer, because a non-empty custom + * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the + * user typed into the answer field must not vanish on that click: it moves back + * into the thread draft, after whatever was already waiting there. + */ +export function carryDisplacedCustomAnswerIntoPrompt( + prompt: string, + customAnswer: string | undefined, +): string { + const displaced = customAnswer?.trim() ?? ""; + if (displaced.length === 0) { + return prompt; + } + if (prompt.trim().length === 0) { + return displaced; + } + return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; +} + export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, From 63ff33756c47f127391962458d8388a647e8ed44 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:39:05 +0530 Subject: [PATCH 5/7] fix(server): empty Claude homePath shares continuation with ~/.claude (#12624) --- .../src/provider/Drivers/ClaudeDriver.ts | 11 ++++- .../src/provider/Drivers/ClaudeHome.test.ts | 40 +++++++++++++------ .../server/src/provider/Drivers/ClaudeHome.ts | 28 +++++++++++-- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..b87a89dcf528 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -135,7 +135,10 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), ); - const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); + const continuationGroupKey = yield* makeClaudeContinuationGroupKey( + effectiveConfig, + processEnv, + ); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, @@ -171,7 +174,11 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( + effectiveConfig, + cwd, + processEnv, + ); // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 33b11237547e..fb1caf753f86 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -15,13 +15,20 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { - it.effect("uses the process home when no Claude home override is configured", () => + it.effect("treats empty, ~/.claude, and the expanded default as the same Claude home", () => Effect.gen(function* () { const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); + const resolved = path.resolve(path.join(NodeOS.homedir(), ".claude")); expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* resolveClaudeHomePath({ homePath: "~/.claude" })).toBe(resolved); + expect(yield* resolveClaudeHomePath({ homePath: resolved })).toBe(resolved); expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); + + const key = `claude:home:${resolved}`; + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(key); + expect(yield* makeClaudeContinuationGroupKey({ homePath: "~/.claude" })).toBe(key); + expect(yield* makeClaudeContinuationGroupKey({ homePath: resolved })).toBe(key); }), ); @@ -40,6 +47,24 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); + it.effect("uses inherited CLAUDE_CONFIG_DIR when homePath is empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const inherited = path.resolve("/tmp/claude-inherited"); + const environment = { CLAUDE_CONFIG_DIR: inherited }; + + expect(yield* resolveClaudeHomePath({ homePath: "" }, environment)).toBe(inherited); + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" }, environment)).toBe( + `claude:home:${inherited}`, + ); + + const explicit = path.resolve(NodeOS.homedir(), ".claude-work"); + expect(yield* resolveClaudeHomePath({ homePath: "~/.claude-work" }, environment)).toBe( + explicit, + ); + }), + ); + it("points the signed-out hint at the configured Claude home", () => { expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain( "run `claude auth login`", @@ -59,16 +84,5 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(first).not.toBe(second); }), ); - - it.effect("keeps continuation compatible across instances with the same Claude HOME", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); - - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( - `claude:home:${resolved}`, - ); - }), - ); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bbd005a1e00b..70699ca669e8 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -9,12 +9,28 @@ import { expandHomePath } from "../../pathExpansion.ts"; const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +/** + * Resolve the Claude config directory the CLI would use: the instance's + * `homePath` (exported as `CLAUDE_CONFIG_DIR`), then an inherited + * `CLAUDE_CONFIG_DIR`, then Claude's default `~/.claude`. Empty must not + * fall back to bare `$HOME` — that leftover from the old HOME override + * produced a different continuation group than an explicit `~/.claude`. + */ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( config: Pick, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const path = yield* Path.Path; const homePath = config.homePath.trim(); - return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); + if (homePath.length > 0) { + return path.resolve(expandHomePath(homePath)); + } + // Inherited env vars are not shell-expanded, so a literal `~` stays literal. + const inherited = environment?.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (inherited.length > 0) { + return path.resolve(inherited); + } + return path.resolve(path.join(NodeOS.homedir(), ".claude")); }); export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( @@ -38,8 +54,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }); export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( - function* (config: Pick): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); + function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); return `claude:home:${resolvedHomePath}`; }, ); @@ -48,8 +67,9 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC function* ( config: Pick, cwd?: string, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); + const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; }, ); From 52d08a14b9e475371658fabbb12c056c3d3dad67 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:39:10 +0530 Subject: [PATCH 6/7] fix(desktop): include SnapShot app text for Flatpak and GTK4 (#12635) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../src/snapShot/DesktopSnapShot.test.ts | 138 ++++++++++++++++++ .../src/snapShot/SnapShotAccessibility.ts | 32 +++- apps/desktop/src/snapShot/snapShot.test.ts | 39 +++++ apps/desktop/src/snapShot/snapShot.ts | 26 +++- 4 files changed, 224 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index c724bb396cd8..205a72a55265 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -29,6 +29,7 @@ beforeEach(() => { transitionCapturePageMock.mockReset().mockResolvedValue(undefined); transitionSnapshotMock.mockReset().mockResolvedValue(undefined); prepareCaptureRevealMock.mockReset(); + accessibilityListMock.mockReset().mockResolvedValue([]); }); const { @@ -39,6 +40,7 @@ const { accessibilityProcessCoolMock, accessibilityProcessReadMock, accessibilityByPidMock, + accessibilityListMock, accessibilityForegroundMock, accessibilityTrustedMock, allWindowsMock, @@ -87,6 +89,7 @@ const { } >(), accessibilityByPidMock: vi.fn(), + accessibilityListMock: vi.fn(), accessibilityForegroundMock: vi.fn(), accessibilityTrustedMock: vi.fn((_prompt = false) => true), allWindowsMock: vi.fn( @@ -430,6 +433,7 @@ import * as SnapShotAccessibility from "./SnapShotAccessibility.ts"; // Tests hand it this stand-in so the mocks above drive window lookups. const accessibilityApp = { byPid: accessibilityByPidMock, + list: accessibilityListMock, foreground: accessibilityForegroundMock, } as unknown as Parameters[0]; const readAccessibleWindowContext = ( @@ -2654,6 +2658,140 @@ it.each([ } }); +it("reads Flatpak app text from the AT-SPI proxy when the compositor PID misses", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ + name: "Issue — Zen Browser", + children: [{ name: "New Tab", children: [] }], + })); + const proxyWindow = { + role: "window", + name: "Issue — Zen Browser", + bounds, + tree, + children: async () => [], + }; + accessibilityByPidMock + .mockReset() + .mockRejectedValue( + new Error("XA11Y_SELECTOR_NOT_MATCHED: No element matched selector: application[pid=207651]"), + ); + accessibilityListMock.mockReset().mockResolvedValue([ + { + pid: 1, + children: async () => [ + { + role: "window", + name: "Files", + bounds, + tree: async () => ({ name: "Files", children: [] }), + }, + ], + }, + { pid: 207646, children: async () => [proxyWindow] }, + ]); + try { + assert.strictEqual( + await readAccessibleWindowText( + { + title: "Issue — Zen Browser", + bounds: { x: 0, y: 0, width: 1_920, height: 1_048 }, + owner: { processId: 207651 }, + }, + "linux", + "Issue — Zen Browser", + ), + "Issue — Zen Browser\nNew Tab", + ); + assert.deepEqual(accessibilityByPidMock.mock.calls, [[207651, { timeout: 0 }]]); + assert.lengthOf(accessibilityListMock.mock.calls, 1); + assert.lengthOf(tree.mock.calls, 1); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + +it("reads GTK4 app text from one unnamed PID-scoped window of the captured size", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ + name: "System Monitor", + children: [{ name: "CPU", children: [] }], + })); + accessibilityByPidMock.mockReset().mockResolvedValue({ + children: async () => [ + { + role: "group", + name: null, + bounds, + tree, + children: async () => [ + { role: "page_tab_list", name: "Processes", bounds, children: async () => [] }, + ], + }, + ], + }); + accessibilityListMock.mockReset().mockResolvedValue([]); + try { + assert.strictEqual( + await readAccessibleWindowText( + { + title: "System Monitor", + bounds: { x: 12, y: 48, width: 1_920, height: 1_048 }, + owner: { processId: 210600 }, + }, + "linux", + "System Monitor", + ), + "System Monitor\nCPU", + ); + assert.deepEqual(accessibilityByPidMock.mock.calls, [[210600, { timeout: 0 }]]); + assert.lengthOf(accessibilityListMock.mock.calls, 0); + assert.lengthOf(tree.mock.calls, 1); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + +it("does not guess GTK4 app text when two unnamed PID windows share the captured size", async () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 }; + const tree = vi.fn(async () => ({ value: "Wrong window", children: [] })); + accessibilityByPidMock.mockReset().mockResolvedValue({ + children: async () => [ + { role: "group", name: null, bounds, tree }, + { role: "group", name: null, bounds: { ...bounds, x: 12 }, tree }, + ], + }); + accessibilityListMock.mockReset().mockResolvedValue([ + { + pid: 99, + children: async () => [{ role: "window", name: "System Monitor", bounds, tree }], + }, + ]); + try { + assert.isUndefined( + await readAccessibleWindowText( + { + title: "System Monitor", + bounds: { x: 12, y: 48, width: 1_920, height: 1_048 }, + owner: { processId: 210600 }, + }, + "linux", + "System Monitor", + ), + ); + assert.lengthOf(accessibilityListMock.mock.calls, 0); + assert.lengthOf(tree.mock.calls, 0); + } finally { + accessibilityListMock.mockReset().mockResolvedValue([]); + vi.unstubAllEnvs(); + } +}); + it.each([20, 1_350, 2_999])( "includes accessibility text as soon as a %d ms read completes", async (duration) => { diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index bb6cb95952b3..b0a0fe7b959c 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -97,6 +97,23 @@ function accessibilityReadSnapshot( ) as CapturedWindowAccessibilityContext; } +type AccessibilityElement = Awaited["children"]>>[number]; + +async function windowsForPid( + App: AccessibilityApp, + processId: number, +): Promise { + return await App.byPid(processId, { timeout: 0 }) + .then((app) => app.children()) + .catch(() => []); +} + +/** Flatpak apps reach AT-SPI through xdg-dbus-proxy, so the compositor PID misses. */ +async function windowsFromAppList(App: AccessibilityApp): Promise { + const apps = await App.list().catch(() => []); + return (await Promise.all(apps.map((app) => app.children().catch(() => [])))).flat(); +} + async function readCapturedWindowAccessibility( App: AccessibilityApp, request: SnapShotAccessibilityRequest, @@ -105,17 +122,24 @@ async function readCapturedWindowAccessibility( ): Promise { const { active, platform, sourceTitle, imageSize } = request; const foreground = platform === "win32" ? await App.foreground({ timeout: 0 }) : undefined; - const windows = + const pidWindows = foreground !== undefined ? foreground.pid === active.owner.processId ? [foreground.asElement()] : [] - : await (await App.byPid(active.owner.processId, { timeout: 0 })).children(); + : await windowsForPid(App, active.owner.processId); const matchMode = isWaylandSession(platform, process.env) ? "wayland" : "screen-bounds"; + const captured = { + title: active.title, + sourceTitle, + bounds: active.bounds, + clientBounds: active.clientBounds, + }; const window = findAccessibleWindow( - windows, - { title: active.title, sourceTitle, bounds: active.bounds, clientBounds: active.clientBounds }, + pidWindows.length > 0 || foreground !== undefined ? pidWindows : await windowsFromAppList(App), + captured, matchMode, + { allowUntitledUniqueBounds: foreground === undefined && pidWindows.length > 0 }, ); if (!window) { onStarted(); diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 6960718759aa..634c4653a5a6 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -464,6 +464,45 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, { ...captured, title: "" }, "wayland")).toBeUndefined(); }); + it("accepts one PID-scoped untitled window whose bounds match", () => { + const windows = [{ name: null, bounds: captured.bounds }]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBe(windows[0]); + expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); + }); + + it("rejects ambiguous PID-scoped untitled windows even when bounds match", () => { + const windows = [ + { name: null, bounds: captured.bounds }, + { name: "", bounds: { ...captured.bounds, x: 0, y: 0 } }, + ]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + + it("does not fall back to a differently titled PID-scoped window with matching bounds", () => { + const windows = [{ name: "Preferences", bounds: captured.bounds }]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + + it("does not use unique bounds when a titled match is already ambiguous", () => { + const windows = [ + { name: "Editor", bounds: captured.bounds }, + { name: "Editor", bounds: { ...captured.bounds, x: 0, y: 0 } }, + ]; + + expect( + findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), + ).toBeUndefined(); + }); + it.each(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])( "ignores a leading Wayland title spinner frame %s", (frame) => { diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 35c240c6e09a..2cfa406d1781 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -480,6 +480,7 @@ export function findAccessibleWindow< readonly clientBounds?: WindowBounds | undefined; }, matchMode: "screen-bounds" | "wayland" = "screen-bounds", + options: { readonly allowUntitledUniqueBounds?: boolean } = {}, ): T | undefined { const normalizeTitle = (value: string) => { const title = value.trim(); @@ -489,7 +490,6 @@ export function findAccessibleWindow< const titles = new Set( [captured.title, captured.sourceTitle ?? ""].map(normalizeTitle).filter(Boolean), ); - if (titles.size === 0) return undefined; // Wayland accessibility providers can expose window size without a screen position. const boundsKeys = matchMode === "wayland" @@ -499,19 +499,31 @@ export function findAccessibleWindow< matchMode === "wayland" && captured.clientBounds ? [captured.bounds, captured.clientBounds] : [captured.bounds]; - const matches = windows.filter((window) => { + const matchesBounds = (window: T) => { const bounds = window.bounds; return ( - titles.has(normalizeTitle(window.name ?? "")) && bounds !== null && candidateBounds.some((candidate) => boundsKeys.every((key) => Math.abs(bounds[key] - candidate[key]) <= 2), ) ); - }); - if (matches.length === 1) return matches[0]; - const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); - return activeMatches.length === 1 ? activeMatches[0] : undefined; + }; + if (titles.size > 0) { + const matches = windows.filter( + (window) => titles.has(normalizeTitle(window.name ?? "")) && matchesBounds(window), + ); + if (matches.length === 1) return matches[0]; + const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true); + if (activeMatches.length === 1) return activeMatches[0]; + if (matches.length > 1) return undefined; + } + // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped + // lookup can accept the one window whose bounds match; size-only guesses cannot. + if (!options.allowUntitledUniqueBounds) return undefined; + const boundsMatches = windows.filter( + (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), + ); + return boundsMatches.length === 1 ? boundsMatches[0] : undefined; } const ELECTRON_KEY_NAMES: Readonly> = { From d6f291303ddc0c9a14f570266a4d9eff6d431593 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sun, 20 Sep 2026 03:42:03 +0530 Subject: [PATCH 7/7] fix(server): surface ACP stderr when cursor-agent exits at session start (#12625) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../Layers/ProviderCommandReactor.ts | 5 ++ .../src/provider/Layers/CursorAdapter.test.ts | 43 ++++++++++++++ .../provider/acp/AcpAdapterSupport.test.ts | 36 ++++++++++++ .../src/provider/acp/AcpAdapterSupport.ts | 5 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 20 +++++++ .../src/provider/acp/AcpSessionRuntime.ts | 42 ++++++++++++-- .../server/src/provider/acp/AcpStderr.test.ts | 56 +++++++++++++++++++ apps/server/src/provider/acp/AcpStderr.ts | 38 +++++++++++++ packages/effect-acp/src/errors.ts | 8 ++- 9 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpStderr.test.ts create mode 100644 apps/server/src/provider/acp/AcpStderr.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 80cf2a1f4761..6a42b7c67ae1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -36,6 +36,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { + ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterValidationError, ProviderWorkspaceMissingError, @@ -64,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); @@ -373,6 +375,9 @@ const make = Effect.gen(function* () { if (isProviderAdapterRequestError(failReason?.error)) { return failReason.error.detail; } + if (isProviderAdapterProcessError(failReason?.error)) { + return failReason.error.detail; + } if (isProviderAdapterValidationError(failReason?.error)) { return failReason.error.issue; } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 4c3ad4b04551..6b6aeff17f35 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -543,6 +543,49 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("surfaces cursor-agent cli.json schema stderr instead of a closed-session error", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-cli-json-")), + ); + const wrapperPath = writeFakeCli({ + directory: workspace, + name: "fake-cursor-agent", + source: [ + "process.stderr.write(`Invalid project config at ${process.cwd()}/.cursor/cli.json: schema validation failed. [`", + " + JSON.stringify({", + ' code: "unrecognized_keys",', + ' keys: ["approvalMode", "sandbox"],', + " path: [],", + " message: \"Unrecognized key(s) in object: 'approvalMode', 'sandbox'\",", + ' }) + "]\\n");', + "process.exit(1);", + ].join("\n"), + }); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const error = yield* adapter + .startSession({ + threadId: ThreadId.make("cursor-cli-json-schema"), + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.include(error.message, "cli.json"); + assert.include(error.message, "Unrecognized key"); + assert.notInclude(error.message, "adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "approvalMode"); + assert.include(error.detail, "sandbox"); + } + }), + ); + it.effect("maps app plan mode onto the ACP plan session mode", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index 0aebe0ca6d82..60f971edb499 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -25,4 +25,40 @@ describe("AcpAdapterSupport", () => { expect(error._tag).toBe("ProviderAdapterRequestError"); expect(error.message).toContain("Invalid params"); }); + + it("maps ACP process exits without stderr to a process error instead of a closed session", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/start", + new EffectAcpErrors.AcpProcessExitedError({ code: 1 }), + ); + + expect(error._tag).toBe("ProviderAdapterProcessError"); + expect(error.message).not.toContain("adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + expect(error.detail).toBe("ACP process exited with code 1"); + } + }); + + it("maps ACP process exits to a process error whose detail includes stderr", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/start", + new EffectAcpErrors.AcpProcessExitedError({ + code: 1, + stderr: + "Invalid project config at ~/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'", + }), + ); + + expect(error._tag).toBe("ProviderAdapterProcessError"); + expect(error.message).toContain("cli.json"); + expect(error.message).toContain("Unrecognized key"); + expect(error.message).not.toContain("adapter thread is closed"); + if (error._tag === "ProviderAdapterProcessError") { + expect(error.detail).toContain("Unrecognized key(s): 'approvalMode', 'sandbox'"); + } + }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd99..b90ce88e83dc 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -7,8 +7,8 @@ import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; import { + ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, type ProviderAdapterError, } from "../Errors.ts"; const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); @@ -21,9 +21,10 @@ export function mapAcpToAdapterError( error: EffectAcpErrors.AcpError, ): ProviderAdapterError { if (isAcpProcessExitedError(error)) { - return new ProviderAdapterSessionClosedError({ + return new ProviderAdapterProcessError({ provider, threadId, + detail: error.message, cause: error, }); } diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 57323e2675e0..45a5cadb9a31 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -368,6 +368,26 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("attaches child stderr when the ACP process exits before initialize", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + command: process.execPath, + args: [ + "-e", + "process.stderr.write(\"Invalid project config at /tmp/project/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'\\n\"); process.exit(1);", + ], + }, + }); + const error = yield* runtime.start().pipe(Effect.flip); + expect(error._tag).toBe("AcpProcessExitedError"); + expect(error.message).toContain("cli.json"); + expect(error.message).toContain("Unrecognized key"); + expect(error.message).not.toContain("ACP process exited with code 1\nACP process exited"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("drains large stderr output and keeps auth-sized logging chunks", () => Effect.gen(function* () { const lengths: Array = []; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 70106bd16783..77517c44ea9b 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -22,6 +22,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; import { collectSessionConfigOptionValues, decideToolCallUpdateEmission, @@ -354,6 +355,8 @@ export const make = ( ); const stoppingRef = yield* Ref.make(false); const stderrFailure = yield* Deferred.make(); + const stderrTailRef = yield* Ref.make(""); + const stderrDrained = yield* Deferred.make(); const runtimeClosed = yield* Deferred.make(); const promptSerializationSemaphore = yield* Semaphore.make(1); const promptDispatchSemaphore = yield* Semaphore.make(1); @@ -374,22 +377,45 @@ export const make = ( } }); + const enrichProcessExitWithStderr = ( + error: EffectAcpErrors.AcpError, + ): Effect.Effect => + error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 + ? Effect.succeed(error) + : Deferred.await(stderrDrained).pipe( + Effect.timeout("250 millis"), + Effect.ignore, + Effect.andThen(Ref.get(stderrTailRef)), + Effect.map((tail) => { + const stderr = sanitizeAcpStderrExcerpt(tail); + return stderr.length === 0 + ? error + : new EffectAcpErrors.AcpProcessExitedError({ + ...(error.code !== undefined ? { code: error.code } : {}), + ...(error.pid !== undefined ? { pid: error.pid } : {}), + stderr, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }); + }), + ); + const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( error: EffectAcpErrors.AcpError, ) { if (yield* Ref.get(stoppingRef)) { return; } + const enriched = yield* enrichProcessExitWithStderr(error); const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => Option.isSome(current) ? ([false, current] as const) - : ([true, Option.some(error)] as const), + : ([true, Option.some(enriched)] as const), ); if (!firstTermination) { return; } yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error }); + yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); }); const logRequest = (event: AcpSessionRequestLogEvent) => @@ -406,6 +432,9 @@ export const make = ( ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) : effect ).pipe( + Effect.catch((error) => + enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), + ), Effect.tap((result) => logRequest({ method, @@ -453,10 +482,10 @@ export const make = ( yield* child.stderr.pipe( Stream.decodeText(), Stream.runForEach((chunk) => - (options.onStderr - ? options.onStderr(chunk.slice(-maxStderrChunkLength)) - : Effect.void - ).pipe( + Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( + Effect.andThen( + options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, + ), Effect.catch((error) => Effect.gen(function* () { yield* Deferred.fail(stderrFailure, error); @@ -466,6 +495,7 @@ export const make = ( ), ), ), + Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), Effect.ignore, Effect.forkIn(runtimeScope), ); diff --git a/apps/server/src/provider/acp/AcpStderr.test.ts b/apps/server/src/provider/acp/AcpStderr.test.ts new file mode 100644 index 000000000000..e88b59bdb0e1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpStderr.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ACP_STDERR_TAIL_MAX_CHARS, + appendAcpStderrTail, + sanitizeAcpStderrExcerpt, +} from "./AcpStderr.ts"; + +describe("AcpStderr", () => { + it("keeps a bounded tail of stderr chunks", () => { + const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); + expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); + }); + + it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "Invalid project config at /Users/ada/.cursor/cli.json", + "Authorization: Bearer secret-token-value", + "Visit http://localhost:5733/pair#token=ABCDEF for pairing", + "key=sk-abcdefghijklmnopqrstuv", + ].join("\n"), + { HOME: "/Users/ada" }, + ); + + expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); + expect(excerpt).toContain("Bearer [redacted]"); + expect(excerpt).toContain("[pairing-url]"); + expect(excerpt).toContain("[redacted]"); + expect(excerpt).not.toContain("secret-token-value"); + expect(excerpt).not.toContain("ABCDEF"); + expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); + }); + + it("redacts hyphenated OpenAI project keys and header credentials", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", + "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", + "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", + "Authorization: Basic dXNlcjpwYXNz", + "x-api-key: ant-api-key-value", + ].join("\n"), + ); + + expect(excerpt).toContain("[redacted]"); + expect(excerpt).toContain("Authorization: Basic [redacted]"); + expect(excerpt).toContain("x-api-key: [redacted]"); + expect(excerpt).not.toContain("sk-proj-"); + expect(excerpt).not.toContain("sk-svcacct-"); + expect(excerpt).not.toContain("sk-ant-api03-"); + expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); + expect(excerpt).not.toContain("dXNlcjpwYXNz"); + expect(excerpt).not.toContain("ant-api-key-value"); + }); +}); diff --git a/apps/server/src/provider/acp/AcpStderr.ts b/apps/server/src/provider/acp/AcpStderr.ts new file mode 100644 index 000000000000..52f68bb4c19a --- /dev/null +++ b/apps/server/src/provider/acp/AcpStderr.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. +import * as NodeOS from "node:os"; + +/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ +export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; + +const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; +const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; +const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; +const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; +const SECRET_TOKEN_PATTERN = + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + +export function appendAcpStderrTail(current: string, chunk: string): string { + const next = `${current}${chunk}`; + return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); +} + +/** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ +export function sanitizeAcpStderrExcerpt( + text: string, + environment: NodeJS.ProcessEnv = process.env, +): string { + let result = text.replaceAll("\0", ""); + const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( + (value): value is string => typeof value === "string" && value.length > 1, + ); + for (const home of new Set(homes)) { + result = result.split(home).join("~"); + } + result = result + .replace(PAIRING_URL_PATTERN, "[pairing-url]") + .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") + .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") + .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") + .replace(SECRET_TOKEN_PATTERN, "[redacted]"); + return result.trim(); +} diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index a2e343d67664..01f296bb8cb6 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -95,13 +95,15 @@ export class AcpProcessExitedError extends Schema.TaggedError 0 ? `${base}\n${excerpt}` : base; } }