mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: browse and start sessions inside agent workspaces (#121417)
* feat(gateway): allow workspace-contained browsing * fix(ui): preserve canonical workspace selections
This commit is contained in:
committed by
GitHub
parent
98298a9f55
commit
3621c4b511
@@ -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/<name>`); 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.
|
||||
|
||||
|
||||
@@ -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/<name>`). 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/<name>`). 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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -12,14 +12,27 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
async function call(
|
||||
params: Record<string, unknown>,
|
||||
context: Record<string, unknown> = {
|
||||
getRuntimeConfig: () => ({}),
|
||||
nodeRegistry: { get: vi.fn(), invoke: vi.fn() },
|
||||
},
|
||||
client: Record<string, unknown> = { 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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | 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"
|
||||
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
@@ -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<FsListDirResult>("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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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: "" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export function recentPlaces(
|
||||
opts: {
|
||||
workspace: string;
|
||||
execNodes: readonly Pick<DraftNode, "nodeId">[];
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user