Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/mobile/src/state/followed-projects-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>(
Expand Down
57 changes: 53 additions & 4 deletions apps/mobile/src/state/workspace-selection-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
<QueryClientProvider client={queryClient}>
<FollowedProjectsProvider>
<Capture />
</FollowedProjectsProvider>
</QueryClientProvider>,
);
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({
Expand Down
27 changes: 27 additions & 0 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion packages/opencode-adapter/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
39 changes: 32 additions & 7 deletions packages/opencode-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/")) : ".";
Expand All @@ -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");
Expand All @@ -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("\\"),
)
) {
Expand Down
Loading