From 034032bf1511c7b575258766ba0246405c94a6ca Mon Sep 17 00:00:00 2001 From: Adam Cheng <63501289+627150795@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:36:34 +0800 Subject: [PATCH] fix(web): preserve session worktree executable bits under strict umask --- tests/web/session-worktree.test.ts | 23 ++++++++++++++++++- web/runtime/session-worktree.ts | 37 +++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/tests/web/session-worktree.test.ts b/tests/web/session-worktree.test.ts index cf11d9721..f03d2d7ce 100644 --- a/tests/web/session-worktree.test.ts +++ b/tests/web/session-worktree.test.ts @@ -93,6 +93,15 @@ function fixture(t: TestContext) { return { root, repo }; } +async function createWithUmask(cwd: string, commandId: string, umask: number) { + const originalUmask = process.umask(umask); + try { + return await createSessionWorktree(cwd, commandId); + } finally { + process.umask(originalUmask); + } +} + // The wrapper still executes real Git. It introduces a competing change at a // precise process boundary, without adding production-only test hooks. function gitBoundary( @@ -134,10 +143,22 @@ test("forks source HEAD detached and copies final tracked, untracked, binary, ex const beforeStage = stagedIndex(repo); const beforeStatus = git(repo, "status", "--porcelain=v1", "-z"); const head = git(repo, "rev-parse", "HEAD").trim(); - const result = await createSessionWorktree( + const result = await createWithUmask( path.join(repo, "packages", "app"), "fork-dirty", + 0o022, ); + if (process.platform !== "win32") { + const restrictive = await createWithUmask( + repo, + "fork-restrictive-umask", + 0o077, + ); + assert.equal( + fs.statSync(path.join(restrictive.worktree.path, "run.sh")).mode & 0o111, + 0o111, + ); + } assert.equal(result.cwd, path.join(result.worktree.path, "packages", "app")); // Native realpath expands Windows 8.3 aliases such as RUNNER~1. assert.equal(result.worktree.sourceRoot, fs.realpathSync.native(repo)); diff --git a/web/runtime/session-worktree.ts b/web/runtime/session-worktree.ts index 73d6b2d8e..50de61556 100644 --- a/web/runtime/session-worktree.ts +++ b/web/runtime/session-worktree.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { + chmod, lstat, mkdir, mkdtemp, @@ -522,13 +523,19 @@ async function snapshot( environment, ); } - validateIndex( - await git( - source.sourceRoot, - ["ls-files", "--stage", "-z"], - context, - environment, - ), + const entries = await git( + source.sourceRoot, + ["ls-files", "--stage", "-z"], + context, + environment, + ); + validateIndex(entries); + const executableFiles = paths( + entries + .split("\0") + .filter((entry) => entry.startsWith("100755 ")) + .map((entry) => entry.slice(entry.indexOf("\t") + 1)) + .join("\0"), ); const tree = ( await git(source.sourceRoot, ["write-tree"], context, environment) @@ -549,7 +556,7 @@ async function snapshot( environment, ); const ignored = await includedIgnored(source.sourceRoot, context); - return { tree, patch, ignored, ignoredTracked: existing }; + return { tree, patch, ignored, ignoredTracked: existing, executableFiles }; } finally { await rm(index, { force: true }); await rm(`${index}.lock`, { force: true }); @@ -737,6 +744,20 @@ export async function createSessionWorktree( mutationContext, ); } + if (process.platform !== "win32") { + for (const file of frozen.executableFiles) { + check(mutationContext); + const target = join(worktreePath, file); + const info = await lstat(target); + if (!info.isFile()) + throw new SessionWorktreeError( + "changed", + "A tracked executable snapshot file changed during the fork.", + ); + const mode = info.mode & 0o777; + if ((mode & 0o111) !== 0o111) await chmod(target, mode | 0o111); + } + } for (const file of frozen.ignored) { await copyIncludedFile( source.sourceRoot,