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:
parent
76f7019bdf
commit
7b91fe9ea7
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']");
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue