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
- -