setUseRegex((current) => !current)}
>
@@ -191,6 +200,7 @@ function OpenContentSearchDialog(props: {
}
inputProps={{
className: "pe-30",
+ disabled: !search.canReadFiles,
placeholder: `Search in ${target.projectName}`,
onKeyDown: (event) => {
if (event.key === "ArrowDown" && matches.length > 0) {
@@ -239,9 +249,13 @@ function OpenContentSearchDialog(props: {
{matches.length === 0 ? (
- {search.hasQuery && !search.isPending && !search.error
- ? "No results found."
- : "Type to search across your project."}
+ {search.isCheckingAccess
+ ? "Checking file access…"
+ : !search.canReadFiles
+ ? search.error
+ : search.hasQuery && !search.isPending && !search.error
+ ? "No results found."
+ : "Type to search across your project."}
) : (
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 82241469446d..96f45a31e95a 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -21,12 +21,14 @@ import {
AuthOrchestrationReadScope,
AuthRelayReadScope,
AuthRelayWriteScope,
- AuthReviewWriteScope,
AuthSourceControlWriteScope,
+ AuthFilesystemReadScope,
+ AuthFilesystemWriteScope,
AuthStandardClientScopes,
AuthTerminalOperateScope,
type AuthClientSession,
type AuthEnvironmentScope,
+ type AuthGrantScope,
type AuthPairingLink,
type AuthPairingCredentialResult,
type AdvertisedEndpoint,
@@ -187,19 +189,19 @@ function formatAccessTimestamp(value: string): string {
}
const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
- readonly scope: AuthEnvironmentScope;
+ readonly scope: AuthGrantScope;
readonly title: string;
readonly description: string;
}> = [
{
scope: AuthOrchestrationReadScope,
title: "View environment",
- description: "Read threads, status, diffs, and configuration.",
+ description: "Read threads, status, checkpoints, and configuration.",
},
{
scope: AuthOrchestrationOperateScope,
title: "Operate tasks",
- description: "Start tasks and perform changes in the environment.",
+ description: "Start, update, and stop tasks.",
},
{
scope: AuthSettingsWriteScope,
@@ -227,9 +229,14 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
description: "Commit, push, manage branches and repositories, and change pull requests.",
},
{
- scope: AuthReviewWriteScope,
- title: "Write reviews",
- description: "Create comments while reviewing changes.",
+ scope: AuthFilesystemReadScope,
+ title: "Read files",
+ description: "Browse host files, search workspaces, and inspect local changes.",
+ },
+ {
+ scope: AuthFilesystemWriteScope,
+ title: "Write files",
+ description: "Edit workspace files and save plans to disk.",
},
{
scope: AuthAccessReadScope,
@@ -1057,7 +1064,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
const primaryEnvironmentId = usePrimaryEnvironmentId();
const [dialogOpen, setDialogOpen] = useState(false);
const [pairingLabel, setPairingLabel] = useState("");
- const [pairingScopes, setPairingScopes] = useState>([
+ const [pairingScopes, setPairingScopes] = useState>([
...AuthStandardClientScopes,
]);
const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope));
@@ -1097,7 +1104,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
}
}, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
- const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
+ const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => {
setPairingScopes((current) =>
checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope),
);
@@ -1169,9 +1176,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
disabled={isCreatingPairingLink}
onClick={() =>
setPairingScopes(
- delegatableScopes.includes(AuthOrchestrationReadScope)
- ? [AuthOrchestrationReadScope]
- : [],
+ [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) =>
+ delegatableScopes.includes(scope),
+ ),
)
}
>
diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts
index 35e914ccdc52..c6d1beb6d49e 100644
--- a/apps/web/src/environments/primary/auth.ts
+++ b/apps/web/src/environments/primary/auth.ts
@@ -2,6 +2,7 @@ import type {
AuthBrowserSessionResult,
AuthClientMetadata,
AuthEnvironmentScope,
+ AuthGrantScope,
AuthPairingCredentialResult,
ServerAuthSessionMethod,
AuthSessionId,
@@ -363,7 +364,7 @@ export async function submitServerAuthCredential(credential: string): Promise;
+ readonly scopes?: ReadonlyArray;
}): Promise {
const trimmedLabel = input?.label?.trim();
try {
diff --git a/apps/web/src/state/filesystem.ts b/apps/web/src/state/filesystem.ts
index 19d5b53c4e09..c2a5d1212f20 100644
--- a/apps/web/src/state/filesystem.ts
+++ b/apps/web/src/state/filesystem.ts
@@ -1,5 +1,25 @@
-import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem";
+import {
+ createFilesystemEnvironmentAtoms,
+ resolveFilesystemReadAccess,
+} from "@t3tools/client-runtime/state/filesystem";
+import type { EnvironmentId } from "@t3tools/contracts";
import { connectionAtomRuntime } from "../connection/runtime";
+import { useEnvironmentPresentation } from "./presentation";
+import { useEnvironmentQuery } from "./query";
+import { environmentSession } from "./session";
export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime);
+
+export function useFilesystemReadAccess(environmentId: EnvironmentId | null) {
+ const session = useEnvironmentQuery(
+ environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
+ );
+ const environment = useEnvironmentPresentation(environmentId);
+ return resolveFilesystemReadAccess({
+ isCatalogReady: environment.isReady,
+ connection: environment.presentation?.connection ?? null,
+ session: session.data,
+ sessionError: session.error,
+ });
+}
diff --git a/apps/web/src/state/queries.filesystem.test.ts b/apps/web/src/state/queries.filesystem.test.ts
new file mode 100644
index 000000000000..7cff8b1e5ada
--- /dev/null
+++ b/apps/web/src/state/queries.filesystem.test.ts
@@ -0,0 +1,241 @@
+import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts";
+import { beforeEach, expect, it, vi } from "vite-plus/test";
+
+const state = vi.hoisted(() => ({
+ session: null as Pick | null,
+ sessionError: null as string | null,
+ sessionWaiting: false,
+ phase: "connected" as "connected" | "offline",
+ sessionAtom: {},
+ searchAtom: {},
+ contentAtom: {},
+ contentRequests: vi.fn(),
+ contentError: null as string | null,
+ contentData: {
+ matches: [{ path: "src/index.ts", lineNumber: 3, lineContent: "a match", matchRanges: [] }],
+ truncated: false,
+ },
+}));
+
+vi.mock("react", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useCallback: (callback: A) => callback,
+ useEffect: () => {},
+ useMemo: (factory: () => A) => factory(),
+ useState: (value: A) => [value, vi.fn()],
+}));
+vi.mock("./session", () => ({
+ environmentSession: { sessionStateAtom: () => state.sessionAtom },
+}));
+vi.mock("./presentation", () => ({
+ useEnvironmentPresentation: () => ({
+ isReady: true,
+ presentation: { connection: { phase: state.phase, error: null } },
+ }),
+}));
+vi.mock("./projects", () => ({
+ projectEnvironment: { searchEntries: () => state.searchAtom },
+ projectContentSearch: (target: unknown) => {
+ state.contentRequests(target);
+ return state.contentAtom;
+ },
+}));
+vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
+vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} }));
+vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() }));
+vi.mock("./vcs", () => ({ vcsEnvironment: {} }));
+vi.mock("./query", () => ({
+ useEnvironmentQuery: (atom: unknown) => ({
+ data:
+ atom === state.sessionAtom
+ ? state.session
+ : atom === state.searchAtom
+ ? { entries: [{ path: "src/index.ts", kind: "file" }] }
+ : atom === state.contentAtom
+ ? state.contentData
+ : null,
+ error:
+ atom === state.sessionAtom
+ ? state.sessionError
+ : atom === state.contentAtom
+ ? state.contentError
+ : null,
+ isPending:
+ atom === state.sessionAtom &&
+ (state.sessionWaiting || (state.session === null && state.sessionError === null)),
+ refresh: vi.fn(),
+ }),
+}));
+
+import { useProjectContentSearch, useProjectPathSearch } from "./queries";
+
+const useComposerPathSearch = (input: Parameters[0]) =>
+ useProjectPathSearch(input, 20);
+
+const target = {
+ environmentId: EnvironmentId.make("test-environment"),
+ cwd: "/repo",
+ query: "src",
+};
+
+beforeEach(() => {
+ state.session = null;
+ state.sessionError = null;
+ state.sessionWaiting = false;
+ state.phase = "connected";
+ state.contentRequests.mockClear();
+ state.contentError = null;
+});
+
+it("keeps a file search pending until its grant loads, then shows matches", () => {
+ expect(useComposerPathSearch(target)).toMatchObject({
+ entries: [],
+ error: null,
+ isPending: true,
+ });
+ state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
+ expect(useComposerPathSearch(target)).toMatchObject({
+ entries: [{ path: "src/index.ts", kind: "file" }],
+ error: null,
+ isPending: false,
+ });
+});
+
+it("shows a confirmed denial and a connection failure separately", () => {
+ state.session = { authenticated: true, scopes: [] };
+ expect(useComposerPathSearch(target)).toMatchObject({
+ entries: [],
+ error: "This connection cannot search host files.",
+ isPending: false,
+ });
+ state.session = null;
+ state.phase = "offline";
+ expect(useComposerPathSearch(target)).toMatchObject({
+ entries: [],
+ error: "This environment is not connected.",
+ isPending: false,
+ });
+});
+
+it("leaves an inactive search idle while its grant loads", () => {
+ expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({
+ entries: [],
+ error: null,
+ isPending: false,
+ });
+});
+
+const contentTarget = {
+ ...target,
+ query: " a match ",
+ caseSensitive: true,
+ wholeWord: true,
+ useRegex: false,
+};
+
+it("waits for content-search access before issuing a request, including an empty search", () => {
+ for (const query of ["", contentTarget.query]) {
+ const result = useProjectContentSearch({ ...contentTarget, query });
+ expect(state.contentRequests).not.toHaveBeenCalled();
+ expect(result).toMatchObject({
+ canReadFiles: false,
+ isCheckingAccess: true,
+ matches: [],
+ error: null,
+ isPending: true,
+ });
+ }
+ expect(state.contentRequests).not.toHaveBeenCalled();
+});
+
+it("shows content-search denial before typing and never issues an unauthorized request", () => {
+ state.session = { authenticated: true, scopes: [] };
+ for (const query of ["", contentTarget.query]) {
+ const result = useProjectContentSearch({ ...contentTarget, query });
+ expect(state.contentRequests).not.toHaveBeenCalled();
+ expect(result).toMatchObject({
+ canReadFiles: false,
+ isCheckingAccess: false,
+ matches: [],
+ error: "This connection cannot search host files.",
+ isPending: false,
+ });
+ }
+ expect(state.contentRequests).not.toHaveBeenCalled();
+});
+
+it("preserves content query whitespace and options when access is granted", () => {
+ state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
+ expect(useProjectContentSearch(contentTarget)).toMatchObject({
+ matches: state.contentData.matches,
+ error: null,
+ isPending: false,
+ });
+ expect(state.contentRequests).toHaveBeenCalledWith({
+ environmentId: contentTarget.environmentId,
+ input: {
+ cwd: contentTarget.cwd,
+ query: contentTarget.query,
+ limit: 500,
+ caseSensitive: true,
+ wholeWord: true,
+ useRegex: false,
+ },
+ });
+});
+
+it("keeps confirmed content access during revalidation and drops results when it is revoked", () => {
+ state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
+ state.sessionWaiting = true;
+ expect(useProjectContentSearch(contentTarget)).toMatchObject({
+ matches: state.contentData.matches,
+ isPending: false,
+ });
+
+ state.contentRequests.mockClear();
+ state.sessionWaiting = false;
+ state.session = { authenticated: true, scopes: [] };
+ const result = useProjectContentSearch(contentTarget);
+ expect(state.contentRequests).not.toHaveBeenCalled();
+ expect(result).toMatchObject({
+ canReadFiles: false,
+ matches: [],
+ error: "This connection cannot search host files.",
+ isPending: false,
+ });
+ expect(state.contentRequests).not.toHaveBeenCalled();
+});
+
+it("fails closed after a session check fails, even with a cached grant and matches", () => {
+ state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
+ state.sessionError = "The session has expired.";
+ const result = useProjectContentSearch(contentTarget);
+ expect(state.contentRequests).not.toHaveBeenCalled();
+ expect(result).toMatchObject({
+ canReadFiles: false,
+ matches: [],
+ error: state.sessionError,
+ isPending: false,
+ });
+ expect(state.contentRequests).not.toHaveBeenCalled();
+});
+
+it("keeps inactive content search idle and reports a disconnected target without querying", () => {
+ expect(
+ useProjectContentSearch({ ...contentTarget, environmentId: null, cwd: null }),
+ ).toMatchObject({
+ matches: [],
+ error: null,
+ isPending: false,
+ });
+ state.phase = "offline";
+ const result = useProjectContentSearch(contentTarget);
+ expect(state.contentRequests).not.toHaveBeenCalled();
+ expect(result).toMatchObject({
+ canReadFiles: false,
+ matches: [],
+ error: "This environment is not connected.",
+ isPending: false,
+ });
+ expect(state.contentRequests).not.toHaveBeenCalled();
+});
diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts
index 094db94c4dcf..edbdce8aa0ae 100644
--- a/apps/web/src/state/queries.ts
+++ b/apps/web/src/state/queries.ts
@@ -1,3 +1,6 @@
+import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
+import { environmentSession } from "./session";
+import { useEnvironmentPresentation } from "./presentation";
import { useAtomValue } from "@effect/atom-react";
import {
type CheckpointDiffTarget,
@@ -270,12 +273,25 @@ export function useProjectPathSearch(
[target.cwd, target.environmentId, target.imageOnly, target.kind, target.query],
);
const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
- const result = useEnvironmentQuery(
+ const fileAccessSession = useEnvironmentQuery(
+ debouncedTarget.environmentId === null
+ ? null
+ : environmentSession.sessionStateAtom(debouncedTarget.environmentId),
+ );
+ const fileEnvironment = useEnvironmentPresentation(debouncedTarget.environmentId);
+ const fileAccess = resolveFilesystemReadAccess({
+ isCatalogReady: fileEnvironment.isReady,
+ connection: fileEnvironment.presentation?.connection ?? null,
+ session: fileAccessSession.data,
+ sessionError: fileAccessSession.error,
+ });
+ const { canReadFiles } = fileAccess;
+ const searchTarget =
debouncedTarget.environmentId !== null &&
- debouncedTarget.cwd !== null &&
- debouncedTarget.query !== null &&
- (allowEmptyQuery || debouncedTarget.query.length > 0)
- ? projectEnvironment.searchEntries({
+ debouncedTarget.cwd !== null &&
+ debouncedTarget.query !== null &&
+ (allowEmptyQuery || debouncedTarget.query.length > 0)
+ ? {
environmentId: debouncedTarget.environmentId,
input: {
cwd: debouncedTarget.cwd,
@@ -284,15 +300,24 @@ export function useProjectPathSearch(
...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),
},
- })
- : null,
+ }
+ : null;
+ const result = useEnvironmentQuery(
+ canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null,
);
+ const hasTarget = searchTarget !== null;
return {
entries: result.data?.entries ?? [],
- error: result.error,
+ error:
+ !hasTarget || fileAccess.isPending
+ ? null
+ : canReadFiles
+ ? result.error
+ : (fileAccess.error ?? "This connection cannot search host files."),
isPending:
- !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,
+ !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) ||
+ (hasTarget && (fileAccess.isPending || result.isPending)),
searchedQuery: debouncedTarget.query ?? "",
refresh: result.refresh,
};
@@ -312,13 +337,29 @@ interface ProjectContentSearchTarget {
}
export function useProjectContentSearch(target: ProjectContentSearchTarget) {
+ const hasTarget = target.environmentId !== null && target.cwd !== null;
+ const fileAccessSession = useEnvironmentQuery(
+ target.environmentId === null
+ ? null
+ : environmentSession.sessionStateAtom(target.environmentId),
+ );
+ const fileEnvironment = useEnvironmentPresentation(target.environmentId);
+ const fileAccess = resolveFilesystemReadAccess({
+ isCatalogReady: fileEnvironment.isReady,
+ connection: fileEnvironment.presentation?.connection ?? null,
+ session: fileAccessSession.data,
+ sessionError: fileAccessSession.error,
+ });
+ const canReadFiles = hasTarget && fileAccess.canReadFiles;
+ const isCheckingAccess = hasTarget && fileAccess.isPending;
// Whitespace is significant in content queries; trimming is only used to
// decide whether the input is blank.
const query = target.query;
const hasQuery = query.trim().length > 0;
const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS);
const result = useEnvironmentQuery(
- target.environmentId !== null &&
+ canReadFiles &&
+ target.environmentId !== null &&
target.cwd !== null &&
hasQuery &&
debouncedQuery.trim().length > 0
@@ -337,9 +378,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) {
);
return {
+ canReadFiles,
+ isCheckingAccess,
matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES,
- error: result.error,
- isPending: hasQuery && (query !== debouncedQuery || result.isPending),
+ error:
+ !hasTarget || isCheckingAccess
+ ? null
+ : canReadFiles
+ ? result.error
+ : (fileAccess.error ?? "This connection cannot search host files."),
+ isPending:
+ isCheckingAccess ||
+ (canReadFiles && hasQuery && (query !== debouncedQuery || result.isPending)),
hasQuery,
truncated: result.data?.truncated ?? false,
invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined,
diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md
index 8cb65ad6d69a..2239432ae6dd 100644
--- a/docs/internals/environment-auth.md
+++ b/docs/internals/environment-auth.md
@@ -45,7 +45,7 @@ do not follow this replacement rule.
## The environment is the filesystem boundary
Projects are organizational boundaries, not filesystem sandboxes.
-`orchestration:read` permits reading files the server account can read, including
+`filesystem:read` permits reading files the server account can read, including
absolute paths outside a project. This lets clients display artifacts that an
agent writes in a temporary directory. Relative paths and writes still follow
the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts).
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 8de5ca89a139..1c3d419b0fe8 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -164,6 +164,10 @@ permissions. Existing clients keep their original grants after an update; to
receive newly separated permissions, pair the client again with the scopes it
needs. Reconnecting or refreshing a session does not expand its grant.
+`filesystem:read` allows browsing host files, opening workspace files, and viewing
+local changes. Add `filesystem:write` to allow editing files or saving plans to
+the workspace. These scopes control direct file access from the client.
+
To remove an environment from T3 Connect, open your account menu's **T3 Connect**
page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
revokes its cloud access and frees its host space even when the environment is
diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts
index 44e3df6ab267..2fc46a760e17 100644
--- a/packages/client-runtime/src/state/filesystem.test.ts
+++ b/packages/client-runtime/src/state/filesystem.test.ts
@@ -1,12 +1,119 @@
import { describe, expect, it } from "vite-plus/test";
+import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
import {
canPreloadBrowsePath,
createBrowseNavigationCoordinator,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
+ resolveFilesystemReadAccess,
} from "./filesystem.ts";
+describe("filesystem read access", () => {
+ it("waits for the initial catalog before declaring a missing environment disconnected", () => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: false,
+ connection: null,
+ session: null,
+ sessionError: null,
+ }),
+ ).toEqual({ canReadFiles: false, isPending: true, error: null });
+ });
+
+ it("stops waiting when the loaded catalog has no matching environment", () => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: true,
+ connection: null,
+ session: null,
+ sessionError: null,
+ }),
+ ).toEqual({
+ canReadFiles: false,
+ isPending: false,
+ error: "This environment is not connected.",
+ });
+ });
+
+ it.each(["available", "offline", "error"] as const)(
+ "stops waiting for an unresolved session when the connection is %s",
+ (phase) => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: true,
+ connection: { phase, error: null },
+ session: null,
+ sessionError: null,
+ }),
+ ).toEqual({
+ canReadFiles: false,
+ isPending: false,
+ error: "This environment is not connected.",
+ });
+ },
+ );
+
+ it.each(["connected", "connecting", "reconnecting"] as const)(
+ "waits for the session check while %s",
+ (phase) => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: true,
+ connection: { phase, error: null },
+ session: null,
+ sessionError: null,
+ }),
+ ).toEqual({ canReadFiles: false, isPending: true, error: null });
+ },
+ );
+
+ it("reports the transport failure when the session cannot be checked", () => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: true,
+ connection: { phase: "error", error: "The relay is unavailable." },
+ session: null,
+ sessionError: null,
+ }),
+ ).toEqual({ canReadFiles: false, isPending: false, error: "The relay is unavailable." });
+ });
+
+ it.each([false, true])(
+ "preserves a cached file grant offline with catalog ready=%s",
+ (isCatalogReady) => {
+ const input = {
+ isCatalogReady,
+ connection: { phase: "offline", error: null },
+ session: { authenticated: true, scopes: [AuthFilesystemReadScope] },
+ sessionError: null,
+ } as const;
+ expect(resolveFilesystemReadAccess(input)).toEqual({
+ canReadFiles: true,
+ isPending: false,
+ error: null,
+ });
+ expect(
+ resolveFilesystemReadAccess({ ...input, sessionError: "The session has expired." }),
+ ).toEqual({ canReadFiles: false, isPending: false, error: "The session has expired." });
+ },
+ );
+
+ it.each([
+ { authenticated: true, scopes: [AuthOrchestrationReadScope] },
+ { authenticated: false, scopes: [AuthFilesystemReadScope] },
+ ] as const)("does not infer file access from an ungranted session", (session) => {
+ expect(
+ resolveFilesystemReadAccess({
+ isCatalogReady: true,
+ connection: { phase: "connected", error: null },
+ session,
+ sessionError: null,
+ }),
+ ).toEqual({ canReadFiles: false, isPending: false, error: null });
+ });
+});
+
describe("filesystem browse model", () => {
it("derives the browse target and navigation state", () => {
expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({
diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts
index 794dc404147d..c82c54567cd4 100644
--- a/packages/client-runtime/src/state/filesystem.ts
+++ b/packages/client-runtime/src/state/filesystem.ts
@@ -1,7 +1,15 @@
-import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts";
+import {
+ AuthFilesystemReadScope,
+ type AuthSessionState,
+ type FilesystemBrowseEntry,
+ WS_METHODS,
+} from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
-import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
+import type {
+ EnvironmentConnectionPhase,
+ EnvironmentConnectionPresentation,
+} from "../connection/presentation.ts";
import type { EnvironmentRegistry } from "../connection/registry.ts";
import {
canNavigateUp,
@@ -13,6 +21,38 @@ import {
} from "./projects.ts";
import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
+export function resolveFilesystemReadAccess(input: {
+ readonly isCatalogReady: boolean;
+ readonly connection: Pick | null;
+ readonly session: Pick | null;
+ readonly sessionError: string | null;
+}) {
+ if (input.sessionError !== null) {
+ return { canReadFiles: false, isPending: false, error: input.sessionError };
+ }
+ if (input.session === null) {
+ // Wait for the catalog before interpreting a missing presentation as offline.
+ // Once ready, an offline environment cannot finish its session check.
+ const isPending =
+ !input.isCatalogReady ||
+ input.connection?.phase === "connected" ||
+ input.connection?.phase === "connecting" ||
+ input.connection?.phase === "reconnecting";
+ return {
+ canReadFiles: false,
+ isPending,
+ error: isPending ? null : (input.connection?.error ?? "This environment is not connected."),
+ };
+ }
+ return {
+ canReadFiles:
+ input.session.authenticated &&
+ input.session.scopes?.includes(AuthFilesystemReadScope) === true,
+ isPending: false,
+ error: null,
+ };
+}
+
export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) {
const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform);
const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : "";
diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts
new file mode 100644
index 000000000000..12cf77411c73
--- /dev/null
+++ b/packages/contracts/src/auth.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vite-plus/test";
+import * as Schema from "effect/Schema";
+
+import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts";
+
+describe("authorization grants", () => {
+ it("decodes legacy review credentials without offering them in new grants", () => {
+ expect(Schema.decodeUnknownSync(AuthEnvironmentScopes)(["review:write"])).toEqual([
+ "review:write",
+ ]);
+ expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow();
+ expect(AuthStandardClientScopes).not.toContain("review:write");
+ });
+});
diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts
index 8ed9655bc56b..90156f2d1570 100644
--- a/packages/contracts/src/auth.ts
+++ b/packages/contracts/src/auth.ts
@@ -85,6 +85,9 @@ export const AuthProvidersManageScope = "providers:manage" as const;
export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
export const AuthTerminalOperateScope = "terminal:operate" as const;
export const AuthSourceControlWriteScope = "source-control:write" as const;
+export const AuthFilesystemReadScope = "filesystem:read" as const;
+export const AuthFilesystemWriteScope = "filesystem:write" as const;
+/** Retained for decoding existing credentials; grants no current RPC access. */
export const AuthReviewWriteScope = "review:write" as const;
export const AuthAccessReadScope = "access:read" as const;
export const AuthAccessWriteScope = "access:write" as const;
@@ -97,6 +100,8 @@ export const AuthEnvironmentScope = Schema.Literals([
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
AuthTerminalOperateScope,
+ AuthFilesystemReadScope,
+ AuthFilesystemWriteScope,
AuthReviewWriteScope,
AuthSourceControlWriteScope,
AuthAccessReadScope,
@@ -108,6 +113,13 @@ export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type;
export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope);
export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;
+export const AuthGrantScope = Schema.Literals(
+ AuthEnvironmentScope.literals.filter((scope) => scope !== AuthReviewWriteScope),
+);
+export type AuthGrantScope = typeof AuthGrantScope.Type;
+export const AuthGrantScopes = Schema.Array(AuthGrantScope);
+export type AuthGrantScopes = typeof AuthGrantScopes.Type;
+
export const AuthStandardClientScopes = [
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
@@ -115,8 +127,9 @@ export const AuthStandardClientScopes = [
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
AuthTerminalOperateScope,
- AuthReviewWriteScope,
AuthSourceControlWriteScope,
+ AuthFilesystemReadScope,
+ AuthFilesystemWriteScope,
AuthRelayReadScope,
] as const;
export const AuthAdministrativeScopes = [
@@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T
export const AuthCreatePairingCredentialInput = Schema.Struct({
label: Schema.optionalKey(TrimmedNonEmptyString),
- scopes: Schema.optionalKey(AuthEnvironmentScopes),
+ scopes: Schema.optionalKey(AuthGrantScopes),
});
export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
diff --git a/packages/shared/src/threadEnvMode.test.ts b/packages/shared/src/threadEnvMode.test.ts
index 4cf22c248868..411beae63d7d 100644
--- a/packages/shared/src/threadEnvMode.test.ts
+++ b/packages/shared/src/threadEnvMode.test.ts
@@ -29,6 +29,28 @@ describe("resolveDefaultThreadEnvMode", () => {
});
describe("isDefaultThreadEnvModeSettled", () => {
+ it("waits for file permission before accepting a fallback while the file query is paused", () => {
+ const sources = {
+ explicitMode: undefined,
+ projectSetting: null,
+ projectFilePending: false,
+ projectFilePermissionPending: true,
+ };
+ expect(isDefaultThreadEnvModeSettled(sources)).toBe(false);
+ expect(
+ isDefaultThreadEnvModeSettled({
+ ...sources,
+ projectFilePermissionPending: false,
+ projectFilePending: true,
+ }),
+ ).toBe(false);
+ expect(isDefaultThreadEnvModeSettled({ ...sources, projectFilePermissionPending: false })).toBe(
+ true,
+ );
+ expect(isDefaultThreadEnvModeSettled({ ...sources, explicitMode: "local" })).toBe(true);
+ expect(isDefaultThreadEnvModeSettled({ ...sources, projectSetting: "local" })).toBe(true);
+ });
+
it("settles on an explicit pick or project setting even while the file loads", () => {
expect(
isDefaultThreadEnvModeSettled({
diff --git a/packages/shared/src/threadEnvMode.ts b/packages/shared/src/threadEnvMode.ts
index 4c01c0f27b91..ac2e2fa0b3ce 100644
--- a/packages/shared/src/threadEnvMode.ts
+++ b/packages/shared/src/threadEnvMode.ts
@@ -19,7 +19,7 @@ export function resolveDefaultThreadEnvMode(sources: {
/**
* True once the resolved default can no longer change: an explicit pick or a
- * source that outranks t3.json decided, or the file read settled. While
+ * source that outranks t3.json decided, or its permission lookup and file read settled. While
* false, nothing may persist the provisional default (for example into a
* draft's workspace selection) — it could differ from the final value.
*/
@@ -27,10 +27,11 @@ export function isDefaultThreadEnvModeSettled(sources: {
readonly explicitMode: ThreadEnvMode | undefined;
readonly projectSetting: ThreadEnvMode | null | undefined;
readonly projectFilePending: boolean;
+ readonly projectFilePermissionPending?: boolean;
}): boolean {
return (
sources.explicitMode !== undefined ||
sources.projectSetting != null ||
- !sources.projectFilePending
+ (!sources.projectFilePending && !sources.projectFilePermissionPending)
);
}