From 00f5e92865598491ab803e3a322389007d57ff1b Mon Sep 17 00:00:00 2001 From: Eric Tsai <52527831+EricTsai83@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:14:29 +0800 Subject: [PATCH 1/9] fix(git): treat selected commit paths literally (#3998) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 18 ++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c6..ea8d03eb9718 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -620,6 +620,24 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(status, "a.txt"); }), ); + + it.effect("treats selected file paths literally", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); + yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); + + yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? selected1.txt"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fe659c2b3187..40486737b185 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1526,6 +1526,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "--literal-pathspecs", "add", "-A", "--", From be445a8640d28d004e4204afe3fc70aa26e80a1a Mon Sep 17 00:00:00 2001 From: Eric Tsai <52527831+EricTsai83@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:15:31 +0800 Subject: [PATCH 2/9] fix(server): stabilize non-repository Git diagnostics (#4077) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 73 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 20 +++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ea8d03eb9718..8c627525b4cf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6,6 +6,9 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -20,6 +23,21 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); +const makeNonRepositoryHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make("fatal: not a git repository")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -77,7 +95,62 @@ const initRepoWithCommit = ( return { initialBranch }; }); +it.effect("uses stable diagnostics for every parsed non-repository command", () => { + const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (!ChildProcess.isStandardCommand(command)) { + return assert.fail("expected a standard Git command"); + } + commands.push({ + args: command.args, + ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), + }); + return makeNonRepositoryHandle(); + }), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = "/repo"; + + yield* driver.statusDetailsLocal(cwd); + yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + yield* driver.listRefs({ cwd }); + + assert.deepStrictEqual(commands, [ + { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, + { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, + ]); + }).pipe(Effect.provide(layer)); +}); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("process environment", () => { + it.effect("preserves the caller locale for general Git subprocesses", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + + const locale = yield* git( + cwd, + ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], + { LC_ALL: "zh_CN.UTF-8" }, + ); + + assert.equal(locale, "zh_CN.UTF-8"); + }), + ); + }); + describe("structured errors", () => { it.effect("preserves structured spawn context and the platform cause", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 40486737b185..ea221e060248 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -832,6 +832,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const executeGitWithStableDiagnostics = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, { + ...options, + env: { + ...options.env, + LC_ALL: "C", + }, + }); + const runGit = ( operation: string, cwd: string, @@ -1186,7 +1200,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { - const branchResult = yield* executeGit( + const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, ["rev-parse", "--abbrev-ref", "HEAD"], @@ -1306,7 +1320,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { - const statusResult = yield* executeGit( + const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], @@ -1988,7 +2002,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.orElseSucceed(() => new Map()), ); - const localBranchResult = yield* executeGit( + const localBranchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], From bad6071a8766b60f0fe0d8817958559053653218 Mon Sep 17 00:00:00 2001 From: Kriday Dave Date: Sat, 18 Jul 2026 01:17:35 +0530 Subject: [PATCH 3/9] Fix duplicate keybinding rule when replacing with an existing rule (#3969) Co-authored-by: Julius Marminge --- apps/server/src/keybindings.test.ts | 22 ++++++++++++++++++++++ apps/server/src/keybindings.ts | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index a51ad20afbe8..2eef6ac84167 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -411,6 +411,28 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("replacing with a rule that already exists elsewhere does not duplicate it", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+alt+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "mod+r", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+alt+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 65a558c3936c..304726ecbaf6 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -658,7 +658,9 @@ const make = Effect.gen(function* () { const nextConfig = [ ...customConfig.filter((entry) => { if (replaceTarget) { - return !isSameKeybindingRule(entry, replaceTarget); + return ( + !isSameKeybindingRule(entry, replaceTarget) && !isSameKeybindingRule(entry, rule) + ); } return !isSameKeybindingRule(entry, rule); }), From bc0f3951b9019d6690581ac377c9aa708d9cd00e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 17 Jul 2026 12:47:52 -0700 Subject: [PATCH 4/9] fix(server): image upload crashed dispatchCommand with a stack overflow (#3952) Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge --- apps/server/src/imageMime.test.ts | 57 +++++++++++++++++++++++++ apps/server/src/imageMime.ts | 71 +++++++++++++++++++++++++++---- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index cc1cc2ff776b..e87e4dcbd0dc 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -32,6 +32,63 @@ describe("imageMime", () => { }); }); + it("rejects payload with characters outside the base64 alphabet", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull(); + }); + + it("rejects structurally malformed base64", () => { + // '=' before the trailing padding position + expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); + // more than two padding characters + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull(); + // length not a multiple of 4 + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull(); + }); + + it("accepts base64 with one or two trailing padding characters", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVsbA==")).toEqual({ + mimeType: "image/png", + base64: "SGVsbA==", + }); + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8h")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8h", + }); + }); + + it("rejects empty and whitespace-only payloads", () => { + expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull(); + }); + + it("parses a case-insensitive scheme and mime type", () => { + expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("parses a multi-megabyte payload from a deep call stack", () => { + // Regression: matching the payload with a regex borrowed the JS call + // stack, so a ~10 MB image parsed inside fiber execution threw + // "RangeError: Maximum call stack size exceeded". + const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; + const atDepth = (depth: number): ReturnType => + depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); + const findMaxDepth = (depth: number): number => { + try { + return findMaxDepth(depth + 1); + } catch { + return depth; + } + }; + const result = atDepth(Math.floor(findMaxDepth(0) * 0.85)); + expect(result?.mimeType).toBe("image/png"); + expect(result?.base64.length).toBe(14_000_000); + }); + it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abea..66ce6096e853 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +// Whether `code` is a character the base64 payload may contain, aside from +// the whitespace handled separately below. +function isBase64Char(code: number): boolean { + return ( + (code >= 0x61 && code <= 0x7a) || // a-z + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x30 && code <= 0x39) || // 0-9 + code === 0x2b || // + + code === 0x2f || // / + code === 0x3d // = + ); +} + +function isBase64Whitespace(code: number): boolean { + return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space +} + +// Data URLs carry the full image payload, so this parser must never run a +// regex across the payload: V8's regex engine borrows the JS call stack, and +// matching a multi-megabyte string from a deep call stack (e.g. inside fiber +// execution) throws "Maximum call stack size exceeded". export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { - const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); - if (!match) return null; + const trimmed = dataUrl.trim(); + if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null; + + const commaIndex = trimmed.indexOf(","); + if (commaIndex === -1) return null; + const header = trimmed.slice(5, commaIndex); + if (header.length === 0) return null; const headerParts: Array = []; - for (const part of (match[1] ?? "").split(";")) { - const trimmed = part.trim(); - if (trimmed.length > 0) { - headerParts.push(trimmed); + for (const part of header.split(";")) { + const partTrimmed = part.trim(); + if (partTrimmed.length > 0) { + headerParts.push(partTrimmed); } } if (headerParts.length < 2) { @@ -51,8 +77,37 @@ export function parseBase64DataUrl( } const mimeType = headerParts[0]?.toLowerCase(); - const base64 = match[2]?.replace(/\s+/g, ""); - if (!mimeType || !base64) return null; + if (!mimeType) return null; + + const payload = trimmed.slice(commaIndex + 1); + const runs: Array = []; + let runStart = -1; + for (let index = 0; index < payload.length; index += 1) { + const code = payload.charCodeAt(index); + if (isBase64Char(code)) { + if (runStart === -1) runStart = index; + continue; + } + if (!isBase64Whitespace(code)) return null; + if (runStart !== -1) { + runs.push(payload.slice(runStart, index)); + runStart = -1; + } + } + if (runStart !== -1) { + runs.push(payload.slice(runStart)); + } + const base64 = runs.length === 1 ? runs[0]! : runs.join(""); + if (base64.length === 0 || base64.length % 4 !== 0) return null; + const firstPad = base64.indexOf("="); + if (firstPad !== -1) { + // '=' is only valid as one or two trailing padding characters; Node's + // decoder would otherwise silently truncate at the first '='. + if (base64.length - firstPad > 2) return null; + for (let index = firstPad; index < base64.length; index += 1) { + if (base64.charCodeAt(index) !== 0x3d) return null; + } + } return { mimeType, base64 }; } From d0965f805a4fe6e088ea71d250025313283e01fb Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Fri, 17 Jul 2026 14:21:19 -0700 Subject: [PATCH 5/9] fix(terminal): strip AppImage runtime env from spawned terminals (#3108) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/server/src/terminal/Manager.test.ts | 57 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 49 +++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 3a1cabc4a270..1cf7e8dffeca 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1334,6 +1334,63 @@ it.layer( }), ); + it.effect("strips AppImage runtime env from terminal sessions", () => + Effect.gen(function* () { + const appDir = "/tmp/.mount_T3Codeabc123"; + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + APPIMAGE: "/home/user/T3-Code.AppImage", + APPDIR: appDir, + ARGV0: "/home/user/T3-Code.AppImage", + OWD: "/home/user/project", + PATH: `${appDir}/usr/bin:${appDir}:/usr/local/bin:/usr/bin:/bin`, + LD_LIBRARY_PATH: `${appDir}/usr/lib:/home/user/.local/lib`, + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + // AppImage runtime markers must never reach the PTY — tools inside the + // terminal otherwise resolve against the AppImage mount (e.g. PHP_BINARY + // reporting the AppImage path instead of the real binary). + expect(spawnInput.env.APPIMAGE).toBeUndefined(); + expect(spawnInput.env.APPDIR).toBeUndefined(); + expect(spawnInput.env.ARGV0).toBeUndefined(); + expect(spawnInput.env.OWD).toBeUndefined(); + // PATH/LD_LIBRARY_PATH keep the user's real entries but drop the AppImage + // mount segments that the runtime prepended. + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + // Unrelated host vars still pass through untouched. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); + }), + ); + + it.effect("leaves the environment untouched when not launched from an AppImage", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/local/bin:/usr/bin:/bin", + LD_LIBRARY_PATH: "/home/user/.local/lib", + // Without APPIMAGE/APPDIR set, OWD is an ordinary variable and must + // not be stripped — only an AppImage launch gives it special meaning. + OWD: "/home/user/keep-this", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + expect(spawnInput.env.OWD).toBe("/home/user/keep-this"); + }), + ); + it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6347fdfc64d6..caa5106bb9fd 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1065,6 +1065,53 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } +// Marker variables the AppImage runtime injects into the process it launches. +// They describe the AppImage itself, not the user's session, so terminals must +// not inherit them. +const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const; +// PATH-style variables the AppImage runtime prepends with its temporary mount +// (e.g. /tmp/.mount_T3-XXXX/usr/bin). Only the mount segments are dropped; the +// user's real entries are preserved. +const APPIMAGE_PATH_LIKE_ENV_KEYS = ["PATH", "LD_LIBRARY_PATH"] as const; + +function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { + return segment === appDir || segment.startsWith(`${appDir}/`); +} + +// On Linux AppImage builds the runtime mounts the app under a temporary dir and +// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. +// The integrated terminal inherits the server process environment, so without +// this scrub those leak into the PTY and tools resolve against the AppImage +// mount instead of the user's real environment (e.g. `php` reporting +// PHP_BINARY as the AppImage path). See issue #1699. The scrub is gated on an +// actual AppImage launch so non-AppImage environments are left untouched. +function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; + + const scrubbed: NodeJS.ProcessEnv = { ...env }; + for (const key of APPIMAGE_RUNTIME_ENV_KEYS) { + delete scrubbed[key]; + } + + const appDir = env.APPDIR?.replace(/\/+$/, ""); + if (appDir) { + for (const key of APPIMAGE_PATH_LIKE_ENV_KEYS) { + const value = scrubbed[key]; + if (value === undefined) continue; + const kept = value + .split(":") + .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir)); + if (kept.length > 0) { + scrubbed[key] = kept.join(":"); + } else { + delete scrubbed[key]; + } + } + } + + return scrubbed; +} + function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record | null, @@ -1080,7 +1127,7 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } - return spawnEnv; + return stripAppImageRuntimeEnv(spawnEnv); } function normalizedRuntimeEnv( From 0cbc9faebd70bc42c314b00f343aeb69e05e8a02 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 17 Jul 2026 14:26:22 -0700 Subject: [PATCH 6/9] fix(server): skip undecodable provider runtime rows when listing sessions (#3951) Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge Co-authored-by: codex --- .../src/persistence/ProviderSessionRuntime.ts | 26 +++++++++++++---- .../RepositoryErrorCorrelation.test.ts | 28 ++++++++++++------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index a3475d2f190b..2ccdd862522f 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -1,7 +1,9 @@ +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -280,18 +282,30 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((rows) => + // Skip rows that no longer decode (e.g. written by an older build) + // instead of failing the whole list — one stale row must not disable + // every consumer that enumerates sessions, such as the reaper. Effect.forEach(rows, (row) => decodeRuntimeRow(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:decodeRows", - cause, - { threadId: row.threadId }, - ), + Effect.map(Option.some), + Effect.catch((cause) => + Effect.logWarning("provider.session.runtime.row-skipped", { + threadId: row.threadId, + error: PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ).message, + }).pipe(Effect.as(Option.none())), ), ), ), ), + Effect.map((decoded) => + Arr.filterMap(decoded, (row) => + Option.isSome(row) ? Result.succeed(row.value) : Result.failVoid, + ), + ), ); const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts index f7425200fd1d..379b06e2a222 100644 --- a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -182,7 +182,7 @@ describe("persistence error correlation", () => { }).pipe(Effect.provide(authPairingLinkLayer)), ); - it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + it.effect("skips undecodable provider runtime rows and correlates SQL failures by thread", () => Effect.gen(function* () { const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const sql = yield* SqlClient.SqlClient; @@ -215,16 +215,24 @@ describe("persistence error correlation", () => { ) `; - const decodeError = yield* Effect.flip(runtimes.list()); - assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); - assert.deepStrictEqual(decodeError.correlation, { threadId }); - assert.equal( - decodeError.message, - `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + const validThreadId = ThreadId.make("thread-valid"); + yield* runtimes.upsert({ + threadId: validThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: null, + }); + + const listed = yield* runtimes.list(); + assert.deepStrictEqual( + listed.map((runtime) => runtime.threadId), + [validThreadId], ); - assert.notInclude(decodeError.issue, runtimePayload); - assert.notInclude(decodeError.message, runtimePayload); - assert.notInclude(decodeError.message, lastSeenAt); yield* sql`DROP TABLE provider_session_runtime`; const sqlFailure = yield* Effect.flip( From 161f02d95d866b2e626b610c034cb91586aeba46 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Jul 2026 23:26:53 +0200 Subject: [PATCH 7/9] Share MCP OAuth locks across Codex shadow homes (#4104) --- .../provider/Drivers/CodexHomeLayout.test.ts | 36 +++++++++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 39 ++++++++++++++----- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index ec78b1665ef5..03c717abb14d 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -114,6 +114,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const mcpOauthLocksTarget = yield* fileSystem.readLink( + path.join(shadowHome, "mcp-oauth-locks"), + ); const modelsCacheExists = yield* fileSystem.exists( path.join(shadowHome, "models_cache.json"), ); @@ -124,12 +127,45 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); }), ); + it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); + const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); + + yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const locksTarget = yield* fileSystem.readLink(shadowLocks); + const sharedLockExists = yield* fileSystem.exists( + path.join(sharedLocks, "file-store.lock"), + ); + + expect(locksTarget).toBe(sharedLocks); + expect(sharedLockExists).toBe(true); + }), + ); + it.effect("accepts Codex-created shadow-local runtime directories", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index d2d09e9d8440..92e923c0b669 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -26,10 +26,12 @@ const KNOWN_SHARED_DIRECTORIES = [ "plugins", "cache", "logs", + "mcp-oauth-locks", ] as const; const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); +const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = @@ -225,16 +227,6 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu linkPath: link, }); - if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, - }); - } - const createLink = input.fileSystem.symlink(target, link).pipe( Effect.catchTags({ PlatformError: (cause) => @@ -250,6 +242,33 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu }), ); + if (state._tag === "NotSymlink") { + if (!REPLACEABLE_SHARED_RUNTIME_DIRECTORIES.has(input.entryName)) { + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, + }); + } + + yield* input.fileSystem.remove(link, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + return yield* createLink; + } + if (state._tag === "Missing") { return yield* createLink; } From 5c087ea4bf3a748670bbbca931cb364d3de2c1f6 Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Sat, 18 Jul 2026 15:14:06 +0530 Subject: [PATCH 8/9] fix: address CodeRabbit review on image MIME and runtime list decode Accept tab as Base64 whitespace, and collect decoded session rows with Arr.getSomes instead of a Result-based Arr.filterMap conversion. --- apps/server/src/imageMime.ts | 2 +- apps/server/src/persistence/ProviderSessionRuntime.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 66ce6096e853..7ecf41222045 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -43,7 +43,7 @@ function isBase64Char(code: number): boolean { } function isBase64Whitespace(code: number): boolean { - return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space + return code === 0x09 || code === 0x0d || code === 0x0a || code === 0x20; // \t \r \n space } // Data URLs carry the full image payload, so this parser must never run a diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd862522f..d2318fb63b5e 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -3,7 +3,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -301,11 +300,7 @@ export const make = Effect.gen(function* () { ), ), ), - Effect.map((decoded) => - Arr.filterMap(decoded, (row) => - Option.isSome(row) ? Result.succeed(row.value) : Result.failVoid, - ), - ), + Effect.map((decoded) => Arr.getSomes(decoded)), ); const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( From 6320e7ff144cac88635bc6dd9014a69b81201a7a Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Sat, 18 Jul 2026 15:16:48 +0530 Subject: [PATCH 9/9] fix(terminal): scrub AppImage env before per-terminal overrides Keep explicit TerminalOpenInput APPDIR/PATH overrides when the server itself is not an AppImage, while still stripping AppImage mount markers from the inherited process environment. --- apps/server/src/terminal/Manager.test.ts | 25 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 8 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffeca..43e1068089e6 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1391,6 +1391,31 @@ it.layer( }), ); + it.effect("preserves explicit APPDIR overrides when not launched from an AppImage", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/bin:/bin", + HOME: "/home/user", + }, + }); + yield* manager.open( + openInput({ + env: { + APPDIR: "/custom/appdir", + MY_TOOL: "1", + }, + }), + ); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.APPDIR).toBe("/custom/appdir"); + expect(spawnInput.env.MY_TOOL).toBe("1"); + }), + ); + it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9fd..03effed02ae1 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1122,12 +1122,16 @@ function createTerminalSpawnEnv( if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + // Scrub AppImage markers from the server process env before applying + // explicit per-terminal overrides, so a caller-supplied APPDIR is preserved + // when the server itself is not running as an AppImage. + const scrubbed = stripAppImageRuntimeEnv(spawnEnv); if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + scrubbed[key] = value; } } - return stripAppImageRuntimeEnv(spawnEnv); + return scrubbed; } function normalizedRuntimeEnv(