diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index 579660c68f98..7d80f2544e63 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -53,7 +53,7 @@ OpenClaw exposes these tools only to operator sessions with an actionable Gatewa The resulting managed worktree is owned by the session, and every agent run in that session uses its checkout. When the workspace is a repository subdirectory, the worktree is anchored at the repository root and the session runs from the matching subdirectory inside it. Session worktree creation uses the method's `operator.write` scope, but repository checkout hooks and the `.openclaw/worktree-setup.sh` step run only for `operator.admin` callers because they execute repository code; `.worktreeinclude` provisioning still applies to every caller. Deleting the session removes the worktree only when doing so is lossless. Dirty worktrees or branches with unpushed commits stay available; hourly cleanup snapshots session worktrees after 7 idle days, treating recent session activity as worktree activity. Removed worktrees remain restorable from their snapshots as described below. -`sessions.create` may include an absolute `cwd` to run directly in another Gateway folder, to choose the source checkout together with `worktree: true`, or to set a paired node's working directory. Every explicit host path requires `operator.admin`; ordinary worktree chat creation remains `operator.write` and stays anchored to the configured workspace. +`sessions.create` may include an absolute `cwd` to run directly in another Gateway folder, to choose the source checkout together with `worktree: true`, or to set a paired node's working directory. Connections with `operator.write` may use a Gateway `cwd` contained in any configured agent workspace; realpath containment prevents symlinks from escaping that boundary. Gateway paths outside those workspaces and every paired-node working directory require `operator.admin`. Ordinary worktree chat creation remains `operator.write` and stays anchored to the configured workspace. `sessions.create` also accepts `worktreeBaseRef` and `worktreeName` alongside `worktree: true` to pick the base ref and the worktree name (the branch becomes `openclaw/`); both stay at `operator.write`. If `worktreeName` is omitted, the session label or generated first-message title supplies the readable branch name, with a crustacean-themed fallback. The created worktree is returned in the create result and persisted on the session row as `worktree: { id, branch, repoRoot }`, so session lists can show the checkout and branch. Deleting a session reports a preserved dirty checkout as `worktreePreserved` instead of silently leaving it behind. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 760c0472e1da..aa3ca03f1a5d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -234,11 +234,11 @@ The sidebar organizes everything around the agent. The identity row at the top i ## New session page -The **+** in the sidebar session-list header opens a full-page draft at `/new`: nothing is created until you send the first message. A unified **Place** picker chooses the working folder and, for admin operators, the execution destination: **Gateway · local**, a paired node that exposes `system.run`, or an available cloud profile. The folder defaults to the agent workspace; another absolute Gateway path requires `operator.admin` but can run directly without being a Git checkout. When the selected Gateway folder is a Git checkout, the same picker offers optional **Worktree** isolation with a base-branch picker backed by `worktrees.branches` (no fetch) and an optional worktree name (the branch becomes `openclaw/`). Cloud workers require that managed-worktree path; paired nodes never expose it. The composer footer chooses the new session's model and reasoning level. Its **Incognito** toggle creates a web-only thread whose session entry, transcript, and compaction state stay in memory until the Gateway restarts; OpenClaw also skips its automatic memory flush. The agent keeps its normal tools, so an explicit save request or tool-driven file write can still persist data. The model provider still processes messages, and content-free audit metadata is still recorded. Cloud starts persist their model and reasoning choices before dispatching the session to its worker. +The **+** in the sidebar session-list header opens a full-page draft at `/new`: nothing is created until you send the first message. A unified **Place** picker chooses the working folder and, for admin operators, the execution destination: **Gateway · local**, a paired node that exposes `system.run`, or an available cloud profile. The folder defaults to the agent workspace. Connections with `operator.write` can browse, restore recent folders, and start sessions anywhere inside a configured agent workspace; another absolute Gateway path requires `operator.admin` but can run directly without being a Git checkout. When the selected Gateway folder is a Git checkout, the same picker offers optional **Worktree** isolation with a base-branch picker backed by `worktrees.branches` (no fetch) and an optional worktree name (the branch becomes `openclaw/`). Cloud workers require that managed-worktree path; paired nodes and their filesystem browsing remain admin-only. The composer footer chooses the new session's model and reasoning level. Its **Incognito** toggle creates a web-only thread whose session entry, transcript, and compaction state stay in memory until the Gateway restarts; OpenClaw also skips its automatic memory flush. The agent keeps its normal tools, so an explicit save request or tool-driven file write can still persist data. The model provider still processes messages, and content-free audit metadata is still recorded. Cloud starts persist their model and reasoning choices before dispatching the session to its worker. On multi-user gateways, only admin-scope connections can create or view incognito threads, and other sessions cannot reach them through agent session tools or transcript search. Incognito protects against storage and other gateway-mediated users, not against the gateway owner or process operator, who can always observe live sessions. -**Browse folders** opens the Place picker's inline directory browser, backed by the admin-only `fs.listDir` method and scoped to the selected Gateway or node. Gateway and browse-capable nodes list their filesystem; an execution-capable node without `fs.listDir` still accepts a typed absolute path. Recent places can restore a folder and its owning node together without carrying paths across hosts. Submitting calls `sessions.create` with the first message, so the run starts in the same round-trip and the UI jumps to the new session's chat. If the Gateway creates the session but rejects that first send, the chat preserves the prompt and error across reloads; **Retry** sends it through the already-created session instead of creating another one. +**Browse folders** opens the Place picker's inline directory browser through `fs.listDir`. Write-scope Gateway browsing starts at the configured agent workspace and cannot navigate above it; realpath checks also reject symlinks that escape the workspace. Admin connections can browse arbitrary Gateway paths and browse-capable nodes. An execution-capable node without `fs.listDir` still accepts a typed absolute path for admins. Recent places restore only folders the current connection can submit, and node recents remain admin-only. Submitting calls `sessions.create` with the first message, so the run starts in the same round-trip and the UI jumps to the new session's chat. If the Gateway creates the session but rejects that first send, the chat preserves the prompt and error across reloads; **Retry** sends it through the already-created session instead of creating another one. Inside **Settings**, the dedicated sidebar includes **Ask OpenClaw** and starts with a **Search settings** field for quickly finding settings sections. diff --git a/packages/gateway-protocol/src/schema/fs.ts b/packages/gateway-protocol/src/schema/fs.ts index d06a1b499838..7c946699871f 100644 --- a/packages/gateway-protocol/src/schema/fs.ts +++ b/packages/gateway-protocol/src/schema/fs.ts @@ -3,10 +3,11 @@ import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; -// Host directory browsing for the new-session folder picker. Admin-only on the -// gateway; listing stays directories-only so the picker never leaks file names. +// Host directory browsing for the new-session folder picker. Gateway-local +// write-scope browsing stays inside configured agent workspaces; node and +// arbitrary host browsing require admin. export const FsListDirParamsSchema = closedObject({ - /** Absolute directory to list; omitted means the selected host's home directory. */ + /** Absolute directory to list; for non-admin Gateway callers, omission means the first configured agent workspace. */ path: Type.Optional(NonEmptyString), /** Connected node host to browse; omitted means the Gateway host. */ nodeId: Type.Optional(NonEmptyString), diff --git a/packages/gateway-protocol/src/schema/sessions-create.ts b/packages/gateway-protocol/src/schema/sessions-create.ts index 3e7e53533dec..e23e39d0aa59 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.ts @@ -59,7 +59,7 @@ export const SessionsCreateParamsSchema = closedObject({ Type.String({ minLength: 1, description: - "Absolute Gateway working directory, managed-worktree source directory, or working directory on execNode. Requires operator.admin.", + "Absolute Gateway working directory, managed-worktree source directory, or working directory on execNode. Gateway paths outside configured agent workspaces and all execNode paths require operator.admin.", }), ), }); diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index f6358fa00b80..5c597a7b754b 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -341,29 +341,54 @@ describe("method scope resolution", () => { expect(isGatewayMethodClassified("sessions.patch")).toBe(true); }); - it("requires admin whenever sessions.create targets an explicit cwd", () => { + it("defers Gateway cwd containment to sessions.create while keeping node cwd admin-only", () => { expect( resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { worktree: true }), ).toEqual(["operator.write"]); expect( resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { cwd: "/other/repo" }), - ).toEqual(["operator.admin"]); + ).toEqual(["operator.write"]); expect( authorizeOperatorScopesForMethod("sessions.create", ["operator.write"], { cwd: "/other/repo", }), - ).toEqual({ allowed: false, missingScope: "operator.admin" }); + ).toEqual({ allowed: true }); expect( resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { worktree: true, cwd: "/other/repo", }), - ).toEqual(["operator.admin"]); + ).toEqual(["operator.write"]); expect( authorizeOperatorScopesForMethod("sessions.create", ["operator.write"], { worktree: true, cwd: "/other/repo", }), + ).toEqual({ allowed: true }); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { + execNode: "macbook", + cwd: "/other/repo", + }), + ).toEqual(["operator.admin"]); + }); + + it("keeps Gateway fs.listDir write-scoped and node browsing admin-only", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("fs.listDir", {})).toEqual([ + "operator.write", + ]); + expect( + authorizeOperatorScopesForMethod("fs.listDir", ["operator.write"], { + path: "/configured/workspace", + }), + ).toEqual({ allowed: true }); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("fs.listDir", { nodeId: "macbook" }), + ).toEqual(["operator.admin"]); + expect( + authorizeOperatorScopesForMethod("fs.listDir", ["operator.write"], { + nodeId: "macbook", + }), ).toEqual({ allowed: false, missingScope: "operator.admin" }); }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 21247e6e391e..49d568ba10a4 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -173,6 +173,14 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod( : undefined; return bootstrapCommandOwner === true ? [PAIRING_SCOPE, ADMIN_SCOPE] : [PAIRING_SCOPE]; } + if (method === "fs.listDir") { + const targetsNode = + params !== null && + typeof params === "object" && + !Array.isArray(params) && + Object.hasOwn(params, "nodeId"); + return [targetsNode ? ADMIN_SCOPE : WRITE_SCOPE]; + } if (method === "sessions.patch") { return [resolveDynamicSessionMutationRequiredScope(method, params) ?? WRITE_SCOPE]; } diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 1fdd612ed0ba..b400543487af 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -167,9 +167,9 @@ const CORE_GATEWAY_METHOD_SPECS = [ // Read-only git probe, but it accepts arbitrary host paths; keep it at the // same bar as starting worktree sessions instead of plain read scope. ["worktrees.branches", "worktrees", "operator.write", "2026.7"], - // Arbitrary host-path directory listing backs the new-session folder picker; - // same trust bar as sessions.create with an explicit cwd. - ["fs.listDir", "fs", "operator.admin", "<=2026.7"], + // Params-aware: Gateway paths start at write scope and are containment-checked + // by the handler; node browsing remains admin-only. + ["fs.listDir", "fs", "dynamic", "<=2026.7"], ["worktrees.create", "worktrees", "operator.admin", "2026.7", { controlPlaneWrite: true }], ["worktrees.remove", "worktrees", "operator.admin", "2026.7", { controlPlaneWrite: true }], ["worktrees.restore", "worktrees", "operator.admin", "2026.7", { controlPlaneWrite: true }], @@ -239,7 +239,8 @@ const CORE_GATEWAY_METHOD_SPECS = [ ["sessions.branches.switch", "sessions-rewind", "operator.admin", "<=2026.7"], ["sessions.rewind", "sessions-rewind", "operator.admin", "<=2026.7"], ["sessions.fork", "sessions-rewind", "operator.write", "<=2026.7"], - // Params-aware: explicit cwd can point at any host checkout and requires admin. + // Params-aware plus state-aware: the handler permits write-scoped cwd only + // inside configured agent workspaces; execNode and other privileged modes stay admin. ["sessions.create", "sessions-create", "dynamic", "<=2026.7", { startup: true }], ["sessions.send", "sessions-messaging", "operator.write", "<=2026.7", { startup: true }], ["sessions.abort", "sessions-abort", "operator.write", "<=2026.7", { startup: true }], diff --git a/src/gateway/server-methods/fs.test.ts b/src/gateway/server-methods/fs.test.ts index ced0ee9d7d76..c56a1049ae1f 100644 --- a/src/gateway/server-methods/fs.test.ts +++ b/src/gateway/server-methods/fs.test.ts @@ -12,14 +12,27 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach); async function call( params: Record, context: Record = { + getRuntimeConfig: () => ({}), nodeRegistry: { get: vi.fn(), invoke: vi.fn() }, }, + client: Record = { connect: { scopes: ["operator.admin"] } }, ) { const respond = vi.fn(); - await fsHandlers["fs.listDir"]?.({ params, respond, context } as never); + await fsHandlers["fs.listDir"]?.({ params, respond, context, client } as never); return respond.mock.calls[0]; } +const writeClient = { connect: { scopes: ["operator.write"] } }; + +function workspaceContext(workspace: string) { + return { + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true, workspace }] }, + }), + nodeRegistry: { get: vi.fn(), invoke: vi.fn() }, + }; +} + describe("fs.listDir", () => { it("lists only directories, visible before hidden, in byte order", async () => { const root = tempDirs.make("openclaw-fs-listdir-"); @@ -96,6 +109,74 @@ describe("fs.listDir", () => { expect((error as { message?: string })?.message).toContain("ENOENT"); }); + it("allows write-scoped browsing inside a configured workspace", async () => { + const workspace = tempDirs.make("openclaw-fs-workspace-"); + const nested = path.join(workspace, "packages"); + await fs.mkdir(nested); + + const [ok, result] = expectDefined( + await call({ path: nested }, workspaceContext(workspace), writeClient), + "write-scoped workspace listing", + ); + + expect(ok).toBe(true); + expect(result).toMatchObject({ path: nested, parent: workspace }); + }); + + it("defaults write-scoped browsing to the workspace root and clamps its parent", async () => { + const workspace = tempDirs.make("openclaw-fs-workspace-"); + + const [ok, result] = expectDefined( + await call({}, workspaceContext(workspace), writeClient), + "write-scoped workspace root listing", + ); + + expect(ok).toBe(true); + expect(result).toMatchObject({ path: workspace }); + expect(result).not.toHaveProperty("parent"); + }); + + it("rejects write-scoped browsing outside configured workspaces", async () => { + const workspace = tempDirs.make("openclaw-fs-workspace-"); + const outside = tempDirs.make("openclaw-fs-outside-"); + + const [ok, , error] = expectDefined( + await call({ path: outside }, workspaceContext(workspace), writeClient), + "write-scoped outside listing", + ); + + expect(ok).toBe(false); + expect(error).toMatchObject({ message: expect.stringContaining("operator.admin") }); + }); + + it("rejects write-scoped browsing through a workspace symlink that escapes", async () => { + const workspace = tempDirs.make("openclaw-fs-workspace-"); + const outside = tempDirs.make("openclaw-fs-outside-"); + const escape = path.join(workspace, "escape"); + fsSync.symlinkSync(outside, escape); + + const [ok, , error] = expectDefined( + await call({ path: escape }, workspaceContext(workspace), writeClient), + "write-scoped symlink escape listing", + ); + + expect(ok).toBe(false); + expect(error).toMatchObject({ message: expect.stringContaining("operator.admin") }); + }); + + it("keeps missing workspace descendants as filesystem errors instead of scope errors", async () => { + const workspace = tempDirs.make("openclaw-fs-workspace-"); + const missing = path.join(workspace, "missing", "child"); + + const [ok, , error] = expectDefined( + await call({ path: missing }, workspaceContext(workspace), writeClient), + "write-scoped missing descendant listing", + ); + + expect(ok).toBe(false); + expect(error).toMatchObject({ message: expect.stringContaining("ENOENT") }); + }); + it("routes node listings through the connected node capability", async () => { const invoke = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/gateway/server-methods/fs.ts b/src/gateway/server-methods/fs.ts index 10a3d22438d2..0f3afd65afec 100644 --- a/src/gateway/server-methods/fs.ts +++ b/src/gateway/server-methods/fs.ts @@ -1,17 +1,19 @@ -// Host directory browsing for the new-session folder picker. operator.admin -// only (see core-descriptors): listing arbitrary host paths carries the same -// trust as starting a session with an explicit cwd. +// Host directory browsing for the new-session folder picker. Write-scoped +// callers stay inside configured agent workspaces; admin retains host access. import { safeParseJson } from "@openclaw/normalization-core"; import { ErrorCodes, errorShape, + missingScopeErrorShape, validateFsListDirParams, validateFsListDirResult, } from "../../../packages/gateway-protocol/src/index.js"; import { listHostDirectories } from "../../infra/host-directory-listing.js"; import { NODE_FS_LIST_DIR_COMMAND } from "../../infra/node-commands.js"; import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; import type { GatewayRequestHandlers } from "./types.js"; +import { resolveWorkspacePathContainment } from "./workspace-path-containment.js"; function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknown { if (payloadJSON) { @@ -21,7 +23,7 @@ function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknow } export const fsHandlers: GatewayRequestHandlers = { - "fs.listDir": async ({ params, respond, context }) => { + "fs.listDir": async ({ params, respond, context, client }) => { if (!validateFsListDirParams(params)) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid fs parameters")); return; @@ -90,7 +92,31 @@ export const fsHandlers: GatewayRequestHandlers = { respond(true, payload, undefined); return; } - respond(true, await listHostDirectories(params.path), undefined); + const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + if (scopes.includes(ADMIN_SCOPE)) { + respond(true, await listHostDirectories(params.path), undefined); + return; + } + const containment = await resolveWorkspacePathContainment( + params.path?.trim() || undefined, + context.getRuntimeConfig(), + { allowMissing: true }, + ); + if (!containment) { + respond( + false, + undefined, + missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }), + ); + return; + } + const listing = await listHostDirectories(containment.path); + if (listing.path === containment.workspaceRoot) { + const { parent: _parent, ...clamped } = listing; + respond(true, clamped, undefined); + return; + } + respond(true, listing, undefined); } catch (error) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(error))); } diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index a7ce4114288e..0947b5337241 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -7,6 +7,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { ErrorCodes, errorShape, + missingScopeErrorShape, validateSessionsCreateParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; @@ -45,6 +46,7 @@ import { resolveOperatorSessionCreation } from "./session-creation-provenance.js import { sessionLog } from "./sessions-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; +import { resolveWorkspacePathContainment } from "./workspace-path-containment.js"; async function prepareOperatorSessionDiffBaseline(params: { agentId: string; @@ -142,7 +144,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { hasInitialTurn, message: initialMessage, } = initialTurn; - const requestedCwd = normalizeOptionalString(p.cwd); + let requestedCwd = normalizeOptionalString(p.cwd); const requestedExecNode = normalizeOptionalString(p.execNode); // Agent tools expand `~` before RPC; the Gateway contract stays absolute-only. // Remote nodes may use Windows paths; local cwd must match the Gateway host. @@ -159,6 +161,22 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ); return; } + const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + if (requestedCwd && !requestedExecNode && !clientScopes.includes(ADMIN_SCOPE)) { + const containment = await resolveWorkspacePathContainment(requestedCwd, cfg); + if (!containment) { + respond( + false, + undefined, + missingScopeErrorShape({ + missingScope: ADMIN_SCOPE, + requiredScopes: [ADMIN_SCOPE], + }), + ); + return; + } + requestedCwd = containment.path; + } if (requestedExecNode && p.worktree === true) { respond( false, @@ -404,7 +422,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { let runError: unknown; let runMeta: Record | undefined; let messageSeq: number | undefined; - const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; const sessionCreation = resolveOperatorSessionCreation(client, { allowTrustedHint: true }); const spawnActorSessionKey = sessionCreation.via === "spawn" && sessionCreation.actor?.type === "agent" diff --git a/src/gateway/server-methods/workspace-path-containment.ts b/src/gateway/server-methods/workspace-path-containment.ts new file mode 100644 index 000000000000..994dffe2ecf4 --- /dev/null +++ b/src/gateway/server-methods/workspace-path-containment.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; + +async function resolveRequestedRealPath( + requestedPath: string, + allowMissing: boolean, +): Promise { + try { + return await fs.realpath(requestedPath); + } catch (error) { + if (!allowMissing || !isNotFoundPathError(error)) { + return null; + } + } + + let ancestor = path.dirname(requestedPath); + for (;;) { + try { + const ancestorRealPath = await fs.realpath(ancestor); + return path.resolve(ancestorRealPath, path.relative(ancestor, requestedPath)); + } catch (error) { + if (!isNotFoundPathError(error)) { + return null; + } + } + const parent = path.dirname(ancestor); + if (parent === ancestor) { + return null; + } + ancestor = parent; + } +} + +/** Resolves a Gateway path against the real roots of configured agent workspaces. */ +export async function resolveWorkspacePathContainment( + requestedPath: string | undefined, + cfg: OpenClawConfig, + options: { allowMissing?: boolean } = {}, +): Promise<{ path: string; workspaceRoot: string } | null> { + const workspaceRoots = await Promise.all( + listAgentIds(cfg).map(async (agentId) => { + try { + return await fs.realpath(resolveAgentWorkspaceDir(cfg, agentId)); + } catch { + return null; + } + }), + ); + if (requestedPath === undefined) { + const firstWorkspaceRoot = workspaceRoots[0]; + return firstWorkspaceRoot + ? { path: firstWorkspaceRoot, workspaceRoot: firstWorkspaceRoot } + : null; + } + const existingRoots = workspaceRoots.filter((root): root is string => root !== null); + if (!path.isAbsolute(requestedPath)) { + return null; + } + const requested = path.resolve(requestedPath); + const requestedRealPath = await resolveRequestedRealPath( + requested, + options.allowMissing === true, + ); + if (!requestedRealPath) { + return null; + } + const workspaceRoot = existingRoots + .filter((root) => isPathInside(root, requestedRealPath)) + .toSorted((left, right) => right.length - left.length)[0]; + return workspaceRoot ? { path: requestedRealPath, workspaceRoot } : null; +} diff --git a/src/gateway/server-methods/worktrees.test.ts b/src/gateway/server-methods/worktrees.test.ts index da5c422e81bf..a2db27ca515f 100644 --- a/src/gateway/server-methods/worktrees.test.ts +++ b/src/gateway/server-methods/worktrees.test.ts @@ -128,13 +128,15 @@ describe("worktrees gateway methods", () => { expect(String((denied?.[2] as { message?: string })?.message)).toContain("operator.admin"); }); - it("allows write-scoped branch listing for a configured agent workspace", async () => { + it("allows write-scoped branch listing for a subdirectory inside an agent workspace", async () => { const os = await import("node:os"); const path = await import("node:path"); const fs = await import("node:fs/promises"); const workspace = await fs.mkdtemp( path.join(await fs.realpath(os.tmpdir()), "openclaw-branches-scope-"), ); + const repoRoot = path.join(workspace, "packages", "app"); + await fs.mkdir(repoRoot, { recursive: true }); try { const service = { listRepositoryBranches: vi.fn(async () => ({ branches: [] })), @@ -143,7 +145,7 @@ describe("worktrees gateway methods", () => { const response = await call( handlers, "worktrees.branches", - { repoRoot: workspace }, + { repoRoot }, { client: writeClient, context: { @@ -154,7 +156,7 @@ describe("worktrees gateway methods", () => { }, ); expect(response?.[0]).toBe(true); - expect(service.listRepositoryBranches).toHaveBeenCalledWith(workspace); + expect(service.listRepositoryBranches).toHaveBeenCalledWith(repoRoot); } finally { await fs.rm(workspace, { recursive: true, force: true }); } diff --git a/src/gateway/server-methods/worktrees.ts b/src/gateway/server-methods/worktrees.ts index 85dc4c263bd9..99f8c97a2830 100644 --- a/src/gateway/server-methods/worktrees.ts +++ b/src/gateway/server-methods/worktrees.ts @@ -1,5 +1,3 @@ -import fsSync from "node:fs"; -import fs from "node:fs/promises"; import { ErrorCodes, errorShape, @@ -10,7 +8,6 @@ import { validateWorktreesRemoveParams, validateWorktreesRestoreParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { createManagedWorktreeOwnerProtection } from "../../agents/worktrees/owner-protection.js"; import { managedWorktrees, @@ -20,6 +17,7 @@ import { import type { ManagedWorktreeService } from "../../agents/worktrees/service.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import type { GatewayRequestHandlers } from "./types.js"; +import { resolveWorkspacePathContainment } from "./workspace-path-containment.js"; type WorktreeService = Pick< ManagedWorktreeService, @@ -97,22 +95,14 @@ export function createWorktreesHandlers(service: WorktreeService): GatewayReques invalidParams(respond); return; } - // Write scope may only enumerate configured agent workspaces; arbitrary - // host paths stay behind the same admin bar as sessions.create cwd. + let repoRoot = params.repoRoot; const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; if (!scopes.includes(ADMIN_SCOPE)) { - const cfg = context.getRuntimeConfig(); - const requested = await fs.realpath(params.repoRoot).catch(() => null); - const allowed = - requested !== null && - listAgentIds(cfg).some((agentId) => { - try { - return fsSync.realpathSync(resolveAgentWorkspaceDir(cfg, agentId)) === requested; - } catch { - return false; - } - }); - if (!allowed) { + const containment = await resolveWorkspacePathContainment( + params.repoRoot, + context.getRuntimeConfig(), + ); + if (!containment) { respond( false, undefined, @@ -123,12 +113,13 @@ export function createWorktreesHandlers(service: WorktreeService): GatewayReques ); return; } + repoRoot = containment.path; } const result = params.includeRepositoryStatus - ? await service.listRepositoryBranches(params.repoRoot, { + ? await service.listRepositoryBranches(repoRoot, { includeRepositoryStatus: true, }) - : await service.listRepositoryBranches(params.repoRoot); + : await service.listRepositoryBranches(repoRoot); respond(true, result, undefined); }, "worktrees.gc": async ({ params, respond, context }) => { diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index d6267621a298..303a9a4b7810 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -1223,7 +1223,11 @@ test("sessions.create provisions a worktree from an admin-selected cwd", async ( }); test("sessions.create persists a Gateway cwd without a managed worktree", async () => { - const created = await directSessionReq("sessions.create", { cwd: "/tmp/repo" }); + const created = await directSessionReq( + "sessions.create", + { cwd: "/tmp/repo" }, + { client: { connect: { scopes: ["operator.admin"] } } as never }, + ); expect(created.ok).toBe(true); expect((created.payload as { entry?: { spawnedCwd?: string } })?.entry?.spawnedCwd).toBe( @@ -1231,6 +1235,51 @@ test("sessions.create persists a Gateway cwd without a managed worktree", async ); }); +test("sessions.create allows a write-scoped cwd inside the configured workspace", async () => { + const workspace = tempDirs.make("openclaw-session-cwd-workspace-"); + const cwd = path.join(workspace, "packages", "app"); + await fs.mkdir(cwd, { recursive: true }); + testState.agentConfig = { workspace }; + await createSessionStoreDir(); + const { ws } = await openClient({ + scopes: ["operator.write"], + deviceIdentityPath: path.join(workspace, "write-cwd-device.json"), + }); + try { + const created = await rpcReq<{ entry?: { spawnedCwd?: string } }>(ws, "sessions.create", { + cwd, + }); + + expect(created.ok, JSON.stringify(created.error)).toBe(true); + expect(created.payload?.entry?.spawnedCwd).toBe(cwd); + } finally { + ws.close(); + testState.agentConfig = undefined; + } +}); + +test("sessions.create rejects a write-scoped cwd outside configured workspaces", async () => { + const workspace = tempDirs.make("openclaw-session-cwd-workspace-"); + const outside = tempDirs.make("openclaw-session-cwd-outside-"); + testState.agentConfig = { workspace }; + await createSessionStoreDir(); + const { ws } = await openClient({ + scopes: ["operator.write"], + deviceIdentityPath: path.join(workspace, "outside-cwd-device.json"), + }); + try { + const created = await rpcReq(ws, "sessions.create", { cwd: outside }); + + expect(created).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN", message: "missing scope: operator.admin" }, + }); + } finally { + ws.close(); + testState.agentConfig = undefined; + } +}); + test("sessions.create uses a non-git Gateway cwd directly but not as a worktree source", async () => { const cwd = await makeNonGitTempDir("openclaw-session-direct-cwd-"); const client = { client: { connect: { scopes: ["operator.admin"] } } as never }; @@ -1259,7 +1308,11 @@ test("sessions.create keeps its cwd contract absolute-only", async () => { test("sessions.create rejects cwd outside a sandboxed agent workspace", async () => { testState.agentConfig = { workspace: "/tmp/safe-workspace", sandbox: { mode: "all" } }; try { - const created = await directSessionReq("sessions.create", { cwd: "/tmp/outside" }); + const created = await directSessionReq( + "sessions.create", + { cwd: "/tmp/outside" }, + { client: { connect: { scopes: ["operator.admin"] } } as never }, + ); expect(created.ok).toBe(false); expect(created.error).toMatchObject({ @@ -1275,7 +1328,11 @@ test("sessions.create allows cwd within a sandboxed agent workspace", async () = testState.agentConfig = { workspace: "/tmp/safe-workspace", sandbox: { mode: "all" } }; try { const cwd = "/tmp/safe-workspace/packages/app"; - const created = await directSessionReq("sessions.create", { cwd }); + const created = await directSessionReq( + "sessions.create", + { cwd }, + { client: { connect: { scopes: ["operator.admin"] } } as never }, + ); expect(created.ok).toBe(true); expect((created.payload as { entry?: { spawnedCwd?: string } })?.entry?.spawnedCwd).toBe(cwd); diff --git a/src/shared/session-method-scopes.test.ts b/src/shared/session-method-scopes.test.ts index 944ac623b346..3c131f8706f2 100644 --- a/src/shared/session-method-scopes.test.ts +++ b/src/shared/session-method-scopes.test.ts @@ -16,7 +16,6 @@ describe("resolveDynamicSessionMutationRequiredScope", () => { { incognito: true }, { key: "agent:main:dashboard:incognito-123" }, { parentSessionKey: "agent:main:subagent:incognito-123" }, - { cwd: "/tmp/workspace" }, { execNode: "node-1" }, ])("requires admin for privileged session creation params %#", (params) => { expect(resolveDynamicSessionMutationRequiredScope("sessions.create", params)).toBe( @@ -24,6 +23,14 @@ describe("resolveDynamicSessionMutationRequiredScope", () => { ); }); + it("leaves Gateway cwd containment to the state-aware create handler", () => { + expect( + resolveDynamicSessionMutationRequiredScope("sessions.create", { + cwd: "/configured/workspace/packages/app", + }), + ).toBe("operator.write"); + }); + it.each([ { name: "model set", patch: { model: "openai/gpt-5.6-luna" } }, { name: "model reset", patch: { model: null } }, diff --git a/src/shared/session-method-scopes.ts b/src/shared/session-method-scopes.ts index c0af967b874a..00da374b392a 100644 --- a/src/shared/session-method-scopes.ts +++ b/src/shared/session-method-scopes.ts @@ -62,14 +62,14 @@ function resolveSessionsCreateRequiredScope(params: unknown): SessionMutationOpe if (!isRecord(params)) { return "operator.write"; } - // Incognito creation and inheritance expose process-only session state; cwd and - // execNode target privileged host resources. All require operator.admin. + // Incognito creation and inheritance expose process-only session state, while + // execNode targets privileged host resources. Gateway cwd containment needs + // runtime config and filesystem facts, so the create handler owns that check. if ( params.incognito === true || (typeof params.key === "string" && isIncognitoSessionKey(params.key)) || (typeof params.parentSessionKey === "string" && isIncognitoSessionKey(params.parentSessionKey)) || - Object.hasOwn(params, "cwd") || Object.hasOwn(params, "execNode") ) { return "operator.admin"; diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index 8810e87865fa..81ffaddda095 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -1,7 +1,9 @@ import { expect, it } from "vitest"; import { + SESSION_LIST_DEFAULTS, createNewSessionPageE2eSuite, installMockGateway, + pollLocatorText, } from "./new-session-page.test-support.ts"; const suite = createNewSessionPageE2eSuite(); @@ -68,6 +70,186 @@ suite.define(() => { } }); + it("lets write-scoped operators browse and restore only workspace-contained folders", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const workspace = "/home/peter/openclaw"; + const contained = `${workspace}/packages/app`; + const gateway = await installMockGateway(page, { + workspace, + workspaceGit: true, + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "sessions.create", + "worktrees.branches", + ], + operatorScopes: ["operator.read", "operator.write"], + methodResponses: { + "fs.listDir": { + path: workspace, + home: "/home/peter", + entries: [{ name: "packages", path: `${workspace}/packages` }], + }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { key: "agent:main:inside", kind: "direct", updatedAt: 2, execCwd: contained }, + { key: "agent:main:outside", kind: "direct", updatedAt: 1, execCwd: "/private/repo" }, + ], + ts: Date.now(), + }, + "worktrees.branches": { branches: [], repositoryStatus: "not_git" }, + "sessions.create": { key: "agent:main:write-workspace" }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const browse = page.getByRole("button", { name: "Browse folders" }); + await expect.poll(() => browse.isEnabled()).toBe(true); + await browse.click(); + await expect(gateway.waitForRequest("fs.listDir")).resolves.toMatchObject({ + params: { path: workspace }, + }); + await page.getByRole("button", { name: "Parent folder" }).click(); + await page.getByRole("button", { name: "app", exact: true }).click(); + expect(await page.locator('[data-value="recent::/private/repo"]').count()).toBe(0); + + await page.locator(".new-session-page__message").fill("work in the package"); + await page.getByRole("button", { name: "Start session" }).click(); + await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ + params: { cwd: contained, message: "work in the package" }, + }); + } finally { + await context.close(); + } + }); + + it("keeps a canonical browser selection submittable for a symlinked workspace alias", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const workspaceAlias = "/var/folders/openclaw/workspace-alias"; + const canonicalWorkspace = "/private/var/folders/openclaw/workspace"; + const canonicalFolder = `${canonicalWorkspace}/packages`; + const gateway = await installMockGateway(page, { + workspace: workspaceAlias, + workspaceGit: true, + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "sessions.create", + "worktrees.branches", + ], + operatorScopes: ["operator.read", "operator.write"], + methodResponses: { + "fs.listDir": { + cases: [ + { + match: { path: workspaceAlias }, + response: { + path: canonicalWorkspace, + home: "/Users/peter", + entries: [{ name: "packages", path: canonicalFolder }], + }, + }, + { + match: { path: canonicalFolder }, + response: { + path: canonicalFolder, + parent: canonicalWorkspace, + home: "/Users/peter", + entries: [], + }, + }, + ], + }, + "worktrees.branches": { branches: [], repositoryStatus: "not_git" }, + "sessions.create": { key: "agent:main:symlinked-workspace" }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator("#new-session-place-trigger").click(); + await page.getByRole("button", { name: "Browse folders" }).click(); + await page.getByRole("button", { name: "packages" }).click(); + const useFolder = page.getByRole("button", { name: "Use this folder" }); + await expect.poll(() => useFolder.isEnabled()).toBe(true); + await useFolder.click(); + + await page.locator(".new-session-page__message").fill("inspect the canonical checkout"); + const submit = page.getByRole("button", { name: "Start session" }); + await expect.poll(() => submit.isEnabled()).toBe(true); + await submit.click(); + await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ + params: { + cwd: canonicalFolder, + message: "inspect the canonical checkout", + }, + }); + } finally { + await context.close(); + } + }); + + it("submits an unvalidated typed folder so the Gateway error stays actionable", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const workspace = "/home/peter/openclaw"; + const typedFolder = "/private/repo"; + const gateway = await installMockGateway(page, { + workspace, + workspaceGit: true, + deferredMethods: ["sessions.create"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "sessions.create", + "worktrees.branches", + ], + operatorScopes: ["operator.read", "operator.write"], + methodResponses: { + "fs.listDir": { path: workspace, home: "/home/peter", entries: [] }, + "worktrees.branches": { branches: [], repositoryStatus: "not_git" }, + "sessions.create": { key: "agent:main:typed-folder" }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator("#new-session-place-trigger").click(); + await page.getByRole("button", { name: "Browse folders" }).click(); + const pathInput = page.locator("input.new-session-page__browser-path"); + await expect.poll(() => pathInput.inputValue()).toBe(workspace); + await pathInput.fill(typedFolder); + const useFolder = page.getByRole("button", { name: "Use this folder" }); + await expect.poll(() => useFolder.isEnabled()).toBe(true); + await useFolder.click(); + + await page.locator(".new-session-page__message").fill("let the Gateway decide"); + const submit = page.getByRole("button", { name: "Start session" }); + await expect.poll(() => submit.isEnabled()).toBe(true); + await submit.click(); + await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ + params: { cwd: typedFolder, message: "let the Gateway decide" }, + }); + await gateway.rejectDeferred("sessions.create", { + code: "FORBIDDEN", + message: "missing scope: operator.admin", + }); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "missing scope: operator.admin", + ); + } finally { + await context.close(); + } + }); + it("allows admin-scoped incognito creation with exact dynamic parameters", async () => { const { context, gateway, page } = await openDraft([ "operator.admin", diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 3adb55cc93b0..e6044e163bb4 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -419,6 +419,14 @@ suite.define(() => { const gateway = await installMockGateway(page, { workspace: WORKSPACE, workspaceGit: true, + operatorScopes: ["operator.read", "operator.write"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "sessions.create", + "worktrees.branches", + ], methodResponses: { "agents.list": mainAgentList(), "worktrees.branches": GIT_BRANCHES, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6c3273973acd..e32f165a1fc0 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -699,7 +699,7 @@ export const en: TranslationMap = { recentFolders: "Recent", runsOn: "Runs on {place}", browse: "Browse folders", - browseRequiresAdmin: "Folder browsing needs an admin connection", + browseRequiresAdmin: "Browsing outside agent workspaces needs an admin connection", browserUp: "Parent folder", browserUse: "Use this folder", browserEmpty: "No subfolders", diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 07db3bfbb45e..294604454874 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -72,7 +72,7 @@ import { isMissingRestoredFolderError } from "./folder-validation.ts"; import { discoverGatewayName } from "./gateway-name-discovery.ts"; import { newSessionSearch, type NewSessionRouteData } from "./location.ts"; import { NewSessionModelControl } from "./model-control.ts"; -import { isAbsolutePath } from "./path.ts"; +import { isAbsolutePath, isKnownWorkspacePath } from "./path.ts"; import { renderPlaceSelect } from "./place-picker.ts"; import { loadNewSessionPreference, @@ -117,6 +117,7 @@ class NewSessionPage extends OpenClawLightDomElement { // Live head input; absolute paths stay applicable even without fs.listDir. @state() private browserPathDraft = ""; @state() private restoredFolderValidation: "none" | "checking" | "failed" = "none"; + @state() private gatewayApprovedWorkspaceRoots: string[] = []; private openedFor: string | null = null; private openedAgentId = ""; @@ -126,6 +127,7 @@ class NewSessionPage extends OpenClawLightDomElement { // Discovery retry provenance separates user choices from Gateway-derived defaults. private agentSelectedByUser = false; private folderSelectedByUser = false; + private folderGatewayApproved = false; private preferredWorktreeRestore = false; private worktreeSelectedByUser = false; private submitRequestToken = 0; @@ -343,6 +345,8 @@ class NewSessionPage extends OpenClawLightDomElement { this.attachmentDraft.abortReads(); this.closeBrowser(); this.cancelRestoredFolderValidation(); + this.gatewayApprovedWorkspaceRoots = []; + this.folderGatewayApproved = false; this.invalidateSubmission(submissionOutcome); if (!resetHostSelection) { return; @@ -604,11 +608,49 @@ class NewSessionPage extends OpenClawLightDomElement { return normalizeOptionalString(this.selectedAgent()?.workspace) ?? ""; } + private knownWorkspaceRoots(): string[] { + const configuredWorkspace = this.workspacePath(); + return configuredWorkspace + ? [configuredWorkspace, ...this.gatewayApprovedWorkspaceRoots] + : this.gatewayApprovedWorkspaceRoots; + } + + private recordGatewayApprovedListing(listing: FsListDirResult) { + if (this.isAdmin()) { + return; + } + const roots = new Set(this.gatewayApprovedWorkspaceRoots); + roots.add(listing.path); + if (listing.parent) { + roots.add(listing.parent); + } + if (roots.size !== this.gatewayApprovedWorkspaceRoots.length) { + this.gatewayApprovedWorkspaceRoots = [...roots]; + } + } + private usesCustomFolder(): boolean { const folder = this.folder.trim(); return Boolean(folder) && folder !== this.workspacePath(); } + private folderSubmissionMode(): "blocked" | "approved" | "server" { + if (this.restoredFolderValidation !== "none") { + return "blocked"; + } + if ( + !this.usesCustomFolder() || + this.isAdmin() || + this.folderGatewayApproved || + isKnownWorkspacePath(this.knownWorkspaceRoots(), this.folder) + ) { + return "approved"; + } + // Free-typed paths still reach sessions.create so the Gateway can return + // the authoritative missing-scope error instead of the UI dead-ending. + return "server"; + } + private buildCreateParamsForAccess( visibility: NewSessionVisibility = this.visibility, ): Record { @@ -704,18 +746,31 @@ class NewSessionPage extends OpenClawLightDomElement { this.restoredFolderValidation = "none"; } + private restoreWorkspaceFolder() { + this.restoredFolderValidation = "none"; + this.folderGatewayApproved = false; + if (this.error === t("newSession.browserLoadFailed")) { + this.error = null; + } + this.folder = this.workspacePath(); + this.worktree = false; + this.preferredWorktreeRestore = false; + this.persistPreference({ folder: this.folder, worktree: false }); + this.maybeLoadBranches(); + } + private validateRestoredFolder(folder: string) { const snapshot = this.context?.gateway.snapshot; const client = snapshot?.client; - if (snapshot?.phase !== "connected" || !client || !this.isAdmin()) { - this.restoredFolderValidation = "checking"; + if (snapshot?.phase !== "connected" || !client) { + this.restoreWorkspaceFolder(); return; } const requestId = ++this.restoredFolderValidationToken; this.restoredFolderValidation = "checking"; void client .request("fs.listDir", { path: folder }) - .then(() => { + .then((result) => { if ( requestId !== this.restoredFolderValidationToken || this.folderSelectedByUser || @@ -723,6 +778,8 @@ class NewSessionPage extends OpenClawLightDomElement { ) { return; } + this.recordGatewayApprovedListing(result); + this.folderGatewayApproved = !this.isAdmin(); this.restoredFolderValidation = "none"; if (this.error === t("newSession.browserLoadFailed")) { this.error = null; @@ -737,16 +794,8 @@ class NewSessionPage extends OpenClawLightDomElement { ) { return; } - if (isMissingRestoredFolderError(error)) { - this.restoredFolderValidation = "none"; - if (this.error === t("newSession.browserLoadFailed")) { - this.error = null; - } - this.folder = this.workspacePath(); - this.worktree = false; - this.preferredWorktreeRestore = false; - this.persistPreference({ folder: this.folder, worktree: false }); - this.maybeLoadBranches(); + if (!this.isAdmin() || isMissingRestoredFolderError(error)) { + this.restoreWorkspaceFolder(); return; } this.restoredFolderValidation = "failed"; @@ -781,13 +830,9 @@ class NewSessionPage extends OpenClawLightDomElement { Boolean(storedFolder) && storedFolder === preference?.workspace && preference.workspace !== workspace; - // Only an admin can browse outside the workspace, so any other stored - // folder is unreachable for this viewer. - const storedFolderUsable = - Boolean(storedFolder) && - !storedWorkspaceMoved && - (storedFolder === workspace || this.isAdmin()); + const storedFolderUsable = Boolean(storedFolder) && !storedWorkspaceMoved; this.folder = storedFolderUsable ? storedFolder : workspace; + this.folderGatewayApproved = false; this.folderSelectedByUser = false; this.preferredWorktreeRestore = preference?.worktree === true; this.worktreeSelectedByUser = false; @@ -827,6 +872,8 @@ class NewSessionPage extends OpenClawLightDomElement { this.agentSelectedByUser = false; this.folder = ""; this.folderSelectedByUser = false; + this.folderGatewayApproved = false; + this.gatewayApprovedWorkspaceRoots = []; this.cancelRestoredFolderValidation(); this.preferredWorktreeRestore = false; this.worktreeSelectedByUser = false; @@ -901,6 +948,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.visibility = recovery.createParams?.incognito === true ? "incognito" : "normal"; // Show the staged repo (not the agent workspace) while the draft is locked. this.folder = recovery.createParams?.cwd ?? ""; + this.folderGatewayApproved = false; this.setMessage(recovery.message); this.attachmentDraft.replace(restoreChatApiAttachments(recovery.attachments)); } @@ -929,6 +977,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.execNode = ""; this.folder = this.workspacePath(); this.folderSelectedByUser = false; + this.folderGatewayApproved = false; this.worktree = false; this.worktreeName = ""; this.closeBrowser(); @@ -1106,7 +1155,7 @@ class NewSessionPage extends OpenClawLightDomElement { if (!access.allowed) { return false; } - if (this.restoredFolderValidation !== "none") { + if (this.folderSubmissionMode() === "blocked") { return false; } // Stored model and worktree choices are provisional until their current @@ -1153,9 +1202,6 @@ class NewSessionPage extends OpenClawLightDomElement { ) { return false; } - if (this.usesCustomFolder() && !this.isAdmin()) { - return false; - } if (this.execNode && this.worktree) { return false; } @@ -1503,6 +1549,8 @@ class NewSessionPage extends OpenClawLightDomElement { this.error = null; this.agentSelectedByUser = true; this.folderSelectedByUser = false; + this.folderGatewayApproved = false; + this.gatewayApprovedWorkspaceRoots = []; this.preferredWorktreeRestore = false; this.worktreeSelectedByUser = false; this.cloudProfileId = ""; @@ -1530,7 +1578,7 @@ class NewSessionPage extends OpenClawLightDomElement { return this.repository.repoRoot === repoRoot; } - private applyFolder(folder: string, execNode = this.execNode) { + private applyFolder(folder: string, execNode = this.execNode, gatewayApproved = false) { if (this.submitting || this.pendingCloud.sessionKey) { return; } @@ -1542,6 +1590,7 @@ class NewSessionPage extends OpenClawLightDomElement { } this.error = null; this.folder = folder.trim(); + this.folderGatewayApproved = gatewayApproved && !execNode && !this.isAdmin(); this.folderSelectedByUser = true; this.preferredWorktreeRestore = false; this.worktreeSelectedByUser = true; @@ -1577,6 +1626,7 @@ class NewSessionPage extends OpenClawLightDomElement { // Folder paths belong to one host; never carry a Gateway or node path to another host. this.folder = execNode ? "" : this.workspacePath(); this.folderSelectedByUser = false; + this.folderGatewayApproved = false; } this.worktree = keepWorktree; this.closeBrowser(); @@ -1608,7 +1658,7 @@ class NewSessionPage extends OpenClawLightDomElement { } private browseAvailable(): boolean { - return this.isAdmin(); + return this.gatewayConnected && (this.isAdmin() || Boolean(this.workspacePath())); } private closeAgentDropdown() { @@ -1717,6 +1767,9 @@ class NewSessionPage extends OpenClawLightDomElement { return; } this.browserListing = result ?? null; + if (result) { + this.recordGatewayApprovedListing(result); + } // Sync the head input to the listed directory unless the user typed // while this request was in flight; their edit wins. if (result?.path && this.browserPathDraft === draftAtRequest) { @@ -1757,8 +1810,10 @@ class NewSessionPage extends OpenClawLightDomElement { const cloudDisabledReason = this.cloudDisabledReason(); return renderPlaceSelect({ browseAvailable: this.browseAvailable(), + isAdmin: this.isAdmin(), folder: this.folder, workspace: this.workspacePath(), + workspaceRoots: this.knownWorkspaceRoots(), sessions: this.context?.sessions.state.result?.sessions ?? [], execNodes: this.isAdmin() ? execNodes : [], gatewayName: this.gatewayName, @@ -1814,7 +1869,8 @@ class NewSessionPage extends OpenClawLightDomElement { }, onSelectExecNode: (nodeId) => this.selectExecNode(nodeId), onSelectCloudProfile: (profileId) => this.selectCloudProfile(profileId), - onApplyFolder: (folder, execNode) => this.applyFolder(folder, execNode), + onApplyFolder: (folder, execNode) => + this.applyFolder(folder, execNode, !execNode && this.browserListing?.path === folder), onBrowse: (target) => this.selectBrowserTarget(target), onBrowserPathDraftChange: (value) => { this.browserPathDraft = value; diff --git a/ui/src/pages/new-session/path.test.ts b/ui/src/pages/new-session/path.test.ts new file mode 100644 index 000000000000..325352424ba3 --- /dev/null +++ b/ui/src/pages/new-session/path.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment node +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; +import { isKnownWorkspacePath } from "./path.ts"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("isKnownWorkspacePath", () => { + it("accepts a canonical child after the Gateway approves a symlinked workspace root", async () => { + const tempRoot = await fs.realpath(os.tmpdir()); + const container = tempDirs.make("openclaw-ui-workspace-alias-", tempRoot); + const canonicalWorkspace = path.join(container, "canonical-workspace"); + const workspaceAlias = path.join(container, "workspace-alias"); + await fs.mkdir(path.join(canonicalWorkspace, "packages", "app"), { recursive: true }); + await fs.symlink(canonicalWorkspace, workspaceAlias); + + const approvedCanonicalRoot = await fs.realpath(workspaceAlias); + const canonicalFolder = path.join(approvedCanonicalRoot, "packages", "app"); + expect(canonicalFolder.startsWith(workspaceAlias)).toBe(false); + expect(isKnownWorkspacePath([workspaceAlias], canonicalFolder)).toBe(false); + expect(isKnownWorkspacePath([workspaceAlias, approvedCanonicalRoot], canonicalFolder)).toBe( + true, + ); + }); +}); diff --git a/ui/src/pages/new-session/path.ts b/ui/src/pages/new-session/path.ts index 10c7fedfeae1..a9010ad2cbcf 100644 --- a/ui/src/pages/new-session/path.ts +++ b/ui/src/pages/new-session/path.ts @@ -6,3 +6,46 @@ export function folderDisplayName(path: string): string { export function isAbsolutePath(path: string): boolean { return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path); } + +function comparableAbsolutePath(value: string): string | null { + if (!isAbsolutePath(value)) { + return null; + } + const path = value.trim().replaceAll("\\", "/"); + const windows = /^[A-Za-z]:\//u.test(path) || path.startsWith("//"); + const parts: string[] = []; + const floor = /^[A-Za-z]:\//u.test(path) ? 1 : path.startsWith("//") ? 2 : 0; + for (const part of path.split("/")) { + if (!part || part === ".") { + continue; + } + if (part === "..") { + if (parts.length > floor) { + parts.pop(); + } + continue; + } + parts.push(part); + } + const prefix = path.startsWith("//") ? "//" : path.startsWith("/") ? "/" : ""; + const normalized = `${prefix}${parts.join("/")}`.replace(/\/+$/u, "") || "/"; + return windows ? normalized.toLowerCase() : normalized; +} + +/** Client-side affordance check; the Gateway remains the realpath authority. */ +function isWorkspaceContainedPath(workspace: string, candidate: string): boolean { + const root = comparableAbsolutePath(workspace); + const target = comparableAbsolutePath(candidate); + if (!root || !target) { + return false; + } + return target === root || target.startsWith(root === "/" ? root : `${root}/`); +} + +/** Checks a path against every configured or Gateway-approved workspace spelling. */ +export function isKnownWorkspacePath( + workspaceRoots: readonly string[], + candidate: string, +): boolean { + return workspaceRoots.some((root) => isWorkspaceContainedPath(root, candidate)); +} diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index b89cba3bba79..85dd3b7274f5 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -4,7 +4,7 @@ import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { renderCloudProfileMenuItems, renderSessionMenuItem } from "./cloud-target.ts"; import type { BrowserTarget, DraftBranches, DraftCloudProfile, DraftNode } from "./discovery.ts"; -import { folderDisplayName } from "./path.ts"; +import { folderDisplayName, isKnownWorkspacePath } from "./path.ts"; import { disambiguate, isPhoneFamily, nodeTooltip } from "./place-labels.ts"; import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts"; @@ -131,8 +131,10 @@ function renderBrowseView(params: { export function renderPlaceSelect(params: { browseAvailable: boolean; + isAdmin: boolean; folder: string; workspace: string; + workspaceRoots: readonly string[]; sessions: readonly RecentPlaceSource[]; execNodes: DraftNode[]; gatewayName: string; @@ -196,9 +198,12 @@ export function renderPlaceSelect(params: { : gatewayLabel; const label = params.showDestinations ? `${folderLabel} · ${destinationLabel}` : folderLabel; const effectiveFolder = folder || params.workspace; - const recents = params.browseAvailable - ? recentPlaces(params.sessions, { workspace: params.workspace, execNodes: params.execNodes }) - : []; + const recents = recentPlaces(params.sessions, { + workspace: params.workspace, + execNodes: params.execNodes, + allowGatewayFolder: (recentFolder) => + params.isAdmin || isKnownWorkspacePath(params.workspaceRoots, recentFolder), + }); const recentItems = recents.map((recent) => { const node = params.execNodes.find((candidate) => candidate.nodeId === recent.execNode); const recentLabel = @@ -317,7 +322,9 @@ export function renderPlaceSelect(params: { class="session-menu__item" data-value="browse" aria-pressed="false" - title=${params.browseAvailable ? nothing : t("newSession.browseRequiresAdmin")} + title=${params.browseAvailable || params.isAdmin + ? nothing + : t("newSession.browseRequiresAdmin")} ?disabled=${params.submitting || params.pendingCloud || !params.browseAvailable} @click=${() => params.onBrowse(browseTarget)} > diff --git a/ui/src/pages/new-session/recent-places.test.ts b/ui/src/pages/new-session/recent-places.test.ts index c4e0a2cbde54..b66ddd247b6c 100644 --- a/ui/src/pages/new-session/recent-places.test.ts +++ b/ui/src/pages/new-session/recent-places.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; +import { isKnownWorkspacePath } from "./path.ts"; import { recentPlaces } from "./recent-places.ts"; describe("recentPlaces", () => { @@ -22,6 +23,7 @@ describe("recentPlaces", () => { { workspace: "/workspace", execNodes: [{ nodeId: "macbook" }], + allowGatewayFolder: () => true, }, ), ).toEqual([ @@ -31,4 +33,21 @@ describe("recentPlaces", () => { { folder: "/cwd/two", execNode: "" }, ]); }); + + it("filters Gateway recents through the viewer's folder boundary", () => { + expect( + recentPlaces( + [ + { execCwd: "/workspace/packages/app" }, + { execCwd: "/workspace-other/private" }, + { execCwd: "/node/repo", execNode: "macbook" }, + ], + { + workspace: "/workspace", + execNodes: [], + allowGatewayFolder: (folder) => isKnownWorkspacePath(["/workspace"], folder), + }, + ), + ).toEqual([{ folder: "/workspace/packages/app", execNode: "" }]); + }); }); diff --git a/ui/src/pages/new-session/recent-places.ts b/ui/src/pages/new-session/recent-places.ts index 2e3a519bbe8a..3ccb1843b284 100644 --- a/ui/src/pages/new-session/recent-places.ts +++ b/ui/src/pages/new-session/recent-places.ts @@ -17,6 +17,7 @@ export function recentPlaces( opts: { workspace: string; execNodes: readonly Pick[]; + allowGatewayFolder: (folder: string) => boolean; }, ): RecentPlace[] { const knownNodes = new Set(opts.execNodes.map((node) => node.nodeId)); @@ -30,6 +31,7 @@ export function recentPlaces( if ( !folder || (folder === opts.workspace && !execNode) || + (!execNode && !opts.allowGatewayFolder(folder)) || (execNode && !knownNodes.has(execNode)) ) { continue;