From 7b91fe9ea7c9385cd94a1f8e38215c954bbc16ac Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 27 Aug 2026 11:59:14 -0700 Subject: [PATCH] Hide host-path and execution-engine surfaces in managed-sandbox-only mode (#12293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - An instance can turn on the `enableManagedSandboxOnly` feature, which hides the local environment and runs every agent in the platform-managed environment > - That feature already gated the environment pickers, the onboarding wizard, and the server-side run selection, but many other screens still showed absolute paths on the execution host and still let the user pick an execution engine > - On such an instance those controls name a filesystem the user cannot reach; a path written there is stored and then ignored, which reads as a broken control > - This pull request hides the remaining host-path and execution-engine surfaces behind the same feature, adds a server rule that refuses a project-workspace path write while the feature is on, and closes a related route gap in the isolated-workspace pages > - The benefit is that a managed instance shows no host path and no folder picker anywhere, and a write that carries a path now fails with a clear message instead of being silently discarded ## Linked Issues or Issue Description No public issue exists. The description below follows `.github/ISSUE_TEMPLATE/enhancement.yml`. **What existing behavior does this improve?** The `enableManagedSandboxOnly` instance feature, and the UI surfaces that show a host filesystem path: project properties, the new-project dialog, the project workspace and execution workspace detail pages, the workspace and task cards, plugin local folders, and the agent configuration form with its per-adapter fields. It also improves route gating for `enableIsolatedWorkspaces`. **Subsystem affected** Cross-cutting (`ui/` and `server/`). **Current behavior** When `enableManagedSandboxOnly` is on, the local environment disappears from the environment pickers and the server refuses to run an agent on the local host. Everything else stays visible. A user still sees: - the project "Local folder" row, its absolute path, and the Set/Change/Clear buttons - the "Local folder" field and its "Choose" folder picker in the new-project dialog - the "Local path" field and fact row on a project workspace - the "Paths" and "Lifecycle commands" groups on an execution workspace - the working directory on workspace cards, task properties, and runtime service rows - the plugin "Local folders" section - "Working directory (deprecated)", "Command", "Execution engine", "ACP server command", "ACP state directory", and "Agent instructions file" in the agent configuration form A path typed into any of these names a filesystem no agent on the instance uses. The project workspace API also accepts a `cwd` write and stores it. Separately, `/workspaces`, `/execution-workspaces/*`, and `/projects/:projectId/workspaces/:workspaceId` render for anyone who types or bookmarks the URL, even with `enableIsolatedWorkspaces` off. Only the sidebar entry reads that flag. **Proposed behavior** With `enableManagedSandboxOnly` on, none of those surfaces render. A project whose codebase came from a managed checkout keeps its one-line "Paperclip-managed folder." label and shows no path. The non-path controls stay: repo URL, branch, service URL, port, command output, ACP session mode, ACP non-interactive permissions, Codex fast mode, and the sandbox toggles. The project-workspace create and patch routes, and the nested workspace on project create, answer `422` with "This instance runs agents only in the platform-managed environment; local folders are not configurable." when the payload carries a non-null `cwd`. A `cwd: null` write still passes, so an instance that just turned the feature on can clear a stale path. With `enableIsolatedWorkspaces` off, the three workspace route groups redirect to the dashboard. **Reason and benefit** A control that cannot do anything is worse than a missing control: the user fills it in, saves, and gets no error and no effect. The server rule turns that silent no-op into a clear refusal. The route gate stops a feature that an instance has turned off from staying reachable by URL, which is the same standard the Cases, Pipelines, and hidden-settings pages already meet. **Breaking changes** None for a default instance: both flags are off by default for self-hosted and managed instances, so nothing changes unless an operator turns them on. Stored `adapterConfig` values are never cleared, so turning the feature off restores every previous value. ## What Changed - Add `ui/src/hooks/useManagedSandboxOnly.ts`, modelled on `useAppsEnabled`, for components that do not already read the experimental settings. It exposes `hideHostPaths`, which fails closed while the settings query is in flight, so a cold cache never flashes a host path before the policy resolves. Components that keep their own settings read compute the same gate from `isFetched`. - Add `managedSandboxOnly` to `AdapterConfigFieldsProps` and populate it where `AgentConfigForm` builds the adapter field props. Resolve the effective instructions-file gate once as `hideInstructionsFile || hideHostPaths`, so every adapter hides that path field with no per-adapter edit. - Hide under the flag: the project "Local folder" block and its absolute-path edit panel (a managed checkout keeps its label, without the path); the new-project "Local folder" field; the project-workspace "Local path" field and fact row; the execution-workspace "Paths" and "Lifecycle commands" groups; the working directory on the workspace summary card, the task workspace card, the task properties "Folder" row, and the runtime service rows; the plugin "Local folders" section; "Working directory (deprecated)" and "Command" in the agent form; and the per-adapter "Execution engine", "ACP server command", and "ACP state directory" for `claude_local`, `codex_local`, and `gemini_local`. - Drop two working-directory fallbacks that had no gate to read: the close-workspace dialog now falls back to "No additional details", and the reuse-existing workspace label and picker subtitle fall back to a neutral phrase. - Refuse a non-null `cwd` with `422` on `POST /projects/:id/workspaces`, `PATCH /projects/:id/workspaces/:workspaceId`, and the nested workspace on `POST /companies/:companyId/projects`, following the `assertNoAgentHostWorkspaceCommandMutation` precedent on those routes. - Add `IsolatedWorkspacesRouteGate` and wrap the `/workspaces`, `/execution-workspaces/*`, and `/projects/:projectId/workspaces/:workspaceId` routes with it. - Leave the SSH "Remote workspace path" and the workspace file browser alone, with a comment explaining why. ## Verification Automated: - `pnpm --filter @paperclipai/ui exec vitest run` — 479 of 480 files pass (4455 of 4456 tests). The one failure is `OnboardingWizard.test.tsx > renders instead of throwing when the browser denies storage access`, which also fails on `origin/master` and is unrelated to this change. - `pnpm --filter @paperclipai/server exec vitest run project workspace instance-settings` — 45 of 50 files pass. Four files fail on macOS for reasons unrelated to this change: `workspace-instance-cleanup`, `workspace-runtime`, `execution-workspace-runtime-control-conflict`, and `workspace-runtime-exposure` compare `/var/...` against the resolved `/private/var/...` or bind real ports. The same files fail on a clean `master` checkout on the same machine. - `pnpm --filter @paperclipai/ui typecheck` - `tsc --noEmit` in `server/` (after `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps`). The package `typecheck` script also builds the Rust runner, which needs `cargo`; it is not installed on the machine that ran this. New and extended tests: - `ui/src/adapters/managed-sandbox-only-config-fields.test.tsx` — the three adapters drop the execution engine, the ACP paths, the instructions-file path, and every "Choose" button when the flag is on, and keep the non-path controls. - `ui/src/components/AgentConfigForm.render.test.tsx` — flag-on and flag-off renders for the working directory, the command, the engine, the ACP paths, and the resolved adapter field props. - `ui/src/components/ProjectProperties.managed-sandbox.test.tsx`, `ui/src/components/NewProjectDialog.managed-sandbox.test.tsx`, `ui/src/pages/ProjectWorkspaceDetail.test.tsx`, `ui/src/components/ProjectWorkspaceSummaryCard.test.tsx`, `ui/src/components/WorkspaceRuntimeControls.test.tsx`. - `ui/src/components/IsolatedWorkspacesRouteGate.test.tsx` — redirect when off, render when on, and render nothing while the flag query is in flight. - "Still loading" cases for the project properties, the new-project dialog, the workspace summary card, the runtime service rows, and the agent configuration form, each asserting that no host path renders before the policy resolves. - `server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts` — the `422` on all three write paths, the `cwd: null` pass-through, and the flag-off pass-through. Manual check to reproduce: turn on Managed Environment Only in instance experimental settings, then open a project, the new-project dialog, an agent's configuration, and a workspace page. No path, folder icon, or "Choose" button appears. Turn the setting off and each control returns with its stored value. No documentation change was needed. The operator-facing text for both settings lives in the feature catalog entry, which already states the contract this pull request now enforces across the UI. ## Risks - Low. Both flags default to off, so a default instance is unchanged. - The hidden fields are presentation only. No stored `adapterConfig` value is cleared, because an import carries adapter configuration written on another instance and clearing it would break that flow. Turning the setting off shows every previous value again. - The `422` is the one behavior change for an API caller, and only while the setting is on. `cwd: null` still passes so a stale path can be cleared. - The route gate renders nothing until the flag query settles, so an instance with isolated workspaces on never flashes a redirect. An instance with the feature off now redirects a bookmarked workspace URL to the dashboard. - Every host-path guard fails closed while the settings query is in flight, so a default instance shows those controls a moment later than before on a cold load. That is the safe direction: the alternative flashes a path a managed instance must never show. - Two path surfaces stay on purpose, each with a comment: the SSH "Remote workspace path" is a path on the user's own remote host, and the workspace file browser shows workspace-relative paths. The instance Adapters page also keeps its "Local path" install option, since that page is an instance-admin surface the hosting operator can already hide through the hidden-settings mechanism. ## Model Used Claude (Anthropic), Claude Opus, 1M context window, extended thinking, agentic tool use through Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ...t-workspace-managed-sandbox-routes.test.ts | 313 ++++++++++++++++++ server/src/routes/projects.ts | 30 +- ui/src/App.tsx | 23 +- .../adapters/claude-local/config-fields.tsx | 101 +++--- ui/src/adapters/codex-local/config-fields.tsx | 98 +++--- .../adapters/gemini-local/config-fields.tsx | 100 +++--- ...anaged-sandbox-only-config-fields.test.tsx | 182 ++++++++++ ui/src/adapters/types.ts | 9 + .../AgentConfigForm.render.test.tsx | 161 ++++++++- ui/src/components/AgentConfigForm.tsx | 103 ++++-- .../ExecutionWorkspaceCloseDialog.tsx | 8 +- .../IsolatedWorkspacesRouteGate.test.tsx | 90 +++++ .../IsolatedWorkspacesRouteGate.tsx | 30 ++ ui/src/components/IssueWorkspaceCard.tsx | 9 +- ui/src/components/NewIssueDialog.tsx | 7 +- .../NewProjectDialog.managed-sandbox.test.tsx | 117 +++++++ ui/src/components/NewProjectDialog.tsx | 55 +-- ...ProjectProperties.managed-sandbox.test.tsx | 166 ++++++++++ ui/src/components/ProjectProperties.tsx | 87 +++-- .../ProjectWorkspaceSummaryCard.test.tsx | 143 +++++++- .../ProjectWorkspaceSummaryCard.tsx | 9 +- .../WorkspaceRuntimeControls.test.tsx | 146 +++++++- .../components/WorkspaceRuntimeControls.tsx | 8 +- .../issue-properties/IssueProperties.tsx | 9 +- ui/src/hooks/useManagedSandboxOnly.ts | 46 +++ ui/src/lib/reusable-execution-workspaces.ts | 9 +- ui/src/pages/CompanyEnvironments.tsx | 7 + ui/src/pages/ExecutionWorkspaceDetail.tsx | 142 ++++---- ui/src/pages/PluginSettings.test.tsx | 43 ++- ui/src/pages/PluginSettings.tsx | 9 +- ui/src/pages/ProjectWorkspaceDetail.test.tsx | 57 ++++ ui/src/pages/ProjectWorkspaceDetail.tsx | 42 ++- 32 files changed, 2014 insertions(+), 345 deletions(-) create mode 100644 server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts create mode 100644 ui/src/adapters/managed-sandbox-only-config-fields.test.tsx create mode 100644 ui/src/components/IsolatedWorkspacesRouteGate.test.tsx create mode 100644 ui/src/components/IsolatedWorkspacesRouteGate.tsx create mode 100644 ui/src/components/NewProjectDialog.managed-sandbox.test.tsx create mode 100644 ui/src/components/ProjectProperties.managed-sandbox.test.tsx create mode 100644 ui/src/hooks/useManagedSandboxOnly.ts diff --git a/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts b/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts new file mode 100644 index 0000000000..5623fc6b07 --- /dev/null +++ b/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts @@ -0,0 +1,313 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project workspace + * `cwd` is an absolute path on the execution host. When every agent runs in the + * platform-managed environment there is no host for a user to point at, so the + * project-workspace write routes refuse a payload that carries one. These tests + * pin the floor behind the hidden UI field on all three write paths. + */ + +const MANAGED_SANDBOX_CWD_ERROR = + "This instance runs agents only in the platform-managed environment; local folders are not configurable."; + +const mockProjectService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + createWorkspace: vi.fn(), + listWorkspaces: vi.fn(), + updateWorkspace: vi.fn(), + removeWorkspace: vi.fn(), + remove: vi.fn(), + resolveByReference: vi.fn(), +})); +const mockSecretService = vi.hoisted(() => ({ + normalizeEnvBindingsForPersistence: vi.fn(), +})); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + environmentService: () => mockEnvironmentService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, +})); + +vi.mock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); + +vi.mock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), +})); + +function registerModuleMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + environmentService: () => mockEnvironmentService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, + })); + + vi.doMock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); +} + +async function createApp() { + const [{ projectRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/projects.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).actor = { + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app.use("/api", projectRoutes({} as any)); + app.use(errorHandler); + return app; +} + +function buildProject(overrides: Record = {}) { + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "backlog", + leadAgentId: null, + targetDate: null, + color: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project", + effectiveLocalFolder: "/tmp/project", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function buildWorkspace(overrides: Record = {}) { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "Primary", + sourceType: "local_path", + cwd: "/srv/projects/paperclip", + repoUrl: null, + isPrimary: true, + ...overrides, + }; +} + +function setManagedSandboxOnly(enabled: boolean) { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableManagedSandboxOnly: enabled, + }); +} + +describe("project workspace host-path floor", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/projects.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doUnmock("../services/environments.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/secrets.js"); + registerModuleMocks(); + vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + action: "project:read", + reason: "allow_test", + explanation: "Allowed by test mock.", + }); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.getById.mockResolvedValue(buildProject()); + mockProjectService.create.mockResolvedValue(buildProject()); + mockProjectService.createWorkspace.mockResolvedValue(buildWorkspace()); + mockProjectService.updateWorkspace.mockResolvedValue(buildWorkspace()); + mockProjectService.listWorkspaces.mockResolvedValue([buildWorkspace()]); + mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); + setManagedSandboxOnly(false); + }); + + it("creates a project workspace with a cwd when the policy is off", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/projects/project-1/workspaces") + .send({ name: "Primary", cwd: "/srv/projects/paperclip" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockProjectService.createWorkspace).toHaveBeenCalledWith( + "project-1", + expect.objectContaining({ cwd: "/srv/projects/paperclip" }), + ); + }); + + it("refuses a project workspace create that carries a cwd when the policy is on", async () => { + setManagedSandboxOnly(true); + const app = await createApp(); + const res = await request(app) + .post("/api/projects/project-1/workspaces") + .send({ name: "Primary", cwd: "/srv/projects/paperclip" }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.error).toBe(MANAGED_SANDBOX_CWD_ERROR); + expect(mockProjectService.createWorkspace).not.toHaveBeenCalled(); + }); + + it("refuses a project workspace patch that carries a cwd when the policy is on", async () => { + setManagedSandboxOnly(true); + const app = await createApp(); + const res = await request(app) + .patch("/api/projects/project-1/workspaces/workspace-1") + .send({ cwd: "/srv/projects/paperclip" }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.error).toBe(MANAGED_SANDBOX_CWD_ERROR); + expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("still allows clearing a stale cwd when the policy is on", async () => { + setManagedSandboxOnly(true); + mockProjectService.updateWorkspace.mockResolvedValue(buildWorkspace({ cwd: null })); + const app = await createApp(); + const res = await request(app) + .patch("/api/projects/project-1/workspaces/workspace-1") + .send({ cwd: null }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockProjectService.updateWorkspace).toHaveBeenCalledWith( + "project-1", + "workspace-1", + expect.objectContaining({ cwd: null }), + ); + }); + + it("patches a project workspace cwd when the policy is off", async () => { + const app = await createApp(); + const res = await request(app) + .patch("/api/projects/project-1/workspaces/workspace-1") + .send({ cwd: "/srv/projects/paperclip" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockProjectService.updateWorkspace).toHaveBeenCalledWith( + "project-1", + "workspace-1", + expect.objectContaining({ cwd: "/srv/projects/paperclip" }), + ); + }); + + it("refuses a nested workspace cwd on project create when the policy is on", async () => { + setManagedSandboxOnly(true); + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Project", + workspace: { name: "Primary", cwd: "/srv/projects/paperclip" }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.error).toBe(MANAGED_SANDBOX_CWD_ERROR); + // The floor runs before the project row is written, so nothing is orphaned. + expect(mockProjectService.create).not.toHaveBeenCalled(); + expect(mockProjectService.createWorkspace).not.toHaveBeenCalled(); + }); + + it("accepts a nested workspace with only a repo URL when the policy is on", async () => { + setManagedSandboxOnly(true); + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Project", + workspace: { name: "Primary", repoUrl: "https://github.com/paperclipai/paperclip" }, + }); + + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); + expect(mockProjectService.createWorkspace).toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index c01a028ddb..57518c80b5 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -14,7 +14,7 @@ import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } fr import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js"; -import { conflict, forbidden } from "../errors.js"; +import { conflict, forbidden, unprocessable } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; @@ -53,6 +53,31 @@ export function projectRoutes(db: Db) { const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); + /** + * Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project + * workspace `cwd` is an absolute path on the execution host. When the policy + * is on every agent runs in the platform-managed environment, so there is no + * host for a user to point at and a write that carries a path is refused + * rather than stored and silently ignored. This is the floor behind the + * hidden UI field, and it applies to every actor, mirroring how + * `assertNoAgentHostWorkspaceCommandMutation` floors host-executed commands + * on these same routes. + * + * `cwd: null` still passes: clearing a stale path is exactly what an instance + * that just turned the policy on needs to do. The settings read only happens + * when the payload actually carries a path. + */ + async function assertNoManagedSandboxWorkspacePath(workspacePatch: unknown) { + if (typeof workspacePatch !== "object" || workspacePatch === null || Array.isArray(workspacePatch)) return; + const patch = workspacePatch as Record; + if (!Object.prototype.hasOwnProperty.call(patch, "cwd")) return; + if (patch.cwd === null || patch.cwd === undefined) return; + if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly !== true) return; + throw unprocessable( + "This instance runs agents only in the platform-managed environment; local folders are not configurable.", + ); + } + async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) { if (environmentId === undefined || environmentId === null) return; await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { @@ -169,6 +194,7 @@ export function projectRoutes(db: Db) { ...collectProjectWorkspaceCommandPaths(workspace, "workspace"), ], ); + await assertNoManagedSandboxWorkspacePath(workspace); if (projectData.env !== undefined) { projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence( companyId, @@ -290,6 +316,7 @@ export function projectRoutes(db: Db) { req, collectProjectWorkspaceCommandPaths(req.body), ); + await assertNoManagedSandboxWorkspacePath(req.body); const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); @@ -328,6 +355,7 @@ export function projectRoutes(db: Db) { req, collectProjectWorkspaceCommandPaths(req.body), ); + await assertNoManagedSandboxWorkspacePath(req.body); const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId); if (!workspaceExists) { res.status(404).json({ error: "Project workspace not found" }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 66490c991c..b507a08f5d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimenta import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; import { CloudManagedPageGate } from "./components/CloudManagedPageGate"; import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate"; +import { IsolatedWorkspacesRouteGate } from "./components/IsolatedWorkspacesRouteGate"; import { useHiddenSettings } from "./hooks/useHiddenSettings"; import { Cases } from "./pages/Cases"; import { CaseDetail } from "./pages/CaseDetail"; @@ -221,11 +222,15 @@ function boardRoutes() { } /> } /> } /> - } /> + }> + } /> + } /> } /> } /> - } /> + }> + } /> + } /> } /> } /> @@ -291,12 +296,14 @@ function boardRoutes() { /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index d0604b79ab..fa869780c9 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -77,6 +77,7 @@ export function ClaudeLocalAdvancedFields({ config, eff, mark, + managedSandboxOnly, }: AdapterConfigFieldsProps) { const rawEngine = isCreate ? values!.claudeEngine ?? "auto" @@ -86,7 +87,13 @@ export function ClaudeLocalAdvancedFields({ return ( <> - + {/* + The execution engine picks which binary runs on the execution host, and + the ACP sub-fields below name host paths. The platform-managed + environment owns both, so the managed-sandbox-only policy hides them, + the same way `runnerManaged` hides them for the Paperclip Runner. + */} + {!managedSandboxOnly && - + } {acpSelected && ( <> - - - isCreate - ? set!({ claudeAcpAgentCommand: v }) - : mark("adapterConfig", "agentCommand", v || undefined) - } - immediate - className={inputClass} - placeholder="claude-agent-acp" - /> - + {!managedSandboxOnly && ( + + + isCreate + ? set!({ claudeAcpAgentCommand: v }) + : mark("adapterConfig", "agentCommand", v || undefined) + } + immediate + className={inputClass} + placeholder="claude-agent-acp" + /> + + )} - -
- - isCreate - ? set!({ claudeAcpStateDir: v }) - : mark("adapterConfig", "stateDir", v || undefined) - } - immediate - className={inputClass} - placeholder="/path/to/acp-state" - /> - -
-
+ {!managedSandboxOnly && ( + +
+ + isCreate + ? set!({ claudeAcpStateDir: v }) + : mark("adapterConfig", "stateDir", v || undefined) + } + immediate + className={inputClass} + placeholder="/path/to/acp-state" + /> + +
+
+ )} - {!runnerManaged && + {!hideEngineChoice && Fail - -
- - isCreate - ? set!({ codexAcpStateDir: v }) - : mark("adapterConfig", "stateDir", v || undefined) - } - immediate - className={inputClass} - placeholder="/path/to/acp-state" - /> - -
-
+ {!managedSandboxOnly && ( + +
+ + isCreate + ? set!({ codexAcpStateDir: v }) + : mark("adapterConfig", "stateDir", v || undefined) + } + immediate + className={inputClass} + placeholder="/path/to/acp-state" + /> + +
+
+ )} - + {/* + The execution engine picks which binary runs on the execution host, and + the ACP sub-fields below name host paths. The platform-managed + environment owns both, so the managed-sandbox-only policy hides them. + */} + {!managedSandboxOnly && - + } {acpSelected && ( <> - - - isCreate - ? set!({ geminiAcpAgentCommand: v }) - : mark("adapterConfig", "agentCommand", v || undefined) - } - immediate - className={inputClass} - placeholder="gemini --acp" - /> - + {!managedSandboxOnly && ( + + + isCreate + ? set!({ geminiAcpAgentCommand: v }) + : mark("adapterConfig", "agentCommand", v || undefined) + } + immediate + className={inputClass} + placeholder="gemini --acp" + /> + + )} - -
- - isCreate - ? set!({ geminiAcpStateDir: v }) - : mark("adapterConfig", "stateDir", v || undefined) - } - immediate - className={inputClass} - placeholder="/path/to/acp-state" - /> - -
-
+ {!managedSandboxOnly && ( + +
+ + isCreate + ? set!({ geminiAcpStateDir: v }) + : mark("adapterConfig", "stateDir", v || undefined) + } + immediate + className={inputClass} + placeholder="/path/to/acp-state" + /> + +
+
+ )} , + overrides: Partial = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const props: AdapterConfigFieldsProps = { + mode: "edit", + isCreate: false, + adapterType: "codex_local", + values: null, + set: null, + config: ACP_CONFIG, + eff: (_group, _field, original) => original, + mark: vi.fn(), + models: [], + ...overrides, + }; + + act(() => { + root.render( + + + , + ); + }); + + return { container, root }; +} + +function fieldLabels(container: HTMLElement) { + return Array.from(container.querySelectorAll("label")).map((label) => label.textContent?.trim() ?? ""); +} + +function choosePathButtons(container: HTMLElement) { + return Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent?.trim() === "Choose", + ); +} + +describe("adapter config fields under the managed-sandbox-only policy", () => { + const roots: Root[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()); + } + document.body.innerHTML = ""; + }); + + it("renders the Claude execution engine and ACP paths when the policy is off", () => { + const result = renderFields(ClaudeLocalAdvancedFields, { adapterType: "claude_local" }); + roots.push(result.root); + + const labels = fieldLabels(result.container); + expect(labels).toContain("Execution engine"); + expect(labels).toContain("ACP server command"); + expect(labels).toContain("ACP state directory"); + expect(choosePathButtons(result.container)).toHaveLength(1); + }); + + it("drops the Claude execution engine and ACP paths when the policy is on", () => { + const result = renderFields(ClaudeLocalAdvancedFields, { + adapterType: "claude_local", + managedSandboxOnly: true, + }); + roots.push(result.root); + + const labels = fieldLabels(result.container); + expect(labels).not.toContain("Execution engine"); + expect(labels).not.toContain("ACP server command"); + expect(labels).not.toContain("ACP state directory"); + expect(choosePathButtons(result.container)).toHaveLength(0); + expect(result.container.textContent).not.toContain("/srv/agents/cody/acp-state"); + // The non-path ACP controls describe run behavior, not the host, so they stay. + expect(labels).toContain("ACP session mode"); + expect(labels).toContain("ACP non-interactive permissions"); + }); + + it("drops the Claude instructions-file path once the form resolves the gate", () => { + const visible = renderFields(ClaudeLocalConfigFields, { adapterType: "claude_local" }); + roots.push(visible.root); + expect(fieldLabels(visible.container)).toContain("Agent instructions file"); + + // The form resolves `hideInstructionsFile || managedSandboxOnly` once, so + // every adapter hides the path with no per-adapter branch. + const hidden = renderFields(ClaudeLocalConfigFields, { + adapterType: "claude_local", + hideInstructionsFile: true, + managedSandboxOnly: true, + }); + roots.push(hidden.root); + expect(fieldLabels(hidden.container)).not.toContain("Agent instructions file"); + expect(choosePathButtons(hidden.container)).toHaveLength(0); + }); + + it("renders the Codex execution engine and paths when the policy is off", () => { + const result = renderFields(CodexLocalConfigFields); + roots.push(result.root); + + const labels = fieldLabels(result.container); + expect(labels).toContain("Execution engine"); + expect(labels).toContain("ACP server command"); + expect(labels).toContain("ACP state directory"); + expect(labels).toContain("Agent instructions file"); + expect(choosePathButtons(result.container).length).toBeGreaterThan(0); + }); + + it("drops the Codex execution engine and paths when the policy is on", () => { + const result = renderFields(CodexLocalConfigFields, { + managedSandboxOnly: true, + hideInstructionsFile: true, + }); + roots.push(result.root); + + const labels = fieldLabels(result.container); + expect(labels).not.toContain("Execution engine"); + expect(labels).not.toContain("ACP server command"); + expect(labels).not.toContain("ACP state directory"); + expect(labels).not.toContain("Agent instructions file"); + expect(choosePathButtons(result.container)).toHaveLength(0); + expect(result.container.textContent).not.toContain("/srv/agents/cody/acp-state"); + // Codex behavior toggles are not host paths, so the policy keeps them. + expect(result.container.textContent).toContain("Fast mode"); + }); + + it("renders the Gemini execution engine and paths when the policy is off", () => { + const result = renderFields(GeminiLocalConfigFields, { adapterType: "gemini_local" }); + roots.push(result.root); + + const labels = fieldLabels(result.container); + expect(labels).toContain("Execution engine"); + expect(labels).toContain("ACP server command"); + expect(labels).toContain("ACP state directory"); + expect(labels).toContain("Agent instructions file"); + }); + + it("drops the Gemini execution engine and paths when the policy is on", () => { + const result = renderFields(GeminiLocalConfigFields, { + adapterType: "gemini_local", + managedSandboxOnly: true, + hideInstructionsFile: true, + }); + roots.push(result.root); + + const labels = fieldLabels(result.container); + expect(labels).not.toContain("Execution engine"); + expect(labels).not.toContain("ACP server command"); + expect(labels).not.toContain("ACP state directory"); + expect(labels).not.toContain("Agent instructions file"); + expect(choosePathButtons(result.container)).toHaveLength(0); + expect(labels).toContain("ACP session mode"); + }); +}); diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index 8bfa4d7c78..f8a099c95a 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -34,6 +34,15 @@ export interface AdapterConfigFieldsProps { models: { id: string; label: string }[]; /** When true, hides the instructions file path field (e.g. during import where it's set automatically) */ hideInstructionsFile?: boolean; + /** + * When true, the adapter must hide every host filesystem path field and every + * execution-engine choice. Non-path behavior toggles stay visible. + * + * The form sets this from the instance managed-sandbox-only policy + * (`enableManagedSandboxOnly`), and also while that policy is still loading, + * so a stored path never flashes before the policy resolves. + */ + managedSandboxOnly?: boolean; } export interface UIAdapterModule extends TranscriptParserSource { diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index a7940c3600..927bb46491 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -91,10 +91,23 @@ vi.mock("../adapters", () => ({ getUIAdapter: (type: string) => ({ type, label: type === "hermes_gateway" ? "Hermes Gateway" : "Codex", - ConfigFields: ({ adapterType }: { adapterType: string }) => + // The stand-in also records the two gates the form resolves for every + // adapter, so a test can assert the plumbing without rendering a real + // adapter's fields. + ConfigFields: ({ adapterType, hideInstructionsFile, managedSandboxOnly }: { + adapterType: string; + hideInstructionsFile?: boolean; + managedSandboxOnly?: boolean; + }) => adapterType === "hermes_gateway" ?
Hermes Gateway fields
- : null, + : ( +
+ ), buildAdapterConfig: (values: { model?: string }) => ({ model: values.model || undefined, }), @@ -2686,3 +2699,147 @@ describe("AgentConfigForm edit-mode Claude OAuth binding", () => { }); }); + +describe("AgentConfigForm managed-sandbox-only host surfaces", () => { + let roots: Root[] = []; + + const MANAGED_AGENT_CONFIG = { + cwd: "/srv/agents/cody", + command: "claude", + engine: "acp", + agentCommand: "claude-agent-acp", + stateDir: "/srv/agents/cody/acp-state", + }; + + function setManagedSandboxOnly(enabled: boolean) { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableEnvironments: true, + enableManagedSandboxOnly: enabled, + }); + } + + /** Every `Field` renders its label in a `
))} diff --git a/ui/src/components/IsolatedWorkspacesRouteGate.test.tsx b/ui/src/components/IsolatedWorkspacesRouteGate.test.tsx new file mode 100644 index 0000000000..5748b4bce9 --- /dev/null +++ b/ui/src/components/IsolatedWorkspacesRouteGate.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IsolatedWorkspacesRouteGate } from "./IsolatedWorkspacesRouteGate"; + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( +
+ ), + Outlet: () =>
, +})); + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("IsolatedWorkspacesRouteGate", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate() { + root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("redirects to the dashboard when isolated workspaces are disabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + await renderGate(); + + const navigate = container.querySelector('[data-testid="navigate"]'); + expect(navigate?.getAttribute("data-to")).toBe("/dashboard"); + expect(navigate?.getAttribute("data-replace")).toBe("true"); + expect(container.querySelector('[data-testid="workspace-route"]')).toBeNull(); + }); + + it("renders the workspace routes when isolated workspaces are enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + await renderGate(); + + expect(container.querySelector('[data-testid="workspace-route"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + }); + + it("renders nothing while the flag is loading, so an enabled instance never flashes a redirect", async () => { + mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); + await renderGate(); + + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + expect(container.querySelector('[data-testid="workspace-route"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/IsolatedWorkspacesRouteGate.tsx b/ui/src/components/IsolatedWorkspacesRouteGate.tsx new file mode 100644 index 0000000000..d931145617 --- /dev/null +++ b/ui/src/components/IsolatedWorkspacesRouteGate.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { Navigate, Outlet } from "@/lib/router"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Route gate for the isolated-workspace pages: the workspaces board, the + * execution-workspace detail tabs, and the project-workspace detail page. + * + * The sidebar entry for these pages already reads `enableIsolatedWorkspaces`, + * but the routes rendered for anyone who typed or bookmarked the URL, so the + * whole workspace surface stayed reachable on an instance with the feature off. + * The gate redirects to the dashboard instead, mirroring + * {@link HiddenSettingsPageGate}. + * + * Nothing renders until the flag query settles, so an instance that has the + * feature on never flashes a redirect on a hard load. + */ +export function IsolatedWorkspacesRouteGate() { + const { data: experimentalSettings, isFetched } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + + if (!isFetched) return null; + if (experimentalSettings?.enableIsolatedWorkspaces !== true) { + return ; + } + return ; +} diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 4f2e5921ab..a06b7101cf 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -216,6 +216,13 @@ export function IssueWorkspaceCard({ }); const environmentsEnabled = experimentalSettings?.enableEnvironments === true; + // Managed-sandbox-only policy: the workspace path is a host filesystem path, + // so the card omits it and keeps branch, repo, and environment. The gate fails + // closed whenever the policy is unknown — in flight and also on a failed read + // — because an unresolved policy reads as "not managed" and would show the + // path the policy exists to hide. + const hideHostPaths = + experimentalSettings === undefined || experimentalSettings.enableManagedSandboxOnly === true; const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true && Boolean(project?.executionWorkspacePolicy?.enabled); @@ -403,7 +410,7 @@ export function IssueWorkspaceCard({
)} - {workspace?.cwd && ( + {workspace?.cwd && !hideHostPaths && (
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 8de5329688..e316957835 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1924,9 +1924,14 @@ export function NewIssueDialog() { disablePortal /> )} + {/* + The label used to fall back to the workspace working directory, + a path on the execution host. It now falls back to a neutral + phrase, so the dialog never renders a host path. + */} {executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
- Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}. + Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? "existing execution workspace"}.
)} {showParentWorkspaceWarning ? ( diff --git a/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx b/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx new file mode 100644 index 0000000000..270d524f0d --- /dev/null +++ b/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NewProjectDialog } from "./NewProjectDialog"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { queryKeys } from "../lib/queryKeys"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void) { + flushSync(() => { + callback(); + }); +} + +vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), createWorkspace: vi.fn() } })); +vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } })); +vi.mock("../api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([]) } })); +vi.mock("../api/access", () => ({ accessApi: { listUserDirectory: vi.fn().mockResolvedValue({ users: [] }) } })); +vi.mock("../api/assets", () => ({ assetsApi: { uploadImage: vi.fn() } })); +vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({}) } })); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => ({ newProjectOpen: true, closeNewProject: vi.fn() }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip" }, + }), +})); + +vi.mock("./MarkdownEditor", () => ({ + MarkdownEditor: () =>
, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); +}); + +/** Pass `null` for `experimentalSettings` to render with the policy unresolved. */ +function render(experimentalSettings: Record | null) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (experimentalSettings) { + client.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings); + } + act(() => { + root.render( + + + + + , + ); + }); +} + +/** The dialog renders into a portal, so assertions read the whole document. */ +function documentText() { + return document.body.textContent ?? ""; +} + +function localPathInput() { + return document.body.querySelector('input[placeholder="/absolute/path/to/workspace"]'); +} + +describe("NewProjectDialog — local folder under the managed-sandbox-only policy", () => { + it("offers the local folder field and its picker when the policy is off", () => { + render({}); + + expect(documentText()).toContain("Local folder"); + expect(localPathInput()).not.toBeNull(); + const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter( + (button) => button.textContent?.trim() === "Choose", + ); + expect(chooseButtons.length).toBeGreaterThan(0); + }); + + it("keeps the local folder field hidden while the policy is still loading", () => { + // A cold cache resolves the policy to false on the first render. The guard + // fails closed so a managed instance never flashes the field. + render(null); + + expect(documentText()).not.toContain("Local folder"); + expect(localPathInput()).toBeNull(); + expect(documentText()).toContain("Repo URL"); + }); + + it("hides the local folder field and its picker when the policy is on", () => { + render({ enableManagedSandboxOnly: true }); + + expect(documentText()).not.toContain("Local folder"); + expect(localPathInput()).toBeNull(); + const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter( + (button) => button.textContent?.trim() === "Choose", + ); + expect(chooseButtons).toHaveLength(0); + // The repo field is unrelated to the host filesystem, so it stays. + expect(documentText()).toContain("Repo URL"); + }); +}); diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index a041a72a65..90efd3ae70 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -37,6 +37,7 @@ import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; import { ChoosePathButton } from "./PathInstructionsModal"; +import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly"; const projectStatuses = [ { value: "backlog", label: "Backlog" }, @@ -50,6 +51,7 @@ export function NewProjectDialog() { const { newProjectOpen, closeNewProject } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); + const { hideHostPaths } = useManagedSandboxOnly(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("planned"); @@ -301,29 +303,38 @@ export function NewProjectDialog() { />
-
-
- - optional - - - - - - Set an absolute path on this machine where local agents will read and write files for this project. - - + {/* + The local folder is an absolute path on the execution host. Under + the managed-sandbox-only policy every agent runs in the + platform-managed environment, so the field and its folder picker + never render and the create request carries no cwd. The field also + stays hidden until the policy is known. + */} + {!hideHostPaths && ( +
+
+ + optional + + + + + + Set an absolute path on this machine where local agents will read and write files for this project. + + +
+
+ { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }} + placeholder="/absolute/path/to/workspace" + /> + +
-
- { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }} - placeholder="/absolute/path/to/workspace" - /> - -
-
+ )} {workspaceError && (

{workspaceError}

diff --git a/ui/src/components/ProjectProperties.managed-sandbox.test.tsx b/ui/src/components/ProjectProperties.managed-sandbox.test.tsx new file mode 100644 index 0000000000..cbb90eb880 --- /dev/null +++ b/ui/src/components/ProjectProperties.managed-sandbox.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project, ProjectCodebase } from "@paperclipai/shared"; +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectProperties } from "./ProjectProperties"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { queryKeys } from "../lib/queryKeys"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void) { + flushSync(() => { + callback(); + }); +} + +const noop = vi.hoisted(() => () => undefined); + +vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } })); +vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } })); +vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } })); +vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } })); +vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({}) } })); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }), +})); + +vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null })); +vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) =>
{value}
})); + +const LOCAL_FOLDER = "/Users/paperclip/projects/test-project"; +const MANAGED_FOLDER = "/var/paperclip/checkouts/test-project"; + +function makeCodebase(overrides: Partial = {}): ProjectCodebase { + return { + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip", + repoRef: "master", + defaultRef: "origin/master", + repoName: "paperclipai/paperclip", + localFolder: LOCAL_FOLDER, + managedFolder: MANAGED_FOLDER, + effectiveLocalFolder: LOCAL_FOLDER, + origin: "local_folder", + ...overrides, + }; +} + +function makeProject(codebase: ProjectCodebase): Project { + return { + id: "project-1", + urlKey: "project-1", + name: "Test project", + description: "", + status: "in_progress", + goalIds: [], + goals: [], + env: null, + codebase, + primaryWorkspace: null, + workspaces: [], + executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true }, + } as unknown as Project; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +/** Pass `null` for `experimentalSettings` to render with the policy unresolved. */ +function render(project: Project, experimentalSettings: Record | null) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (experimentalSettings) { + client.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings); + } + act(() => { + root.render( + + + "idle"} onArchive={noop} /> + + , + ); + }); +} + +function buttonLabels() { + return Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim() ?? ""); +} + +describe("ProjectProperties — local folder under the managed-sandbox-only policy", () => { + it("shows the folder path and its controls when the policy is off", () => { + render(makeProject(makeCodebase()), { enableIsolatedWorkspaces: true }); + + expect(container.textContent).toContain("Local folder"); + expect(container.textContent).toContain(LOCAL_FOLDER); + expect(buttonLabels()).toContain("Change local folder"); + expect(container.querySelector('button[aria-label="Clear local folder"]')).not.toBeNull(); + }); + + it("hides the folder path and its controls when the policy is on", () => { + render(makeProject(makeCodebase()), { + enableIsolatedWorkspaces: true, + enableManagedSandboxOnly: true, + }); + + expect(container.textContent).not.toContain("Local folder"); + expect(container.textContent).not.toContain(LOCAL_FOLDER); + expect(buttonLabels()).not.toContain("Change local folder"); + expect(container.querySelector('button[aria-label="Clear local folder"]')).toBeNull(); + // The repo row is unrelated to the host filesystem, so it stays. + expect(container.textContent).toContain("Repo"); + }); + + it("keeps only the managed-folder label for a managed checkout when the policy is on", () => { + render( + makeProject(makeCodebase({ + localFolder: null, + effectiveLocalFolder: MANAGED_FOLDER, + origin: "managed_checkout", + })), + { enableIsolatedWorkspaces: true, enableManagedSandboxOnly: true }, + ); + + expect(container.textContent).toContain("Paperclip-managed folder."); + expect(container.textContent).not.toContain(MANAGED_FOLDER); + expect(container.querySelector(".font-mono")?.textContent).not.toBe(MANAGED_FOLDER); + expect(buttonLabels()).not.toContain("Set local folder"); + }); + + it("keeps the folder path hidden while the policy is still loading", () => { + // A cold cache resolves the policy to false on the first render. The guard + // fails closed so a managed instance never flashes the execution-host path. + render(makeProject(makeCodebase()), null); + + expect(container.textContent).not.toContain("Local folder"); + expect(container.textContent).not.toContain(LOCAL_FOLDER); + expect(container.textContent).toContain("Repo"); + }); + + it("never opens the absolute-path edit panel when the policy is on", () => { + render(makeProject(makeCodebase()), { + enableIsolatedWorkspaces: true, + enableManagedSandboxOnly: true, + }); + + const pathInput = container.querySelector('input[placeholder="/absolute/path/to/workspace"]'); + expect(pathInput).toBeNull(); + }); +}); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 7a4cde1df5..644ce56fa1 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -343,6 +343,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa // Defense in depth alongside the server's managed-sandbox-only read // filter: a cached environments list may still carry the local row. const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true; + // The gate for the host-path surfaces below. It fails closed whenever the + // policy is unknown — in flight and also on a failed read: an unresolved + // policy reads as "not managed", which would show the local folder the policy + // exists to hide. + const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly; const runSelectableEnvironments = filterManagedSandboxSelectableEnvironments( environments ?? [], managedSandboxOnly, @@ -712,7 +717,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa - Repo identifies the source of truth. Local folder is the default place agents write code. + {hideHostPaths + ? "Repo identifies the source of truth. Agents check it out in the platform-managed environment." + : "Repo identifies the source of truth. Local folder is the default place agents write code."}
@@ -780,43 +787,57 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa )}
-
-
Local folder
-
-
-
- {codebase.effectiveLocalFolder} + {/* + The local folder is an absolute path on the execution host. Under + the managed-sandbox-only policy every agent runs in the + platform-managed environment, so the path, the folder controls, + and the edit panel below all disappear. A managed checkout keeps + its one-line label so the codebase still reads as accounted for, + but never renders the path itself. + */} + {hideHostPaths ? ( + codebase.origin === "managed_checkout" ? ( +
Paperclip-managed folder.
+ ) : null + ) : ( +
+
Local folder
+
+
+
+ {codebase.effectiveLocalFolder} +
+ {codebase.origin === "managed_checkout" && ( +
Paperclip-managed folder.
+ )}
- {codebase.origin === "managed_checkout" && ( -
Paperclip-managed folder.
- )} -
-
- - {codebase.localFolder ? ( +
- ) : null} + {codebase.localFolder ? ( + + ) : null} +
-
+ )} {hasAdditionalLegacyWorkspaces && (
@@ -882,7 +903,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
) : null}
- {workspaceMode === "local" && ( + {!hideHostPaths && workspaceMode === "local" && (
({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => {children}, })); @@ -19,6 +29,20 @@ vi.mock("./IssuesQuicklook", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +/** + * The card reads the managed-sandbox-only policy through the shared + * instance-settings query, so every render needs a query client. Renders here + * are synchronous and the path guard fails closed until the policy resolves, so + * the cache is primed by default. Pass `null` to leave the policy unresolved. + */ +function withQueryClient(node: ReactNode, experimentalSettings: Record | null = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (experimentalSettings) { + queryClient.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings); + } + return {node}; +} + function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { @@ -115,16 +139,20 @@ describe("ProjectWorkspaceSummaryCard", () => { configurable: true, value: true, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({}); }); afterEach(() => { document.body.innerHTML = ""; + vi.clearAllMocks(); }); - it("renders a stacked mobile-friendly summary with metadata labels and compact issue pills", () => { + it("keeps the path row hidden while the policy is still loading", () => { + // A cold cache resolves the policy to false on the first render. The guard + // fails closed so a managed instance never flashes the execution-host path. const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( { onRuntimeAction={() => {}} onCloseWorkspace={() => {}} />, + null, + )); + }); + + expect(container.textContent).not.toContain("Path"); + expect(container.textContent).toContain("Branch"); + + act(() => { + root.unmount(); + }); + }); + + it("keeps the path row hidden when the policy read fails", async () => { + // A failed settings read leaves the policy unknown, and an unknown policy + // must not be read as "not managed". React Query reports such a query as + // fetched with no data, so a guard keyed on "fetched" would show the + // execution-host path on exactly the managed instance whose settings + // endpoint is unreachable. + mockInstanceSettingsApi.getExperimental.mockRejectedValue(new Error("settings unavailable")); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRoot(container); + + await act(async () => { + root.render( + + {}} + onCloseWorkspace={() => {}} + /> + , ); }); + // Drive the rejected query all the way to a settled failure, so the + // assertion below covers the resolved-error case and not merely the + // in-flight one the loading test already covers. + for (let attempt = 0; attempt < 50; attempt += 1) { + if (queryClient.getQueryState(queryKeys.instance.experimentalSettings)?.status === "error") break; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + expect(queryClient.getQueryState(queryKeys.instance.experimentalSettings)?.status).toBe("error"); + + expect(container.textContent).not.toContain("Path"); + expect(container.textContent).toContain("Branch"); + + act(() => { + root.unmount(); + }); + }); + + it("drops the path row when the instance runs agents only in the platform-managed environment", () => { + const root = createRoot(container); + act(() => { + root.render(withQueryClient( + {}} + onCloseWorkspace={() => {}} + />, + { enableManagedSandboxOnly: true }, + )); + }); + + expect(container.textContent).not.toContain("Path"); + // Branch, service, and linked-task rows describe the workspace, not the host. + expect(container.textContent).toContain("Branch"); + expect(container.textContent).toContain("Service"); + expect(container.textContent).toContain("Linked tasks"); + + act(() => { + root.unmount(); + }); + }); + + it("renders a stacked mobile-friendly summary with metadata labels and compact issue pills", () => { + const root = createRoot(container); + act(() => { + root.render(withQueryClient( + {}} + onCloseWorkspace={() => {}} + />, + )); + }); + expect(container.textContent).toContain("Execution workspace"); expect(container.textContent).toContain("Branch"); expect(container.textContent).toContain("Path"); @@ -162,7 +285,7 @@ describe("ProjectWorkspaceSummaryCard", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( { onRuntimeAction={runtimeSpy} onCloseWorkspace={closeSpy} />, - ); + )); }); const titleLink = container.querySelector("a[href='/projects/paperclip-app/workspaces/workspace-1']"); @@ -195,7 +318,7 @@ describe("ProjectWorkspaceSummaryCard", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( { onRuntimeAction={() => {}} onCloseWorkspace={() => {}} />, - ); + )); }); expect(container.textContent).toContain("Retry close"); @@ -224,7 +347,7 @@ describe("ProjectWorkspaceSummaryCard", () => { }); await act(async () => { - root.render( + root.render(withQueryClient( { onRuntimeAction={() => {}} onCloseWorkspace={() => {}} />, - ); + )); }); const branchTextButton = Array.from(container.querySelectorAll("button")) @@ -277,7 +400,7 @@ describe("ProjectWorkspaceSummaryCard", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( { onRuntimeAction={() => {}} onCloseWorkspace={() => {}} />, - ); + )); }); const serviceLink = container.querySelector("a[href='http://127.0.0.1:62475']"); diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.tsx index 77fa319766..c126b390be 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { CopyText } from "./CopyText"; import { IssuesQuicklook } from "./IssuesQuicklook"; import type { ProjectWorkspaceLinkedIssue, ProjectWorkspaceSummary } from "../lib/project-workspaces-tab"; +import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly"; import { cn, projectWorkspaceUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Copy, ExternalLink, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react"; @@ -45,6 +46,7 @@ export function ProjectWorkspaceSummaryCard({ onRuntimeAction, onCloseWorkspace, }: ProjectWorkspaceSummaryCardProps) { + const { hideHostPaths } = useManagedSandboxOnly(); const visibleIssues = summary.issues.slice(0, 4); const hiddenIssueCount = Math.max(summary.linkedIssueCount - visibleIssues.length, 0); const workspaceHref = @@ -173,7 +175,12 @@ export function ProjectWorkspaceSummaryCard({
) : null} - {summary.cwd ? ( + {/* + The path is a host filesystem path, so it disappears under the + managed-sandbox-only policy, and stays hidden until that policy is + known. Branch, service, and issue rows stay. + */} + {summary.cwd && !hideHostPaths ? (
diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index b1044033df..407224eddf 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom +import type { ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkspaceRuntimeService } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -12,6 +14,15 @@ import { WorkspaceRuntimeQuickControls, WorkspaceRuntimeControls, } from "./WorkspaceRuntimeControls"; +import { queryKeys } from "@/lib/queryKeys"; + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -20,6 +31,20 @@ function act(callback: () => void) { flushSync(callback); } +/** + * The command rows read the managed-sandbox-only policy through the shared + * instance-settings query, so every render needs a query client. Renders here + * are synchronous and the guard fails closed until the policy resolves, so the + * cache is primed by default. Pass `null` to render with the policy unresolved. + */ +function withQueryClient(node: ReactNode, experimentalSettings: Record | null = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (experimentalSettings) { + queryClient.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings); + } + return {node}; +} + function createRuntimeService(overrides: Partial = {}): WorkspaceRuntimeService { return { id: overrides.id ?? "service-1", @@ -262,10 +287,99 @@ describe("WorkspaceRuntimeControls", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({}); }); afterEach(() => { document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows the service working directory when the managed-sandbox-only policy is off", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev", cwd: "." }], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running", cwd: "/srv/repo" }), + ], + canStartServices: true, + }); + + const root = createRoot(container); + act(() => { + root.render(withQueryClient( + , + {}, + )); + }); + + expect(container.textContent).toContain("/srv/repo"); + expect(container.textContent).toContain("pnpm dev"); + + act(() => root.unmount()); + }); + + it("keeps the service working directory hidden while the policy is still loading", () => { + // A cold cache resolves the policy to false on the first render. The guard + // fails closed so a managed instance never flashes the execution-host path. + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev", cwd: "." }], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running", cwd: "/srv/repo" }), + ], + canStartServices: true, + }); + + const root = createRoot(container); + act(() => { + root.render(withQueryClient( + , + null, + )); + }); + + expect(container.textContent).not.toContain("/srv/repo"); + expect(container.textContent).toContain("pnpm dev"); + + act(() => root.unmount()); + }); + + it("drops the service working directory when the managed-sandbox-only policy is on", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev", cwd: "." }], + }, + runtimeServices: [ + createRuntimeService({ + id: "service-web", + serviceName: "web", + status: "running", + cwd: "/srv/repo", + url: "http://127.0.0.1:5173", + port: 5173, + }), + ], + canStartServices: true, + }); + + const root = createRoot(container); + act(() => { + root.render(withQueryClient( + , + { enableManagedSandboxOnly: true }, + )); + }); + + expect(container.textContent).not.toContain("/srv/repo"); + // The URL, the port, and the command describe the service, not the host. + expect(container.textContent).toContain("http://127.0.0.1:5173"); + expect(container.textContent).toContain("Port 5173"); + expect(container.textContent).toContain("pnpm dev"); + + act(() => root.unmount()); }); it("renders service and job actions distinctly", () => { @@ -285,12 +399,12 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim()); @@ -316,12 +430,12 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); const buttons = Array.from(container.querySelectorAll("button")); @@ -351,13 +465,13 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); const buttons = Array.from(container.querySelectorAll("button")); @@ -382,13 +496,13 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); expect(container.textContent).not.toContain("Add runtime settings first."); @@ -411,12 +525,12 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); expect(container.textContent).not.toContain("unknown"); @@ -458,12 +572,12 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); const alert = container.querySelector('[role="alert"]'); @@ -492,13 +606,13 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); const summaryPanel = container.querySelector(".border.border-border\\/70"); @@ -528,14 +642,14 @@ describe("WorkspaceRuntimeControls", () => { const root = createRoot(container); act(() => { - root.render( + root.render(withQueryClient( , - ); + )); }); expect(container.textContent).toContain("Services"); diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index 9f50a6e28a..d50d89905a 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -10,6 +10,7 @@ import { } from "@paperclipai/shared"; import { Activity, ExternalLink, Loader2, Play, RotateCcw, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useManagedSandboxOnly } from "@/hooks/useManagedSandboxOnly"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { timeAgo } from "@/lib/timeAgo"; @@ -460,6 +461,11 @@ function CommandSection({ square?: boolean; iconOnly?: boolean; }) { + // Managed-sandbox-only policy: the working directory is a path on the + // execution host, so the command rows drop it, and keep dropping it until the + // policy is known. The URL, the port, and the command itself stay — they + // describe the service, not the host filesystem. + const { hideHostPaths } = useManagedSandboxOnly(); return (
@@ -502,7 +508,7 @@ function CommandSection({ ) : null} {item.port ?
Port {item.port}
: null} {item.command ?
{item.command}
: null} - {item.cwd ?
{item.cwd}
: null} + {item.cwd && !hideHostPaths ?
{item.cwd}
: null} {item.disabledReason ?
{item.disabledReason}
: null}
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 2aadb3994b..abb9d85d71 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -189,6 +189,13 @@ export function IssueProperties({ queryFn: () => instanceSettingsApi.getExperimental(), }); const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true; + // Managed-sandbox-only policy: the workspace folder is a host filesystem + // path, so the Folder row disappears. The Branch row above it stays. The gate + // fails closed whenever the policy is unknown — in flight and also on a failed + // read — because an unresolved policy reads as "not managed" and would show + // the folder the policy exists to hide. + const hideHostPaths = + experimentalSettings === undefined || experimentalSettings.enableManagedSandboxOnly === true; // Classic Task Interface: gate the Properties | Plans | Artifacts tab shell. // Flag ON renders the legacy stacked sections verbatim (no Tabs wrapper); // flag OFF — including while settings load — renders the chat-style tab @@ -2482,7 +2489,7 @@ export function IssueProperties({ /> )} - {issue.currentExecutionWorkspace?.cwd && ( + {issue.currentExecutionWorkspace?.cwd && !hideHostPaths && ( instanceSettingsApi.getExperimental(), + }); + + const enabled = query.data?.enableManagedSandboxOnly === true; + // Having settings data is what tells us the policy, not the query status. + // `isFetched` turns true once a request fails too, and a failed read leaves + // `enabled` false — gating on it would render host paths on exactly the + // managed-sandbox-only instance that cannot reach its settings endpoint. + // Reading the data instead also keeps a background refetch failure harmless: + // the last known policy is retained and stays in force. + const loaded = query.data !== undefined; + + return { + enabled, + loaded, + /** + * The gate for any surface that shows a host filesystem path or an + * execution-engine choice. It fails closed whenever the policy is unknown — + * while the first read is in flight and also when that read fails — so a + * path is never shown on the strength of a policy nobody has read. Once + * settings are in hand it is exactly `enabled`. + */ + hideHostPaths: !loaded || enabled, + }; +} diff --git a/ui/src/lib/reusable-execution-workspaces.ts b/ui/src/lib/reusable-execution-workspaces.ts index 6ec94d3d73..c05a5e97ab 100644 --- a/ui/src/lib/reusable-execution-workspaces.ts +++ b/ui/src/lib/reusable-execution-workspaces.ts @@ -50,8 +50,15 @@ function compareWorkspaceLastUsedDesc(a: ReusableExecutionWorkspaceLike, b: Reus return compareWorkspaceNames(a, b); } +/** + * The option subtitle. It used to fall back to the workspace working directory, + * a path on the execution host, which the reuse-existing picker then rendered + * next to a label that no longer shows one. The fallback is now the short id, + * so the picker never renders a host path. `workspaceSearchText` still indexes + * the working directory, so a user who already knows a path can search by it. + */ function workspaceDescription(workspace: ReusableExecutionWorkspaceLike) { - return workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8); + return workspace.branchName ?? workspace.id.slice(0, 8); } function workspaceSearchText(workspace: ReusableExecutionWorkspaceLike) { diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 8600b40b9e..5df5c92a88 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -2293,6 +2293,13 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))} /> + {/* + This path lives on the user's own remote SSH host, not on a + Paperclip execution host, so it stays visible under the + managed-sandbox-only policy. The policy hides host paths that + the platform-managed environment owns; an SSH environment the + user configured is outside that contract. + */} (null); const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -1281,72 +1283,94 @@ export function ExecutionWorkspaceDetail() { -
-
Paths
- - setForm((current) => current ? { ...current, cwd: event.target.value } : current)} - placeholder="/absolute/path/to/workspace" - /> - + {/* + Both fields name a path on the execution host. Under the + managed-sandbox-only policy every agent runs in the + platform-managed environment, which owns the paths, so the + whole group and its separator disappear, and stay hidden + until that policy is known. + */} + {!hideHostPaths && ( + <> +
+
Paths
+ + setForm((current) => current ? { ...current, cwd: event.target.value } : current)} + placeholder="/absolute/path/to/workspace" + /> + - - setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} - placeholder="/path/to/worktree or provider ref" - /> - -
+ + setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} + placeholder="/path/to/worktree or provider ref" + /> + +
- + + + )} -
-
Lifecycle commands
- -