Hide host-path and execution-engine surfaces in managed-sandbox-only mode (#12293)

## 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
This commit is contained in:
Devin Foley 2026-08-27 11:59:14 -07:00 committed by GitHub
parent 76f7019bdf
commit 7b91fe9ea7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2014 additions and 345 deletions

View File

@ -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<typeof import("../routes/projects.js")>("../routes/projects.js"),
vi.importActual<typeof import("../middleware/index.js")>("../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<string, unknown> = {}) {
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<string, unknown> = {}) {
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();
});
});

View File

@ -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<string, unknown>;
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" });

View File

@ -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() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
<Route element={<IsolatedWorkspacesRouteGate />}>
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
</Route>
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="workspaces" element={<Workspaces />} />
<Route element={<IsolatedWorkspacesRouteGate />}>
<Route path="workspaces" element={<Workspaces />} />
</Route>
<Route path="issues" element={<Issues />} />
<Route path="search" element={<Search />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
@ -291,12 +296,14 @@ function boardRoutes() {
/>
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="routines/:routineId/:section" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
<Route element={<IsolatedWorkspacesRouteGate />}>
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
</Route>
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="artifacts" element={<Artifacts />} />

View File

@ -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 (
<>
<Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Claude CLI with diagnostics.">
{/*
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 && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Claude CLI with diagnostics.">
<select
className={inputClass}
value={engine}
@ -101,29 +108,31 @@ export function ClaudeLocalAdvancedFields({
<option value="cli">Claude CLI</option>
<option value="acp">ACP</option>
</select>
</Field>
</Field>}
{acpSelected && (
<>
<Field
label="ACP server command"
hint="Optional override for the Claude ACP server command. Defaults to the package-local claude-agent-acp binary."
>
<DraftInput
value={
isCreate
? values!.claudeAcpAgentCommand ?? ""
: eff("adapterConfig", "agentCommand", String(config.agentCommand ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ claudeAcpAgentCommand: v })
: mark("adapterConfig", "agentCommand", v || undefined)
}
immediate
className={inputClass}
placeholder="claude-agent-acp"
/>
</Field>
{!managedSandboxOnly && (
<Field
label="ACP server command"
hint="Optional override for the Claude ACP server command. Defaults to the package-local claude-agent-acp binary."
>
<DraftInput
value={
isCreate
? values!.claudeAcpAgentCommand ?? ""
: eff("adapterConfig", "agentCommand", String(config.agentCommand ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ claudeAcpAgentCommand: v })
: mark("adapterConfig", "agentCommand", v || undefined)
}
immediate
className={inputClass}
placeholder="claude-agent-acp"
/>
</Field>
)}
<Field label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<select
className={inputClass}
@ -165,29 +174,31 @@ export function ClaudeLocalAdvancedFields({
<option value="fail">Fail</option>
</select>
</Field>
<Field
label="ACP state directory"
hint="Optional ACP session state directory. Defaults to Paperclip-managed organization/agent scoped storage."
>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.claudeAcpStateDir ?? ""
: eff("adapterConfig", "stateDir", String(config.stateDir ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ claudeAcpStateDir: v })
: mark("adapterConfig", "stateDir", v || undefined)
}
immediate
className={inputClass}
placeholder="/path/to/acp-state"
/>
<ChoosePathButton />
</div>
</Field>
{!managedSandboxOnly && (
<Field
label="ACP state directory"
hint="Optional ACP session state directory. Defaults to Paperclip-managed organization/agent scoped storage."
>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.claudeAcpStateDir ?? ""
: eff("adapterConfig", "stateDir", String(config.stateDir ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ claudeAcpStateDir: v })
: mark("adapterConfig", "stateDir", v || undefined)
}
immediate
className={inputClass}
placeholder="/path/to/acp-state"
/>
<ChoosePathButton />
</div>
</Field>
)}
<Field
label="ACP warm process idle ms"
hint="Defaults to 0, which closes the ACP process after each run while retaining persistent session state."

View File

@ -30,8 +30,14 @@ export function CodexLocalConfigFields({
mark,
models,
hideInstructionsFile,
managedSandboxOnly,
}: AdapterConfigFieldsProps) {
const runnerManaged = adapterType === "paperclip_runner";
// 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` already does for the Paperclip Runner.
const hideEngineChoice = runnerManaged || managedSandboxOnly === true;
const rawEngine = runnerManaged ? "cli" : isCreate
? values!.codexEngine ?? "auto"
: eff("adapterConfig", "engine", String(config.engine ?? "auto"));
@ -56,7 +62,7 @@ export function CodexLocalConfigFields({
return (
<>
{!runnerManaged && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.">
{!hideEngineChoice && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.">
<select
className={inputClass}
value={engine}
@ -81,26 +87,28 @@ export function CodexLocalConfigFields({
)}
{acpSelected && (
<>
<Field
label="ACP server command"
hint="Optional override for the Codex ACP server command. Defaults to the package-local codex-acp binary."
>
<DraftInput
value={
isCreate
? values!.codexAcpAgentCommand ?? ""
: eff("adapterConfig", "agentCommand", String(config.agentCommand ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ codexAcpAgentCommand: v })
: mark("adapterConfig", "agentCommand", v || undefined)
}
immediate
className={inputClass}
placeholder="codex-acp"
/>
</Field>
{!managedSandboxOnly && (
<Field
label="ACP server command"
hint="Optional override for the Codex ACP server command. Defaults to the package-local codex-acp binary."
>
<DraftInput
value={
isCreate
? values!.codexAcpAgentCommand ?? ""
: eff("adapterConfig", "agentCommand", String(config.agentCommand ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ codexAcpAgentCommand: v })
: mark("adapterConfig", "agentCommand", v || undefined)
}
immediate
className={inputClass}
placeholder="codex-acp"
/>
</Field>
)}
<Field label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<select
className={inputClass}
@ -142,29 +150,31 @@ export function CodexLocalConfigFields({
<option value="fail">Fail</option>
</select>
</Field>
<Field
label="ACP state directory"
hint="Optional ACP session state directory. Defaults to Paperclip-managed organization/agent scoped storage."
>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.codexAcpStateDir ?? ""
: eff("adapterConfig", "stateDir", String(config.stateDir ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ codexAcpStateDir: v })
: mark("adapterConfig", "stateDir", v || undefined)
}
immediate
className={inputClass}
placeholder="/path/to/acp-state"
/>
<ChoosePathButton />
</div>
</Field>
{!managedSandboxOnly && (
<Field
label="ACP state directory"
hint="Optional ACP session state directory. Defaults to Paperclip-managed organization/agent scoped storage."
>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.codexAcpStateDir ?? ""
: eff("adapterConfig", "stateDir", String(config.stateDir ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ codexAcpStateDir: v })
: mark("adapterConfig", "stateDir", v || undefined)
}
immediate
className={inputClass}
placeholder="/path/to/acp-state"
/>
<ChoosePathButton />
</div>
</Field>
)}
<Field
label="ACP warm process idle ms"
hint="Defaults to 0, which closes the ACP process after each run while retaining persistent session state."

View File

@ -19,6 +19,7 @@ export function GeminiLocalConfigFields({
eff,
mark,
hideInstructionsFile,
managedSandboxOnly,
}: AdapterConfigFieldsProps) {
const rawEngine = isCreate
? values!.geminiEngine ?? "auto"
@ -28,7 +29,12 @@ export function GeminiLocalConfigFields({
return (
<>
<Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Gemini CLI with diagnostics.">
{/*
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 && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Gemini CLI with diagnostics.">
<select
className={inputClass}
value={engine}
@ -43,29 +49,31 @@ export function GeminiLocalConfigFields({
<option value="cli">Gemini CLI</option>
<option value="acp">ACP</option>
</select>
</Field>
</Field>}
{acpSelected && (
<>
<Field
label="ACP server command"
hint="Optional override for the Gemini ACP server command. Defaults to gemini --acp."
>
<DraftInput
value={
isCreate
? values!.geminiAcpAgentCommand ?? ""
: eff("adapterConfig", "agentCommand", String(config.agentCommand ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ geminiAcpAgentCommand: v })
: mark("adapterConfig", "agentCommand", v || undefined)
}
immediate
className={inputClass}
placeholder="gemini --acp"
/>
</Field>
{!managedSandboxOnly && (
<Field
label="ACP server command"
hint="Optional override for the Gemini ACP server command. Defaults to gemini --acp."
>
<DraftInput
value={
isCreate
? values!.geminiAcpAgentCommand ?? ""
: eff("adapterConfig", "agentCommand", String(config.agentCommand ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ geminiAcpAgentCommand: v })
: mark("adapterConfig", "agentCommand", v || undefined)
}
immediate
className={inputClass}
placeholder="gemini --acp"
/>
</Field>
)}
<Field label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<select
className={inputClass}
@ -107,29 +115,31 @@ export function GeminiLocalConfigFields({
<option value="fail">Fail</option>
</select>
</Field>
<Field
label="ACP state directory"
hint="Optional ACP session state directory. Defaults to Paperclip-managed organization/agent scoped storage."
>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.geminiAcpStateDir ?? ""
: eff("adapterConfig", "stateDir", String(config.stateDir ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ geminiAcpStateDir: v })
: mark("adapterConfig", "stateDir", v || undefined)
}
immediate
className={inputClass}
placeholder="/path/to/acp-state"
/>
<ChoosePathButton />
</div>
</Field>
{!managedSandboxOnly && (
<Field
label="ACP state directory"
hint="Optional ACP session state directory. Defaults to Paperclip-managed organization/agent scoped storage."
>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.geminiAcpStateDir ?? ""
: eff("adapterConfig", "stateDir", String(config.stateDir ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ geminiAcpStateDir: v })
: mark("adapterConfig", "stateDir", v || undefined)
}
immediate
className={inputClass}
placeholder="/path/to/acp-state"
/>
<ChoosePathButton />
</div>
</Field>
)}
<Field
label="ACP warm process idle ms"
hint="Defaults to 0, which closes the ACP process after each run while retaining persistent session state."

View File

@ -0,0 +1,182 @@
// @vitest-environment jsdom
import { act, type ComponentType } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ClaudeLocalConfigFields, ClaudeLocalAdvancedFields } from "./claude-local/config-fields";
import { CodexLocalConfigFields } from "./codex-local/config-fields";
import { GeminiLocalConfigFields } from "./gemini-local/config-fields";
import type { AdapterConfigFieldsProps } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
/**
* Under `enableManagedSandboxOnly` every agent runs in the platform-managed
* environment. The form resolves that policy once and passes it to every
* adapter, which must then drop each host filesystem path field and each
* execution-engine choice while keeping its behavior toggles.
*/
const ACP_CONFIG = {
engine: "acp",
agentCommand: "vendor-acp",
stateDir: "/srv/agents/cody/acp-state",
instructionsFilePath: "/srv/agents/cody/AGENTS.md",
};
function renderFields(
Component: ComponentType<AdapterConfigFieldsProps>,
overrides: Partial<AdapterConfigFieldsProps> = {},
) {
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(
<TooltipProvider>
<Component {...props} />
</TooltipProvider>,
);
});
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");
});
});

View File

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

View File

@ -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"
? <div data-testid="hermes-gateway-config-fields">Hermes Gateway fields</div>
: null,
: (
<div
data-testid="adapter-config-fields"
data-hide-instructions-file={String(hideInstructionsFile === true)}
data-managed-sandbox-only={String(managedSandboxOnly === true)}
/>
),
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 `<label>`, so this reads the form. */
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",
);
}
beforeEach(() => {
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
setManagedSandboxOnly(false);
});
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount();
});
}
roots = [];
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows the host path and execution-engine fields when the policy is off", async () => {
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
const labels = fieldLabels(result.container);
expect(labels).toContain("Working directory (deprecated)");
expect(labels).toContain("Command");
expect(labels).toContain("Execution engine");
expect(labels).toContain("ACP server command");
expect(labels).toContain("ACP state directory");
expect(choosePathButtons(result.container).length).toBeGreaterThan(0);
const adapterFields = result.container.querySelector('[data-testid="adapter-config-fields"]');
expect(adapterFields?.getAttribute("data-managed-sandbox-only")).toBe("false");
expect(adapterFields?.getAttribute("data-hide-instructions-file")).toBe("false");
});
it("hides the host path and execution-engine fields for claude_local when the policy is on", async () => {
setManagedSandboxOnly(true);
const result = await renderForm(
[makeEnvironment({ id: "managed-1", name: "Managed", driver: "sandbox", config: { provider: "daytona" } })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
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);
// The stored values stay untouched: hiding is presentation, and an import
// that carries adapter configuration from another instance must still save.
expect(result.container.textContent).not.toContain("/srv/agents/cody");
});
it("keeps the non-path ACP controls visible when the policy hides the engine choice", async () => {
setManagedSandboxOnly(true);
const result = await renderForm(
[makeEnvironment({ id: "managed-1", name: "Managed", driver: "sandbox", config: { provider: "daytona" } })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
const labels = fieldLabels(result.container);
expect(labels).toContain("ACP session mode");
expect(labels).toContain("ACP non-interactive permissions");
});
it("keeps the host-path fields hidden while the policy is still loading", async () => {
// A cold cache resolves the policy to false on the first render. The gate
// fails closed so a managed instance never flashes a stored host path.
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
expect(labels).not.toContain("Execution engine");
expect(choosePathButtons(result.container)).toHaveLength(0);
expect(result.container.textContent).not.toContain("/srv/agents/cody");
});
it("hides the command field and forces the instructions-file gate for codex_local when the policy is on", async () => {
setManagedSandboxOnly(true);
const result = await renderForm(
[makeEnvironment({ id: "managed-1", name: "Managed", driver: "sandbox", config: { provider: "daytona" } })],
{ adapterType: "codex_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
expect(choosePathButtons(result.container)).toHaveLength(0);
const adapterFields = result.container.querySelector('[data-testid="adapter-config-fields"]');
expect(adapterFields?.getAttribute("data-managed-sandbox-only")).toBe("true");
expect(adapterFields?.getAttribute("data-hide-instructions-file")).toBe("true");
});
});

View File

@ -288,6 +288,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
// Managed-sandbox-only policy: every agent runs in the platform-managed
// environment, so the form hides each host filesystem path and each
// execution-engine choice. Declared here because the field gates below and
// the adapter field props both read it.
const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
// The gate the host-path fields use. 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 a stored working directory or
// instructions-file path.
const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly;
// Instance execution policy (general settings). When `executionMode` is
// "kubernetes" the instance FORCES all execution onto the managed Kubernetes
@ -449,8 +459,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterCaps = getCapabilities(adapterType);
const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt;
// The legacy working directory is an absolute path on the host, so the
// managed-sandbox-only policy hides it. A stored value stays untouched; it is
// inert while every run happens in the platform-managed environment.
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
isLocal
&& !hideHostPaths
&& shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
const supportedEnvironmentDrivers = useMemo(
() => new Set(supportedEnvironmentDriversForAdapter(adapterType)),
@ -677,7 +692,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
currentDefaultEnvironmentId.length > 0 ||
runnableEnvironments.length >= 1
);
const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
const inheritedEnvironmentLabel = instanceDefaultEnvironment
? environmentDisplayLabel(instanceDefaultEnvironment)
: managedSandboxOnly
@ -737,7 +751,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
models,
hideInstructionsFile,
// Resolve the effective instructions-file gate once. The instructions file
// is an absolute host path, so the managed-sandbox-only policy hides it for
// every adapter without a per-adapter edit.
hideInstructionsFile: hideInstructionsFile || hideHostPaths,
managedSandboxOnly: hideHostPaths,
};
// Section toggle state — advanced always starts collapsed
@ -1641,39 +1659,52 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions &amp; Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff(
"adapterConfig",
adapterCommandField,
String(
config.command ?? "",
),
)
}
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", adapterCommandField, v || null)
}
immediate
className={inputClass}
placeholder={
({
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
kimi_local: "kimi",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
{/*
The command names a binary on the execution host, so the
managed-sandbox-only policy hides it: the platform-managed image
owns the binary. Hiding is presentation only. A stored
`adapterConfig.command` stays as it is and the server does not
reject one, because an import carries adapter configuration
written on another instance; rejecting it would break that flow.
The value is inert while the policy is on. The field also stays
hidden until the policy is known, so a stored command never
flashes on a managed instance.
*/}
{!hideHostPaths && (
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff(
"adapterConfig",
adapterCommandField,
String(
config.command ?? "",
),
)
}
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", adapterCommandField, v || null)
}
immediate
className={inputClass}
placeholder={
({
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
kimi_local: "kimi",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
)}
{supportsModelProfiles && (
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Primary model</div>

View File

@ -236,8 +236,14 @@ export function ExecutionWorkspaceCloseDialog({
<span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
</div>
{/*
The last fallback used to print the service working
directory, a path on the execution host. The dialog has
no instance-policy context of its own, so it drops the
path outright and falls back to a neutral line.
*/}
<div className="mt-1 break-words text-xs text-muted-foreground">
{service.url ?? service.command ?? service.cwd ?? "No additional details"}
{service.url ?? service.command ?? "No additional details"}
</div>
</div>
))}

View File

@ -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 }) => (
<div data-testid="navigate" data-to={to} data-replace={String(replace ?? false)} />
),
Outlet: () => <div data-testid="workspace-route" />,
}));
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(
<QueryClientProvider client={queryClient}>
<IsolatedWorkspacesRouteGate />
</QueryClientProvider>,
);
});
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();
});
});

View File

@ -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 <Navigate to="/dashboard" replace />;
}
return <Outlet />;
}

View File

@ -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({
<CopyableInline value={workspace.branchName} mono />
</div>
)}
{workspace?.cwd && (
{workspace?.cwd && !hideHostPaths && (
<div className="flex items-center gap-1.5">
<FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.cwd} mono />

View File

@ -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 && (
<div className="text-(length:--text-micro) text-muted-foreground">
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? "existing execution workspace"}.
</div>
)}
{showParentWorkspaceWarning ? (

View File

@ -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: () => <div data-testid="markdown-editor" />,
}));
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<string, unknown> | null) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (experimentalSettings) {
client.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
}
act(() => {
root.render(
<QueryClientProvider client={client}>
<TooltipProvider>
<NewProjectDialog />
</TooltipProvider>
</QueryClientProvider>,
);
});
}
/** 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");
});
});

View File

@ -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() {
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
</TooltipContent>
</Tooltip>
{/*
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 && (
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
</div>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>

View File

@ -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 }) => <div>{value}</div> }));
const LOCAL_FOLDER = "/Users/paperclip/projects/test-project";
const MANAGED_FOLDER = "/var/paperclip/checkouts/test-project";
function makeCodebase(overrides: Partial<ProjectCodebase> = {}): 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<string, unknown> | null) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (experimentalSettings) {
client.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
}
act(() => {
root.render(
<QueryClientProvider client={client}>
<TooltipProvider>
<ProjectProperties project={project} onFieldUpdate={vi.fn()} getFieldSaveState={() => "idle"} onArchive={noop} />
</TooltipProvider>
</QueryClientProvider>,
);
});
}
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();
});
});

View File

@ -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
</button>
</TooltipTrigger>
<TooltipContent side="top">
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."}
</TooltipContent>
</Tooltip>
</div>
@ -780,43 +787,57 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)}
</div>
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Local folder</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{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" ? (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
) : null
) : (
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Local folder</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{codebase.effectiveLocalFolder}
</div>
{codebase.origin === "managed_checkout" && (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
)}
</div>
{codebase.origin === "managed_checkout" && (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("local");
setWorkspaceCwd(codebase.localFolder ?? "");
setWorkspaceError(null);
}}
>
{codebase.localFolder ? "Change local folder" : "Set local folder"}
</Button>
{codebase.localFolder ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
onClick={clearLocalWorkspace}
aria-label="Clear local folder"
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("local");
setWorkspaceCwd(codebase.localFolder ?? "");
setWorkspaceError(null);
}}
>
<Trash2 className="h-3 w-3" />
{codebase.localFolder ? "Change local folder" : "Set local folder"}
</Button>
) : null}
{codebase.localFolder ? (
<Button
variant="ghost"
size="icon-xs"
onClick={clearLocalWorkspace}
aria-label="Clear local folder"
>
<Trash2 className="h-3 w-3" />
</Button>
) : null}
</div>
</div>
</div>
</div>
)}
{hasAdditionalLegacyWorkspaces && (
<div className="text-(length:--text-micro) text-muted-foreground">
@ -882,7 +903,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
) : null}
</div>
{workspaceMode === "local" && (
{!hideHostPaths && workspaceMode === "local" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<div className="flex items-center gap-2">
<input

View File

@ -3,11 +3,21 @@
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
import { queryKeys } from "../lib/queryKeys";
import { ProjectWorkspaceSummaryCard } from "./ProjectWorkspaceSummaryCard";
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
}));
@ -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<string, unknown> | null = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (experimentalSettings) {
queryClient.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
}
return <QueryClientProvider client={queryClient}>{node}</QueryClientProvider>;
}
function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary()}
@ -133,9 +161,104 @@ describe("ProjectWorkspaceSummaryCard", () => {
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(
<QueryClientProvider client={queryClient}>
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary()}
runtimeActionKey={null}
runtimeActionPending={false}
onRuntimeAction={() => {}}
onCloseWorkspace={() => {}}
/>
</QueryClientProvider>,
);
});
// 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(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary()}
runtimeActionKey={null}
runtimeActionPending={false}
onRuntimeAction={() => {}}
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(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary()}
runtimeActionKey={null}
runtimeActionPending={false}
onRuntimeAction={() => {}}
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(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary({
@ -178,7 +301,7 @@ describe("ProjectWorkspaceSummaryCard", () => {
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(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary({
@ -206,7 +329,7 @@ describe("ProjectWorkspaceSummaryCard", () => {
onRuntimeAction={() => {}}
onCloseWorkspace={() => {}}
/>,
);
));
});
expect(container.textContent).toContain("Retry close");
@ -224,7 +347,7 @@ describe("ProjectWorkspaceSummaryCard", () => {
});
await act(async () => {
root.render(
root.render(withQueryClient(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={summary}
@ -233,7 +356,7 @@ describe("ProjectWorkspaceSummaryCard", () => {
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(
<ProjectWorkspaceSummaryCard
projectRef="paperclip-app"
summary={createSummary({
@ -290,7 +413,7 @@ describe("ProjectWorkspaceSummaryCard", () => {
onRuntimeAction={() => {}}
onCloseWorkspace={() => {}}
/>,
);
));
});
const serviceLink = container.querySelector("a[href='http://127.0.0.1:62475']");

View File

@ -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({
</div>
) : 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 ? (
<div className="flex items-start gap-2">
<FolderOpen className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">

View File

@ -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<string, unknown> | null = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (experimentalSettings) {
queryClient.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
}
return <QueryClientProvider client={queryClient}>{node}</QueryClientProvider>;
}
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): 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(
<WorkspaceRuntimeControls sections={sections} onAction={vi.fn()} />,
{},
));
});
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(
<WorkspaceRuntimeControls sections={sections} onAction={vi.fn()} />,
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(
<WorkspaceRuntimeControls sections={sections} onAction={vi.fn()} />,
{ 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(
<WorkspaceRuntimeControls
sections={sections}
onAction={vi.fn()}
/>,
);
));
});
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(
<WorkspaceRuntimeQuickControls
sections={sections}
onAction={vi.fn()}
/>,
);
));
});
const buttons = Array.from(container.querySelectorAll("button"));
@ -351,13 +465,13 @@ describe("WorkspaceRuntimeControls", () => {
const root = createRoot(container);
act(() => {
root.render(
root.render(withQueryClient(
<WorkspaceRuntimeControls
sections={sections}
disabledHint="Add a workspace path first."
onAction={vi.fn()}
/>,
);
));
});
const buttons = Array.from(container.querySelectorAll("button"));
@ -382,13 +496,13 @@ describe("WorkspaceRuntimeControls", () => {
const root = createRoot(container);
act(() => {
root.render(
root.render(withQueryClient(
<WorkspaceRuntimeControls
sections={sections}
disabledHint="Add runtime settings first."
onAction={vi.fn()}
/>,
);
));
});
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(
<WorkspaceRuntimeControls
sections={sections}
onAction={vi.fn()}
/>,
);
));
});
expect(container.textContent).not.toContain("unknown");
@ -458,12 +572,12 @@ describe("WorkspaceRuntimeControls", () => {
const root = createRoot(container);
act(() => {
root.render(
root.render(withQueryClient(
<WorkspaceRuntimeControls
sections={sections}
onAction={vi.fn()}
/>,
);
));
});
const alert = container.querySelector('[role="alert"]');
@ -492,13 +606,13 @@ describe("WorkspaceRuntimeControls", () => {
const root = createRoot(container);
act(() => {
root.render(
root.render(withQueryClient(
<WorkspaceRuntimeControls
sections={sections}
square
onAction={vi.fn()}
/>,
);
));
});
const summaryPanel = container.querySelector(".border.border-border\\/70");
@ -528,14 +642,14 @@ describe("WorkspaceRuntimeControls", () => {
const root = createRoot(container);
act(() => {
root.render(
root.render(withQueryClient(
<WorkspaceRuntimeControls
items={items}
emptyMessage="No runtime services have been started yet."
disabledHint="Add runtime settings first."
onAction={vi.fn()}
/>,
);
));
});
expect(container.textContent).toContain("Services");

View File

@ -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 (
<div className="space-y-3">
<div className="space-y-1">
@ -502,7 +508,7 @@ function CommandSection({
) : null}
{item.port ? <div>Port {item.port}</div> : null}
{item.command ? <div className="break-all font-mono">{item.command}</div> : null}
{item.cwd ? <div className="break-all font-mono">{item.cwd}</div> : null}
{item.cwd && !hideHostPaths ? <div className="break-all font-mono">{item.cwd}</div> : null}
{item.disabledReason ? <div>{item.disabledReason}</div> : null}
</div>
<ExposureFailureDetail exposure={item.exposure} />

View File

@ -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({
/>
</PropertyRow>
)}
{issue.currentExecutionWorkspace?.cwd && (
{issue.currentExecutionWorkspace?.cwd && !hideHostPaths && (
<PropertyRow label="Folder">
<TruncatedCopyable
value={issue.currentExecutionWorkspace.cwd}

View File

@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
/**
* Reads the instance managed-sandbox-only policy (`enableManagedSandboxOnly`).
*
* When the policy is on, every agent runs in the platform-managed environment
* and the local environment is hidden. A host filesystem path, a folder picker,
* or an execution-engine choice has no meaning on such an instance, so the UI
* must not render one. Callers that already read the experimental settings keep
* their own read; this hook exists for the components that do not.
*
* `enabled` is the policy itself and `loaded` reports whether the policy is
* actually known. Gate a host-path surface on `hideHostPaths`, never on
* `enabled`: a cold cache resolves `enabled` to false for the first render,
* which would flash the path the policy exists to hide.
*/
export function useManagedSandboxOnly() {
const query = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => 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,
};
}

View File

@ -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) {

View File

@ -2293,6 +2293,13 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
/>
</Field>
{/*
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.
*/}
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"

View File

@ -39,6 +39,7 @@ import { WorkspaceServiceControlBar } from "../components/WorkspaceServiceContro
import { WorkspaceAccessCard } from "../components/WorkspaceAccessCard";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly";
import { useToastActions } from "../context/ToastContext";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { queryKeys } from "../lib/queryKeys";
@ -805,6 +806,7 @@ export function ExecutionWorkspaceDetail() {
const queryClient = useQueryClient();
const { setBreadcrumbs } = useBreadcrumbs();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { hideHostPaths } = useManagedSandboxOnly();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@ -1281,72 +1283,94 @@ export function ExecutionWorkspaceDetail() {
<Separator />
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Paths</div>
<Field label="Working directory">
<Input
className="font-mono"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
{/*
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 && (
<>
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Paths</div>
<Field label="Working directory">
<Input
className="font-mono"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
<Field label="Provider path / ref">
<Input
className="font-mono"
value={form.providerRef}
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
placeholder="/path/to/worktree or provider ref"
/>
</Field>
</div>
<Field label="Provider path / ref">
<Input
className="font-mono"
value={form.providerRef}
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
placeholder="/path/to/worktree or provider ref"
/>
</Field>
</div>
<Separator />
<Separator />
</>
)}
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Lifecycle commands</div>
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
<Textarea
className="min-h-20 font-mono"
value={form.provisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree.sh"
/>
</Field>
{/*
Every lifecycle command runs a shell on the execution host and
its placeholder names a host script path. The platform-managed
environment owns that lifecycle, so the managed-sandbox-only
policy hides the group and its separator, and keeps them
hidden until that policy is known.
*/}
{!hideHostPaths && (
<>
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Lifecycle commands</div>
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
<Textarea
className="min-h-20 font-mono"
value={form.provisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree.sh"
/>
</Field>
<Field
label="Runtime provision command"
hint="Runs once before the first runtime-service start. Leave empty to keep eager provisioning."
>
<Textarea
className="min-h-20 font-mono"
value={form.runtimeProvisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, runtimeProvisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree-runtime.sh"
/>
</Field>
<Field
label="Runtime provision command"
hint="Runs once before the first runtime-service start. Leave empty to keep eager provisioning."
>
<Textarea
className="min-h-20 font-mono"
value={form.runtimeProvisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, runtimeProvisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree-runtime.sh"
/>
</Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<Textarea
className="min-h-20 font-mono"
value={form.teardownCommand}
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<Textarea
className="min-h-20 font-mono"
value={form.teardownCommand}
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</Field>
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
<Textarea
className="min-h-16 font-mono"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
/>
</Field>
</div>
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
<Textarea
className="min-h-16 font-mono"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
/>
</Field>
</div>
<Separator />
<Separator />
</>
)}
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div>

View File

@ -4,6 +4,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { PluginSettings } from "./PluginSettings";
const mockPluginsApi = vi.hoisted(() => ({
@ -118,11 +119,18 @@ function folderStatus(overrides: Record<string, unknown> = {}) {
};
}
async function renderSettings(container: HTMLDivElement) {
async function renderSettings(
container: HTMLDivElement,
experimentalSettings: Record<string, unknown> = {},
) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// The local-folders section is a host-path surface, so it stays hidden until
// the managed-sandbox-only policy is known. Seed the policy as off; the tests
// below are about folder rendering, not about the gate.
queryClient.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
await act(async () => {
root.render(
@ -210,6 +218,39 @@ describe("PluginSettings", () => {
});
});
it("hides local folders when the instance runs agents only in the platform-managed environment", async () => {
const declaration = wikiFolderDeclaration();
mockPluginsApi.get.mockResolvedValue(basePlugin({
pluginKey: "paperclipai.plugin-llm-wiki",
packageName: "@paperclipai/plugin-llm-wiki",
status: "ready",
manifestJson: {
displayName: "LLM Wiki",
version: "0.1.0",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
capabilities: ["local.folders"],
localFolders: [declaration],
},
}));
mockPluginsApi.listLocalFolders.mockResolvedValue({
pluginId: "plugin-1",
companyId: "company-1",
declarations: [declaration],
folders: [folderStatus()],
});
const root = await renderSettings(container, { enableManagedSandboxOnly: true });
// The platform-managed environment owns the filesystem, so the whole
// section disappears rather than showing paths nobody can act on.
expect(container.textContent).not.toContain("Local folders");
await act(async () => {
root.unmount();
});
});
it("renders invalid configured folders with validation problems", async () => {
const declaration = wikiFolderDeclaration();
mockPluginsApi.get.mockResolvedValue(basePlugin({

View File

@ -4,6 +4,7 @@ import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, L
import type { PluginLocalFolderDeclaration } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useManagedSandboxOnly } from "@/hooks/useManagedSandboxOnly";
import { Link, Navigate, useParams } from "@/lib/router";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { pluginsApi, type PluginLocalFolderStatus } from "@/api/plugins";
@ -63,6 +64,7 @@ export function PluginSettings() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
const { hideHostPaths } = useManagedSandboxOnly();
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
const { data: plugin, isLoading: pluginLoading } = useQuery({
@ -150,7 +152,12 @@ export function PluginSettings() {
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
const localFolderDeclarations = plugin.manifestJson.localFolders ?? [];
const hasLocalFolders = localFolderDeclarations.length > 0;
// A plugin local folder is an absolute path on the execution host. Under the
// managed-sandbox-only policy the platform-managed environment owns the
// filesystem, so the whole section disappears and the Settings tab falls back
// to whatever else the plugin declares. The section also stays hidden until
// the policy is known.
const hasLocalFolders = localFolderDeclarations.length > 0 && !hideHostPaths;
const environmentDriverNames = environmentDrivers
.map((driver) => driver.displayName?.trim() || driver.driverKey)
.filter((name, index, values) => values.indexOf(name) === index);

View File

@ -6,6 +6,7 @@ import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectWorkspaceDetail } from "./ProjectWorkspaceDetail";
import { queryKeys } from "../lib/queryKeys";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@ -340,3 +341,59 @@ describe("ProjectWorkspaceDetail plugin tabs", () => {
expect(container.textContent).toContain("Plugin manifest failed");
});
});
describe("ProjectWorkspaceDetail local path under the managed-sandbox-only policy", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockProjectsApi.get.mockResolvedValue(project());
mockPluginSlotState.slots = [];
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = null;
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
mockRouteSearch.value = "";
});
async function render(experimentalSettings: Record<string, unknown>) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ProjectWorkspaceDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
}
it("shows the local path field and fact row when the policy is off", async () => {
await render({});
expect(container.textContent).toContain("Local path");
expect(container.querySelector('input[placeholder="/absolute/path/to/workspace"]')).not.toBeNull();
expect(container.textContent).toContain("/tmp/paperclip");
});
it("hides the local path field and fact row when the policy is on", async () => {
await render({ enableManagedSandboxOnly: true });
expect(container.textContent).not.toContain("Local path");
expect(container.querySelector('input[placeholder="/absolute/path/to/workspace"]')).toBeNull();
expect(container.textContent).not.toContain("/tmp/paperclip");
// The repo fact row does not name the host filesystem, so it stays.
expect(container.textContent).toContain("Repo URL");
});
});

View File

@ -19,6 +19,7 @@ import {
} from "../components/WorkspaceRuntimeControls";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly";
import { queryKeys } from "../lib/queryKeys";
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
@ -255,6 +256,7 @@ export function ProjectWorkspaceDetail() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hideHostPaths } = useManagedSandboxOnly();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
@ -532,19 +534,27 @@ export function ProjectWorkspaceDetail() {
</select>
</Field>
<div className="grid gap-4 md:grid-cols-(--gtc-13)">
<Field label="Local path">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
<div className="flex items-end">
<ChoosePathButton />
{/*
The local path is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
platform-managed environment, so neither the field nor the
folder picker renders; the server refuses a cwd write anyway.
*/}
{!hideHostPaths && (
<div className="grid gap-4 md:grid-cols-(--gtc-13)">
<Field label="Local path">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
<div className="flex items-end">
<ChoosePathButton />
</div>
</div>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Field label="Repo URL">
@ -676,9 +686,11 @@ export function ProjectWorkspaceDetail() {
<DetailRow label="Workspace ID">
<span className="break-all font-mono text-xs">{workspace.id}</span>
</DetailRow>
<DetailRow label="Local path">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
{hideHostPaths ? null : (
<DetailRow label="Local path">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
)}
<DetailRow label="Repo">
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">