From a9fc79bcd22fb79c1ab75ff2524eecb736db5799 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Mon, 26 Oct 2026 00:57:48 +0100 Subject: [PATCH] Fix request coverage for removed external worktrees --- .../src/state/followed-projects-context.tsx | 3 +- .../workspace-selection-context.test.tsx | 57 +++++++++++++++++-- docs/COMPATIBILITY.md | 27 +++++++++ packages/opencode-adapter/src/index.test.ts | 42 +++++++++++++- packages/opencode-adapter/src/index.ts | 39 ++++++++++--- 5 files changed, 154 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/state/followed-projects-context.tsx b/apps/mobile/src/state/followed-projects-context.tsx index e995916..b4b6168 100644 --- a/apps/mobile/src/state/followed-projects-context.tsx +++ b/apps/mobile/src/state/followed-projects-context.tsx @@ -371,8 +371,7 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) ); const projectID = session?.projectID ?? inferLocationProjectID(candidate, projects); const project = projectID ? projectByID.get(projectID) : undefined; - if (!project || !candidate.directory.startsWith(`${project.canonical.replace(/\/$/, "")}/`)) - return undefined; + if (!project) return undefined; // Actual attention takes precedence over retirement; generic event locations // are discovery hints, not evidence of outstanding work. const permissionSnapshot = queryClient.getQueryData<{ data: PermissionRequest[] }>( diff --git a/apps/mobile/src/state/workspace-selection-context.test.tsx b/apps/mobile/src/state/workspace-selection-context.test.tsx index a78c143..382a093 100644 --- a/apps/mobile/src/state/workspace-selection-context.test.tsx +++ b/apps/mobile/src/state/workspace-selection-context.test.tsx @@ -571,10 +571,14 @@ test.each([ queryClient.clear(); }); -test.each([false, true])( - "retires missing directories and checks restoration, registered worktree: %s", - async (registered) => { - const directory = "/b/.worktree/retired"; +test.each([ + [false, "/b/.worktree/retired"], + [true, "/b/.worktree/retired"], + [false, "/external/worktree/project-b/retired"], + [true, "/external/worktree/project-b/retired"], +] satisfies [boolean, string][])( + "retires missing directories and checks restoration, registered: %s, directory: %s", + async (registered, directory) => { if (registered) mockSandboxes = [directory]; const listPermissions = mockListPermissions.getMockImplementation(); mockListPermissions.mockImplementation(async (client, location, options) => { @@ -813,6 +817,51 @@ test.each(["notification", "permission-event", "form-event", "cached-form"])( }, ); +test("cached attention prevents retirement of an external historical location", async () => { + const location = { directory: "/external/worktree/project-b/retired" }; + mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ + cursor: {}, + data: [ + projectID === "project-b" + ? mockSession("ses_beta", projectID, location.directory, 2) + : mockSession("ses_alpha", projectID, "/a", 3), + ], + })); + mockDirectoryExists.mockResolvedValue(false); + const original = mockListPermissions.getMockImplementation(); + mockListPermissions.mockImplementation(async (client, candidate, options) => { + if (candidate.directory === location.directory) throw new Error("HTTP 500"); + if (!original) throw new Error("Missing permission fixture"); + return original(client, candidate, options); + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + queryClient.setQueryData(openCodeQueryKeys.permissions("connection-1", location), { + data: [{ action: "shell", id: "per_beta", resources: [], sessionID: "ses_beta" }], + location: mockResolvedLocation(location.directory, "project-b"), + }); + const view = render( + + + + + , + ); + try { + await waitFor(() => expect(screen.getByText("freshness:current")).toBeOnTheScreen()); + await waitFor(() => expect(screen.getByText("incomplete:2:2:0")).toBeOnTheScreen()); + expect(mockDirectoryExists.mock.calls.some((call) => call[2] === location.directory)).toBe( + false, + ); + expect(screen.getByText("Beta:ses_beta:0:1")).toBeOnTheScreen(); + expect(screen.getByText(/^unavailable:Beta:/)).toBeOnTheScreen(); + } finally { + view.unmount(); + queryClient.clear(); + } +}); + test("an unavailable directory probe does not silently retire a valid session location", async () => { mockDirectoryExists.mockRejectedValue(new Error("HTTP 500")); const queryClient = new QueryClient({ diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 6036cce..d0a5598 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,33 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-10-25: removed external worktrees, pending device verification + +An iPhone screenshot showed failed permission and form checks for historical +worktrees outside their project roots. Read-only local filesystem checks found +the three referenced directories absent. A local server API probe confirmed +that listing an external parent through a live location succeeds, retains the +request location, and returns project-relative entry paths. This agrees with the +published V2 filesystem contract. No missing location was initialized. + +The existing retirement check only considered descendants of the project root. +It now also probes external historical worktrees through the live project root. +Only a successful, validated listing can establish absence. Active locations, +selected locations, notification attention, and cached pending requests retain +their existing protections. Session history remains visible, and refresh checks +for restoration. Failed probes do not establish deletion. + +Regression tests reproduce the incomplete-coverage warning for external +worktrees before the fix and verify retirement and restoration after it. +The full lint, typecheck, test, and build sequence passed, including 536 mobile +tests, 101 adapter tests, and both iOS and Android Hermes exports. Expo Doctor +passed all 18 checks. Physical-device verification of the fix remains pending. + +On 2026-10-26, both native fingerprints and runtime matched the installed signed +build baseline under the preview EAS environment before local OTA publication. +Local EAS Update publication then succeeded for iOS and Android on preview, +runtime 0.1.5. Publication does not establish installation or device behavior. + ## 2026-10-01: location-wide MCP runtime controls, pending device verification ### Switch contrast follow-up diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts index 8607887..f907ac2 100644 --- a/packages/opencode-adapter/src/index.test.ts +++ b/packages/opencode-adapter/src/index.test.ts @@ -240,7 +240,47 @@ describe("historical directory presence", () => { }, ); - it.each(["/elsewhere/retired", "/workspace/../retired", "/workspace/.worktree//retired"])( + it("checks external worktrees through their live project root", async () => { + const fixture = createFakeOpenCodeApi({ + directoryEntries: [{ path: "../external/worktree/live/", type: "directory" }], + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/external/worktree/retired"), + ).resolves.toBe(false); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/external/worktree/live"), + ).resolves.toBe(true); + expect( + fixture.requests.every((request) => request.query.path?.[0] === "../external/worktree"), + ).toBe(true); + }); + + it("preserves same-drive Windows directory checks", async () => { + const fixture = createFakeOpenCodeApi({ + directoryEntries: [{ path: "../worktree/live/", type: "directory" }], + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "C:\\workspace" }, "C:\\worktree\\live"), + ).resolves.toBe(true); + }); + + it.each([ + "live", + "/external/worktree/live", + "../other/live", + "../external/worktree/nested/live", + "../external/worktree/..", + ])("rejects ambiguous external directory entry %s", async (path) => { + const fixture = createFakeOpenCodeApi({ directoryEntries: [{ path, type: "directory" }] }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/external/worktree/retired"), + ).rejects.toThrow("MALFORMED_DIRECTORY_LIST"); + }); + + it.each(["/workspace/../retired", "/workspace/.worktree//retired", "relative/retired"])( "rejects unscoped candidate %s", async (directory) => { const fixture = createFakeOpenCodeApi(); diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts index 45ad7ee..c4aa6e5 100644 --- a/packages/opencode-adapter/src/index.ts +++ b/packages/opencode-adapter/src/index.ts @@ -451,10 +451,33 @@ export async function openCodeDirectoryExists( ) { const base = root.directory.replace(/\\/g, "/").replace(/\/$/, ""); const target = directory.replace(/\\/g, "/"); - if (!target.startsWith(`${base}/`) || root.workspaceID) + // Worktrees can live outside the repository. Keep the live root as the + // request location, but list the target's parent via a relative path. + const absoluteParts = (path: string) => { + if (!path.startsWith("/") && !/^[A-Za-z]:(\/|$)/.test(path)) { + throw new Error("INVALID_DIRECTORY_PATH"); + } + const parts = (path.startsWith("/") ? path.slice(1) : path).split("/"); + if (parts.some((part) => !part || part === "." || part === "..")) { + throw new Error("INVALID_DIRECTORY_PATH"); + } + return parts; + }; + if (root.workspaceID) throw new Error("DIRECTORY_OUTSIDE_PROJECT"); + const baseParts = base === "" ? [] : absoluteParts(base); + const targetParts = absoluteParts(target); + if ( + (/^[A-Za-z]:$/.test(baseParts[0] ?? "") || /^[A-Za-z]:$/.test(targetParts[0] ?? "")) && + baseParts[0] !== targetParts[0] + ) { throw new Error("DIRECTORY_OUTSIDE_PROJECT"); - const relative = target.slice(base.length + 1); - if (relative.split("/").some((part) => !part || part === "." || part === "..")) { + } + let shared = 0; + while (shared < baseParts.length && baseParts[shared] === targetParts[shared]) shared++; + const relative = [...baseParts.slice(shared).map(() => ".."), ...targetParts.slice(shared)].join( + "/", + ); + if (!relative || targetParts.length <= shared) { throw new Error("INVALID_DIRECTORY_PATH"); } const parent = relative.includes("/") ? relative.slice(0, relative.lastIndexOf("/")) : "."; @@ -463,10 +486,11 @@ export async function openCodeDirectoryExists( if ( output.location.directory !== root.directory || !Array.isArray(output.data) || - !output.data.every((entry) => - isRecord(entry) && typeof entry.path === "string" - ? isValidFileSystemEntry({ ...entry, path: entry.path.replace(/\/$/, "") }) - : false, + !output.data.every( + (entry) => + isRecord(entry) && + typeof entry.path === "string" && + (entry.type === "file" || entry.type === "directory"), ) ) { throw new Error("MALFORMED_DIRECTORY_LIST"); @@ -481,6 +505,7 @@ export async function openCodeDirectoryExists( !path.startsWith(prefix) || !path.slice(prefix.length) || path.slice(prefix.length).includes("/") || + !isSafeRelativeFilePath(path.slice(prefix.length)) || path.includes("\\"), ) ) {