feat(workspaces): define project runtime source authority

Route probe and user materialization through one projectRuntimeSource seam, where persisted Git source overrides the host-only convenience root and must belong to Git project data.

Lock default revision, explicit revision, and subdirectory semantics with real Docker acceptance. Enforce provider-probe internals structurally and document that the current Docker POC needs a locally built image because its default reference is unpublished.
This commit is contained in:
Mohamed Boudra
2026-08-12 20:53:05 +02:00
parent 527b252497
commit 33afa7283b
24 changed files with 750 additions and 74 deletions
+2
View File
@@ -74,6 +74,8 @@ not retain non-Git directories.
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/workspace-runtime/` | Runtime-neutral workspace lifecycle, execution, files, Git observation |
| `server/provider-probe/` | Invisible runtime-bound workspaces for pre-creation provider discovery |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
+23
View File
@@ -22,6 +22,29 @@ the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
## Docker workspace runtime
The Desktop New Workspace screen includes a built-in **Docker** runtime. It is separate from running the Paseo daemon image described on this page: the daemon stays on the host and materializes each selected project's committed Git content into a runtime-owned Docker volume. Files, watching, terminals, Git, scripts, provider discovery, and agents then use the same workspace-runtime boundary.
The current POC requires a locally built workspace-runtime image. The configured default, `ghcr.io/getpaseo/workspace-runtime:latest`, is not published, so it does not currently work. Build the image from the repository root and override `workspaceRuntimes.docker.image` in daemon configuration:
```bash
docker build -f packages/docker-workspace-runtime/Dockerfile -t paseo-workspace-runtime:local .
```
```json
{
"workspaceRuntimes": {
"docker": {
"type": "docker",
"image": "paseo-workspace-runtime:local"
}
}
}
```
Optional bind mounts require absolute host and container paths, must target outside `/workspace`, and declare `readOnly` explicitly. They are available during Git materialization and in the running workspace. They cannot replace the runtime-owned project volume. Set `enabled: false` to remove Docker from the runtime catalog.
## Quick Start
```bash
+4 -2
View File
@@ -6,7 +6,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Runtime**, which chooses where commands and files live.
- **Runtime** — The environment where a workspace's files, terminals, Git operations, scripts, and agents run. UI: "Runtime" on New Workspace. **Local** adopts the existing host checkout. **Worktree** creates a Paseo-managed host worktree. **Docker** materializes committed Git content in a container. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`.
- **Runtime** — The selected environment where a workspace's files, terminals, Git operations, scripts, provider discovery, and agents run. UI: "Runtime" on New Workspace. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`.
- **Local** — Built-in runtime that adopts the selected host directory. UI: "Local". It does not mean “the current client device”; it means the daemon host.
- **Docker** — Built-in runtime that materializes committed Git content into a runtime-owned Docker volume. UI: "Docker". Host bind mounts may provide configuration outside `/workspace`, but never replace project materialization.
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
@@ -17,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value and built-in runtime label. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Worktree** — Built-in runtime that creates a Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+5 -3
View File
@@ -101,17 +101,19 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
The daemon keeps provider snapshots per bound workspace, with separate legacy cwd and semantic global scopes. New Workspace first ensures one invisible provider-probe workspace for the selected `(projectId, runtimeId)`, then reads the snapshot through that workspace binding. The probe is created through `WorkspaceRuntimeService`, so availability, models, and modes come from the selected runtime. Probe creation never runs repository setup. Old clients and bare-cwd callers keep the cwd snapshot path; do not replace or remove it.
Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` after the workspace runtime has bound its private placement. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and workspace identity, or per cwd for the legacy path, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
Boundary tests should assert observable behavior: New Workspace never falls back to a host cwd after selecting a runtime; cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one workspace; settings refresh wipes all scopes but immediately refreshes only global.
---
+2
View File
@@ -21,6 +21,8 @@ Evidence is something someone else can look at:
- A video of the interaction
- Logs, requests, responses
Workspace-runtime changes include the selected Local/Worktree parity cases, the shared common-Git watcher assertion, the fixture Desktop journey, and the blocking Docker matrix when Docker behavior changes. Provider-probe changes also include lifecycle/restart evidence and proof that probe records, processes, watchers, containers, and volumes do not leak.
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
@@ -173,6 +173,7 @@ async function create(
"run",
"--rm",
...mounts,
...dockerBindMountArguments(bindMounts),
image,
"/bin/sh",
"-c",
@@ -1,4 +1,5 @@
import type pino from "pino";
import type { PersistedProjectRecord } from "../workspace-registry.js";
import type { ProviderWorkspace } from "../agent/providers/workspace/index.js";
import type {
@@ -33,12 +34,12 @@ export interface ProviderProbeServiceOptions {
filePath: string;
logger: pino.Logger;
projects: {
get(projectId: string): Promise<{
projectId: string;
rootPath: string;
updatedAt: string;
archivedAt?: string | null;
} | null>;
get(
projectId: string,
): Promise<Pick<
PersistedProjectRecord,
"projectId" | "rootPath" | "source" | "updatedAt" | "archivedAt"
> | null>;
};
runtime: {
create(input: CreateWorkspaceInput): Promise<WorkspaceRuntimePlacement>;
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { runGitCommand } from "../../../utils/run-git-command.js";
import { projectRuntimeSource } from "../../workspace-registry.js";
import type {
ProviderProbeClock,
ProviderProbeService,
@@ -122,7 +123,7 @@ export function createService(options: ServiceOptions): ProviderProbeService {
const timestamp = clock.now().toISOString();
const fingerprint = await probeFingerprint({
projectId: project.projectId,
rootPath: project.rootPath,
source: projectRuntimeSource(project),
runtimeId: input.runtimeId,
runtimeConfiguration: options.runtimeConfiguration?.[input.runtimeId],
});
@@ -166,7 +167,7 @@ export function createService(options: ServiceOptions): ProviderProbeService {
runtimeId: input.runtimeId,
project: {
id: project.projectId,
source: { kind: "host-directory", path: project.rootPath },
source: projectRuntimeSource(project),
},
placement: { kind: "existing" },
purpose: "provider-probe",
@@ -210,7 +211,7 @@ export function createService(options: ServiceOptions): ProviderProbeService {
}
const fingerprint = await probeFingerprint({
projectId: project.projectId,
rootPath: project.rootPath,
source: projectRuntimeSource(project),
runtimeId: record.runtimeId,
runtimeConfiguration: options.runtimeConfiguration?.[record.runtimeId],
});
@@ -327,11 +328,15 @@ function probeWorkspaceId(projectId: string, runtimeId: string): string {
async function probeFingerprint(input: {
projectId: string;
rootPath: string;
source: ReturnType<typeof projectRuntimeSource>;
runtimeId: string;
runtimeConfiguration: unknown;
}): Promise<string> {
return hash({ ...input, sourceRevision: await resolveSourceRevision(input.rootPath) });
const sourceRevision =
input.source.kind === "host-directory"
? await resolveSourceRevision(input.source.path)
: input.source.revision || null;
return hash({ ...input, sourceRevision });
}
async function resolveSourceRevision(rootPath: string): Promise<string | null> {
@@ -62,6 +62,27 @@ posixDescribe("provider probe service", () => {
);
});
test("materializes a persisted git source without granting authority to rootPath", async () => {
const fixture = await createFixture({
projectSource: {
kind: "git",
url: "https://example.test/acme/project.git",
revision: "release",
subdirectory: "packages/app",
},
});
await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" });
expect(fixture.creates[0]?.project.source).toEqual({
kind: "git",
url: "https://example.test/acme/project.git",
revision: "release",
subdirectory: "packages/app",
});
expect(JSON.stringify(fixture.creates[0]?.project.source)).not.toContain(fixture.projectRoot);
});
test("reuses persisted ready identity and resolves only probe provider workspaces", async () => {
const first = await createFixture();
const ensured = await first.service.ensure({ projectId: "project-1", runtimeId: "fixture" });
@@ -319,7 +340,17 @@ posixDescribe("provider probe service", () => {
});
});
async function createFixture(options: { gitProject?: boolean } = {}) {
async function createFixture(
options: {
gitProject?: boolean;
projectSource?: {
kind: "git";
url: string;
revision?: string;
subdirectory?: string;
};
} = {},
) {
const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-probe-"));
roots.push(root);
const projectRoot = path.join(root, "project");
@@ -383,6 +414,7 @@ async function createFixture(options: { gitProject?: boolean } = {}) {
providerWorkspaces,
clock,
runtime,
projectSource: options.projectSource,
get inspection() {
return inspection;
},
@@ -419,6 +451,12 @@ function createService(
requiresGitProject: boolean;
}[];
};
projectSource?: {
kind: "git";
url: string;
revision?: string;
subdirectory?: string;
};
},
runtimeConfiguration: Readonly<Record<string, unknown>> = {
fixture: { type: "command", options: { sentinel: true } },
@@ -430,7 +468,13 @@ function createService(
projects: {
get: async (projectId: string) =>
projectId === "project-1"
? { projectId, rootPath: fixture.projectRoot, updatedAt: "2026-08-12T00:00:00.000Z" }
? {
projectId,
rootPath: fixture.projectRoot,
source: fixture.projectSource,
updatedAt: "2026-08-12T00:00:00.000Z",
archivedAt: null,
}
: null,
},
runtime: fixture.runtime,
+7 -1
View File
@@ -136,6 +136,7 @@ import {
} from "./workspace-registry-model.js";
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
import {
projectRuntimeSource,
resolveProjectDisplayName,
resolveWorkspaceDisplayName,
resolveWorkspaceName,
@@ -5520,13 +5521,18 @@ export class Session {
await this.workspaceRegistry.remove(workspace.workspaceId);
throw new Error("Workspace runtime service was not composed");
}
const project = await this.projectRegistry.get(workspace.projectId);
if (!project) {
await this.workspaceRegistry.remove(workspace.workspaceId);
throw new Error(`Project not found after workspace creation: ${workspace.projectId}`);
}
try {
await workspaceRuntime.create({
workspaceId: workspace.workspaceId,
runtimeId,
project: {
id: workspace.projectId,
source: { kind: "host-directory", path: cwd },
source: projectRuntimeSource(project),
},
placement: { kind: "existing" },
setupFromPaseoConfig: true,
@@ -18,7 +18,11 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { createTestLogger } from "../test-utils/test-logger.js";
import { Session } from "./session.js";
import type { SessionOptions } from "./session.js";
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
import {
createWorkspaceRuntimeService,
type CreateWorkspaceInput,
type WorkspaceRuntimeService,
} from "./workspace-runtime/index.js";
import {
bindProviderWorkspace,
resolveProviderPlacementPolicy,
@@ -119,6 +123,7 @@ afterEach(async () => {
});
interface SessionTestAccess {
workspaceRuntime: WorkspaceRuntimeService;
projectRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
archive(projectId: string, archivedAt: string): Promise<void>;
@@ -651,6 +656,7 @@ function createSessionForWorkspaceTests(
? createNoopWorkspaceGitService({ renameBranch: options.renameCurrentBranch })
: (options.workspaceGitService ?? createNoopWorkspaceGitService());
const providerSnapshotManager = createProviderSnapshotManagerStub().manager;
const defaultProjects = new Map<string, PersistedProjectRecord>();
const session = asTestSession(
new Session({
@@ -696,16 +702,19 @@ function createSessionForWorkspaceTests(
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
getOrCreateActiveByRoot: async (input) =>
createPersistedProjectRecord({
get: async (projectId) => defaultProjects.get(projectId) ?? null,
getOrCreateActiveByRoot: async (input) => {
const project = createPersistedProjectRecord({
projectId: "prj_0000000000000000",
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
}),
});
defaultProjects.set(project.projectId, project);
return project;
},
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -9476,13 +9485,15 @@ test("workspace create stays out of a non-matching workspace subscription", asyn
test("workspace.create.request attaches a directory workspace to its explicit active project", async () => {
const runtimeCwd = createRuntimeDirectory();
const projectRoot = path.join(runtimeCwd, "unrelated");
mkdirSync(projectRoot);
const emitted: SessionOutboundMessage[] = [];
const projects = new Map([
[
"prj_explicit",
createPersistedProjectRecord({
projectId: "prj_explicit",
rootPath: path.join(runtimeCwd, "unrelated"),
rootPath: projectRoot,
kind: "non_git",
displayName: "unrelated",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -9515,11 +9526,67 @@ test("workspace.create.request attaches a directory workspace to its explicit ac
const workspaceId = response?.payload.workspace?.id;
expect(workspaceId).toEqual(expect.any(String));
expect(workspaces.get(workspaceId as string)).toMatchObject({
cwd: runtimeCwd,
cwd: projectRoot,
projectId: "prj_explicit",
});
});
test("workspace.create.request materializes an explicit project's git source instead of its cwd", async () => {
const runtimeCwd = createRuntimeDirectory();
const emitted: SessionOutboundMessage[] = [];
const project = createPersistedProjectRecord({
projectId: "prj_git_source",
rootPath: path.join(runtimeCwd, "host-decoy"),
source: {
kind: "git",
url: "https://example.test/acme/project.git",
revision: "release",
subdirectory: "packages/app",
},
kind: "git",
displayName: "project",
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
});
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const creates: CreateWorkspaceInput[] = [];
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.projectRegistry.get = async (projectId: string) =>
projectId === project.projectId ? project : null;
session.workspaceRegistry.upsert = async (record: unknown) => {
const workspace = record as PersistedWorkspaceRecord;
workspaces.set(workspace.workspaceId, workspace);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRuntime.create = async (input) => {
creates.push(input);
const workspace = workspaces.get(input.workspaceId);
if (!workspace) throw new Error(`Workspace not found: ${input.workspaceId}`);
await session.workspaceRegistry.upsert({
...workspace,
runtime: { runtimeId: input.runtimeId },
});
return { workspaceId: input.workspaceId, runtimeId: input.runtimeId, cwd: runtimeCwd };
};
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-git-source-project",
source: { kind: "directory", path: runtimeCwd, projectId: project.projectId },
});
expect(findByType(emitted, "workspace.create.response")?.payload.error).toBeNull();
expect(creates).toHaveLength(1);
expect(creates[0]?.project.source).toEqual({
kind: "git",
url: "https://example.test/acme/project.git",
revision: "release",
subdirectory: "packages/app",
});
expect(JSON.stringify(creates[0]?.project.source)).not.toContain(runtimeCwd);
});
test("workspace.create.request reports an unknown explicit project", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
@@ -428,7 +428,7 @@ export function createWorkspaceProvisioningService(deps: {
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
const kind = project.source || projectCheckout.isGit ? "git" : "non_git";
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
@@ -478,7 +478,8 @@ export function createWorkspaceProvisioningService(deps: {
workspaceCwd && workspaceCheckout && areEquivalentPaths(project.rootPath, workspaceCwd)
? workspaceCheckout
: await workspaceGitService.getCheckout(project.rootPath);
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
const kind: PersistedProjectRecord["kind"] =
project.source || projectCheckout.isGit ? "git" : "non_git";
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
remoteUrl: projectCheckout.remoteUrl,
@@ -9,7 +9,61 @@ import {
initialWorkspacePlacement,
reconcileWorkspacePlacement,
} from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
projectRuntimeSource,
} from "./workspace-registry.js";
describe("project runtime source", () => {
const project = createPersistedProjectRecord({
projectId: "project-one",
rootPath: "/host/decoy",
kind: "git",
displayName: "project-one",
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
});
test("uses the host project root only when no persisted source exists", () => {
expect(projectRuntimeSource(project)).toEqual({
kind: "host-directory",
path: "/host/decoy",
});
});
test("gives persisted git authority precedence over the host root", () => {
expect(
projectRuntimeSource({
...project,
source: {
kind: "git",
url: "https://example.test/acme/project.git",
revision: "release",
subdirectory: "packages/app",
},
}),
).toEqual({
kind: "git",
url: "https://example.test/acme/project.git",
revision: "release",
subdirectory: "packages/app",
});
});
test("uses the runtime contract default revision when persistence omits it", () => {
expect(
projectRuntimeSource({
...project,
source: { kind: "git", url: "https://example.test/acme/project.git" },
}),
).toEqual({
kind: "git",
url: "https://example.test/acme/project.git",
revision: "",
});
});
});
describe("opaque registry ids", () => {
test("generates opaque project ids", () => {
@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync } from "node:fs";
import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { beforeEach, afterEach, describe, expect, test } from "vitest";
@@ -65,6 +65,50 @@ describe("workspace runtime selection compatibility", () => {
);
});
describe("persisted project source validation", () => {
const input = {
projectId: "project-source",
rootPath: "/host/project",
kind: "git" as const,
displayName: "project",
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
};
test("accepts optional git revision and subdirectory", () => {
expect(
createPersistedProjectRecord({
...input,
source: {
kind: "git",
url: "https://example.test/acme/project.git",
revision: "main",
subdirectory: "packages/server",
},
}),
).toMatchObject({ source: { kind: "git", revision: "main", subdirectory: "packages/server" } });
});
test("rejects a Git source on a non-Git project", () => {
expect(() =>
createPersistedProjectRecord({
...input,
kind: "non_git",
source: { kind: "git", url: "https://example.test/acme/project.git" },
}),
).toThrow("A persisted Git source requires project kind git");
});
test.each([
{ kind: "git", url: "" },
{ kind: "git", url: "https://example.test/acme/project.git", revision: "" },
{ kind: "git", url: "https://example.test/acme/project.git", subdirectory: "" },
{ kind: "git", url: "https://example.test/acme/project.git", privateRoot: "/escape" },
])("rejects malformed source at the persistence edge: %j", (source) => {
expect(() => createPersistedProjectRecord({ ...input, source })).toThrow();
});
});
describe("workspace registries", () => {
let tmpDir: string;
let projectRegistry: FileBackedProjectRegistry;
@@ -87,6 +131,32 @@ describe("workspace registries", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("rejects a contradictory project source while loading persisted records", async () => {
mkdirSync(path.join(tmpDir, "projects"), { recursive: true });
writeFileSync(
path.join(tmpDir, "projects", "projects.json"),
JSON.stringify([
{
projectId: "contradictory-project",
rootPath: "/host/project",
kind: "non_git",
displayName: "project",
source: { kind: "git", url: "https://example.test/acme/project.git" },
projectKey: null,
customName: null,
customIconRevision: null,
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
archivedAt: null,
},
]),
);
await projectRegistry.initialize();
expect(await projectRegistry.list()).toEqual([]);
});
test("creates, updates, archives, deletes, and lists project records", async () => {
await projectRegistry.initialize();
await projectRegistry.upsert(
@@ -206,6 +276,31 @@ describe("workspace registries", () => {
expect(opaque.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
});
test("does not downgrade a sourced Git project when its convenience root is non-Git", async () => {
await projectRegistry.initialize();
const rootPath = path.join(tmpDir, "decoy-root");
const project = createPersistedProjectRecord({
projectId: "prj_sourced_git",
rootPath,
kind: "git",
source: { kind: "git", url: "https://example.com/source.git" },
displayName: "source",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
await projectRegistry.upsert(project);
const resolved = await projectRegistry.getOrCreateActiveByRoot({
rootPath,
kind: "non_git",
displayName: "decoy-root",
timestamp: "2026-03-02T00:00:00.000Z",
});
expect(resolved.kind).toBe("git");
expect(resolved.source).toEqual(project.source);
});
test("allocates a fresh opaque ID when only an archived exact root exists", async () => {
await projectRegistry.initialize();
const rootPath = path.join(tmpDir, "archived-root");
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import type { Logger } from "pino";
import { z } from "zod";
import type { WorkspaceProjectSource } from "./workspace-runtime/index.js";
import { writeJsonFileAtomic } from "./atomic-file.js";
import { areEquivalentPaths } from "../utils/path.js";
@@ -11,34 +12,53 @@ import {
type PersistedWorkspaceKind,
} from "./workspace-registry-model.js";
const PersistedProjectRecordSchema = z.object({
projectId: z.string(),
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
displayName: z.string(),
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
// User-set override layered over the derived displayName. Reconciliation
// never touches this. Null means "use the derived name". Added for #987.
customName: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
// Identifies the project's stored custom icon; null means automatic.
customIconRevision: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
const PersistedProjectRecordSchema = z
.object({
projectId: z.string(),
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
displayName: z.string(),
source: z
.object({
kind: z.literal("git"),
url: z.string().min(1),
revision: z.string().min(1).optional(),
subdirectory: z.string().min(1).optional(),
})
.strict()
.optional(),
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
projectKey: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
// User-set override layered over the derived displayName. Reconciliation
// never touches this. Null means "use the derived name". Added for #987.
customName: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
// Identifies the project's stored custom icon; null means automatic.
customIconRevision: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
})
.superRefine((project, context) => {
if (project.source && project.kind !== "git") {
context.addIssue({
code: "custom",
path: ["source"],
message: "A persisted Git source requires project kind git",
});
}
});
const PersistedWorkspaceRecordSchema = z.object({
workspaceId: z.string(),
@@ -402,11 +422,11 @@ export class FileBackedProjectRegistry
left.projectId.localeCompare(right.projectId),
)[0];
if (active) {
if (active.kind === input.kind && active.projectKey === (input.projectKey ?? null))
return active;
const kind = active.source ? "git" : input.kind;
if (active.kind === kind && active.projectKey === (input.projectKey ?? null)) return active;
const refreshed = {
...active,
kind: input.kind,
kind,
projectKey: input.projectKey ?? null,
updatedAt: input.timestamp,
};
@@ -587,6 +607,7 @@ export function createPersistedProjectRecord(input: {
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
source?: PersistedProjectRecord["source"];
customName?: string | null;
projectKey?: string | null;
customIconRevision?: string | null;
@@ -603,6 +624,17 @@ export function createPersistedProjectRecord(input: {
});
}
/** The only conversion from host project persistence to runtime materialization authority. */
export function projectRuntimeSource(
project: Pick<PersistedProjectRecord, "rootPath" | "source">,
): WorkspaceProjectSource {
if (!project.source) return { kind: "host-directory", path: project.rootPath };
return {
...project.source,
revision: project.source.revision ?? "",
};
}
export function resolveProjectDisplayName(record: PersistedProjectRecord): string {
return record.customName ?? record.displayName;
}
@@ -3,9 +3,7 @@ import type { Readable, Writable } from "node:stream";
export type WorkspaceRuntimeId = string;
export type WorkspaceId = string;
export type WorkspaceProjectSource =
| { kind: "host-directory"; path: string }
| { kind: "git"; url: string; revision: string; subdirectory?: string };
export type WorkspaceProjectSource = import("../index.js").WorkspaceProjectSource;
export type WorkspacePlacementIntent =
| { kind: "existing"; relativeCwd?: string }
@@ -11,7 +11,7 @@ import { createLocalRuntime } from "./internal/local-runtime.js";
import { createService } from "./internal/service.js";
import { createWorktreeRuntime } from "./internal/worktree-runtime.js";
export type WorkspaceSource =
export type WorkspaceProjectSource =
| { kind: "host-directory"; path: string }
| { kind: "git"; url: string; revision: string; subdirectory?: string };
@@ -73,7 +73,7 @@ export interface WorkspaceSetupCommand {
export interface CreateWorkspaceInput {
workspaceId: string;
runtimeId: string;
project: { id: string; source: WorkspaceSource };
project: { id: string; source: WorkspaceProjectSource };
placement: WorkspacePlacement;
purpose?: "provider-probe";
setup?: readonly WorkspaceSetupCommand[];
@@ -871,6 +871,184 @@ test("Docker probe pause survives restart and source invalidation rotates the pr
}
}, 180_000);
test("persisted Git sources materialize default and pinned revisions through Docker", async () => {
const fixture = await dockerTestFixture("paseo-runtime-docker-git-source-");
const root = fixture.root;
const sourceRepository = path.join(root, "source-repository");
const bareRepository = path.join(root, "source.git");
const decoyDefault = path.join(root, "decoy-default");
const decoyPinned = path.join(root, "decoy-pinned");
await mkdir(path.join(sourceRepository, "packages", "app"), { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: sourceRepository, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: sourceRepository,
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: sourceRepository });
await copyFile(fixtureAgent, path.join(sourceRepository, "packages", "app", "fixture-agent.mjs"));
await chmod(path.join(sourceRepository, "packages", "app", "fixture-agent.mjs"), 0o755);
await writeFile(path.join(sourceRepository, "packages", "app", "source-state.txt"), "pinned\n");
await writeFile(
path.join(sourceRepository, "packages", "app", "provider-model.txt"),
"fixture-model-pinned\n",
);
execFileSync("git", ["add", "."], { cwd: sourceRepository });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pinned state"], {
cwd: sourceRepository,
});
const pinnedRevision = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: sourceRepository,
encoding: "utf8",
}).trim();
await writeFile(path.join(sourceRepository, "packages", "app", "source-state.txt"), "default\n");
await writeFile(
path.join(sourceRepository, "packages", "app", "provider-model.txt"),
"fixture-model-default\n",
);
execFileSync("git", ["add", "."], { cwd: sourceRepository });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "default state"], {
cwd: sourceRepository,
});
execFileSync("git", ["clone", "--bare", sourceRepository, bareRepository], { stdio: "pipe" });
await mkdir(path.join(decoyDefault, "packages", "app"), { recursive: true });
await mkdir(path.join(decoyPinned, "packages", "app"), { recursive: true });
await writeFile(path.join(decoyDefault, "packages", "app", "source-state.txt"), "decoy\n");
await writeFile(path.join(decoyPinned, "packages", "app", "source-state.txt"), "decoy\n");
const paseoHomeRoot = path.join(root, "daemon-home");
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const projectRegistry = new FileBackedProjectRegistry(
path.join(paseoHome, "projects", "projects.json"),
createTestLogger(),
);
await projectRegistry.initialize();
const now = new Date().toISOString();
const sourceUrl = "file:///git/source.git";
for (const project of [
{
projectId: "docker-git-default",
rootPath: decoyDefault,
source: { kind: "git" as const, url: sourceUrl, subdirectory: "packages/app" },
},
{
projectId: "docker-git-pinned",
rootPath: decoyPinned,
source: {
kind: "git" as const,
url: sourceUrl,
revision: pinnedRevision,
subdirectory: "packages/app",
},
},
]) {
await projectRegistry.upsert(
createPersistedProjectRecord({
...project,
kind: "git",
displayName: project.projectId,
createdAt: now,
updatedAt: now,
}),
);
}
const daemon = await createTestPaseoDaemon({
paseoHomeRoot,
cleanup: false,
mcpEnabled: false,
agentClients: {},
providerOverrides: {
"docker-fixture": {
extends: "acp",
label: "Docker Fixture",
command: ["node", "./fixture-agent.mjs"],
params: { supportsMcpServers: false },
enabled: true,
},
},
workspaceRuntimes: {
docker: {
type: "docker",
image,
bindMounts: [{ source: bareRepository, target: "/git/source.git", readOnly: true }],
},
},
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.3.1",
reconnect: { enabled: false },
});
const projectIds = ["docker-git-default", "docker-git-pinned"] as const;
try {
await client.connect();
const defaultProbe = await client.ensureWorkspaceRuntimeProbe({
projectId: projectIds[0],
runtimeId: "docker",
});
expect(defaultProbe).toMatchObject({ status: "ready", error: null });
fixture.workspace(defaultProbe.workspaceId!);
const workspaceRoot = "/workspace/packages/app";
const probeSnapshot = await client.getProvidersSnapshot({
workspaceId: defaultProbe.workspaceId!,
cwd: workspaceRoot,
});
expect(probeSnapshot.entries).toContainEqual(
expect.objectContaining({
provider: "docker-fixture",
status: "ready",
models: [expect.objectContaining({ id: "fixture-model-default" })],
}),
);
const defaultWorkspace = await client.createWorkspace({
source: { kind: "directory", path: decoyDefault, projectId: projectIds[0] },
runtimeId: "docker",
});
if (!defaultWorkspace.workspace) {
throw new Error(defaultWorkspace.error ?? "Default Git-source workspace creation failed");
}
fixture.workspace(defaultWorkspace.workspace.id);
const defaultFile = await client.readFile(
workspaceRoot,
"source-state.txt",
undefined,
defaultWorkspace.workspace.id,
);
expect(new TextDecoder().decode(defaultFile.bytes)).toBe("default\n");
const pinnedWorkspace = await client.createWorkspace({
source: { kind: "directory", path: decoyPinned, projectId: projectIds[1] },
runtimeId: "docker",
});
if (!pinnedWorkspace.workspace) {
throw new Error(pinnedWorkspace.error ?? "Pinned Git-source workspace creation failed");
}
fixture.workspace(pinnedWorkspace.workspace.id);
const pinnedFile = await client.readFile(
workspaceRoot,
"source-state.txt",
undefined,
pinnedWorkspace.workspace.id,
);
expect(new TextDecoder().decode(pinnedFile.bytes)).toBe("pinned\n");
for (const projectId of projectIds) await client.removeProject(projectId);
for (const workspaceId of [
defaultProbe.workspaceId,
defaultWorkspace.workspace.id,
pinnedWorkspace.workspace.id,
]) {
expect(dockerResourceCount("ps", workspaceId!)).toBe(0);
expect(dockerResourceCount("volume", workspaceId!)).toBe(0);
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
}
}, 180_000);
test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => {
const fixture = await dockerTestFixture("paseo-runtime-docker-rollback-");
const root = fixture.root;
@@ -22,6 +22,10 @@ reconcile
Paseo sends options from trusted daemon configuration. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys—including private authority—fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. The optional create `purpose: "provider-probe"` field and create-only response field `materializedFreshContent` were added before external v1 adoption. Runtimes that optimize probe placement may use `purpose`; omission retains ordinary workspace creation. Every `create` response must set `materializedFreshContent`: `true` only when that call created fresh workspace content/resources on which repo setup is permitted, and `false` when it adopted or reused existing content. `resume` state responses omit it.
`project.source` is either a host directory or a Git URL plus a required revision string and optional subdirectory. An empty Git revision selects the remote's default state. Paseo derives this source before invoking the runtime; `placement.cwd` and `hostVisiblePath` never override it. A `provider-probe` create must expose the same runtime environment as a user workspace but must not execute repository setup. The checked-in JSON example uses this purpose so wrappers can lock its exact bytes.
The built-in Docker adapter owns image selection, project volumes, and configured bind mounts. Bind mounts are daemon configuration, not contract fields: a wrapper receives only its trusted `options`. Keep runtime-specific mount, container, VM, or supervisor authority out of lifecycle state and placement.
## Exec file descriptors
The workload owns fd 0, 1, and 2. Preserve their normal stdin, stdout, stderr, EOF, exit-code, and signal behavior.
@@ -43,3 +47,5 @@ The configured runtime environment must provide the compatible `paseo-workspace-
## Compatibility
Version 1 targets macOS/Linux hosts and POSIX runtime environments. Unknown v1 fields are rejected; future fields require a new protocol version. A semantic change to framing, fd ownership, lifecycle, or cleanup also increments the protocol version. Paseo fails closed on a version mismatch; it never falls back to host execution.
The private `@getpaseo/fixture-workspace-runtime` package is the executable contract fixture. It records the validated create input, can copy a host-directory source into an owned root, exposes deterministic failure and placement options, and runs workloads through both pipe and PTY framing. It is test infrastructure, not a production runtime or a second schema owner; all validation comes from this package's exported schemas.
@@ -10,7 +10,12 @@
"workspaceId": "workspace-01",
"project": {
"projectId": "project-01",
"source": { "kind": "host-directory", "path": "/source/project" }
"source": {
"kind": "git",
"url": "https://example.test/acme/project.git",
"revision": "main",
"subdirectory": "packages/app"
}
},
"placement": { "kind": "existing" },
"purpose": "provider-probe"
@@ -77,7 +82,7 @@
},
"bytes": {
"describeResponse": "{\"protocolVersion\":1,\"modes\":[\"pipes\",\"pty\"],\"reconcile\":true}\n",
"lifecycleRequest": "{\"protocolVersion\":1,\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"host-directory\",\"path\":\"/source/project\"}},\"placement\":{\"kind\":\"existing\"},\"purpose\":\"provider-probe\"},\"options\":{}}\n",
"lifecycleRequest": "{\"protocolVersion\":1,\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"git\",\"url\":\"https://example.test/acme/project.git\",\"revision\":\"main\",\"subdirectory\":\"packages/app\"}},\"placement\":{\"kind\":\"existing\"},\"purpose\":\"provider-probe\"},\"options\":{}}\n",
"lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"},\"materializedFreshContent\":true}\n",
"pipeSpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pipe-01\",\"stdio\":{\"kind\":\"pipes\"}}\n",
"ptySpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pty-01\",\"stdio\":{\"kind\":\"pty\",\"rows\":24,\"cols\":80,\"term\":\"xterm-256color\"}}\n",
+28
View File
@@ -69,6 +69,34 @@ New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new
Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain where they are; changing this setting only changes where Paseo creates and discovers Paseo-managed worktrees going forward.
## Workspace runtimes
New Workspace offers the built-in Local, Worktree, and Docker runtimes. The current Docker POC requires a locally built workspace-runtime image. Its configured default, `ghcr.io/getpaseo/workspace-runtime:latest`, is not published and does not currently work.
From the Paseo repository root, build the image:
```bash
docker build -f packages/docker-workspace-runtime/Dockerfile -t paseo-workspace-runtime:local .
```
Then override `workspaceRuntimes.docker.image` in daemon configuration:
```json
{
"workspaceRuntimes": {
"docker": {
"type": "docker",
"image": "paseo-workspace-runtime:local",
"enabled": true
}
}
}
```
Optional `providerEnvironment` values set the provider process environment. Optional bind mounts are available during Git materialization and in the running workspace. Their sources and targets must be absolute, targets must stay outside `/workspace`, and every mount must set `readOnly`. Set `enabled: false` to remove Docker from the runtime catalog.
Trusted external runtimes use `type: "command"`. `command` and optional `helperCommand` are argv arrays, not shell strings. Optional `label` names the runtime in New Workspace, `providerEnvironment` sets the isolated provider environment, and `options` passes runtime-owned configuration to the adapter. Runtime implementations follow the [`@getpaseo/workspace-runtime-contract`](https://github.com/getpaseo/paseo/tree/main/packages/workspace-runtime-contract).
## Voice
Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`.
+4 -3
View File
@@ -35,14 +35,15 @@ That matters because real development rarely fits into one long chat. You might
In Paseo, the workspace is the stable container. The sessions are what you run inside it.
## Choose the isolation
## Choose the runtime
Every workspace has an isolation mode:
Every workspace has a runtime:
- **Local** uses an existing directory, such as your main checkout. Use it when sessions should share the files already on disk.
- **Worktree** creates or opens a managed git worktree. Use it when a task needs its own directory and branch.
- **Docker** materializes the project's committed Git content in a container-owned workspace. Use it when files, tools, and providers should run in the configured image instead of the host environment.
The workspace is the product concept; a git worktree is one way to isolate its files. More than one workspace can refer to the same managed worktree, and Paseo removes that worktree after its last workspace is archived.
The workspace is the product concept; the runtime chooses where its files and commands live. More than one workspace can refer to the same managed worktree, and Paseo removes that worktree after its last workspace is archived.
## Creating a workspace
+88 -2
View File
@@ -41,6 +41,11 @@ export async function findWorkspaceBoundaryViolations(repoRoot) {
const governed =
external ||
(!isTestFile(importer) && (ownsMigratedSurface(importer) || publicOwners.has(importer)));
for (const specifier of staticSpecifiers(sourceFile)) {
if (!isTestFile(importer) && isForbiddenInternal(importer, specifier)) {
violations.push(violation(importer, specifier, "module-entrypoint"));
}
}
for (const load of moduleLoads(sourceFile)) {
if (load.specifier === null) {
if (governed) violations.push(violation(importer, "<computed>", "nonliteral-module-load"));
@@ -91,6 +96,8 @@ function staticSpecifiers(sourceFile) {
function moduleLoads(sourceFile) {
const loads = [];
const constants = constantBindings(sourceFile);
const loaderAliases = moduleLoaderAliases(sourceFile);
visit(sourceFile);
return loads;
@@ -98,15 +105,88 @@ function moduleLoads(sourceFile) {
if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
(ts.isIdentifier(node.expression) && loaderAliases.has(node.expression.text)))
) {
const argument = node.arguments.length === 1 ? node.arguments[0] : undefined;
loads.push({ specifier: argument && ts.isStringLiteral(argument) ? argument.text : null });
loads.push({ specifier: argument ? constantString(argument, constants) : null });
}
ts.forEachChild(node, visit);
}
}
function constantBindings(sourceFile) {
const constants = new Map();
let changed = true;
while (changed) {
changed = false;
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue;
if (!(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
const value = constantString(declaration.initializer, constants);
if (value === null || constants.get(declaration.name.text) === value) continue;
constants.set(declaration.name.text, value);
changed = true;
}
}
}
return constants;
}
function moduleLoaderAliases(sourceFile) {
const aliases = new Set(["require"]);
let changed = true;
while (changed) {
changed = false;
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (
!ts.isIdentifier(declaration.name) ||
!declaration.initializer ||
!ts.isIdentifier(declaration.initializer) ||
!aliases.has(declaration.initializer.text) ||
aliases.has(declaration.name.text)
) {
continue;
}
aliases.add(declaration.name.text);
changed = true;
}
}
}
return aliases;
}
function constantString(expression, constants) {
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
return expression.text;
}
if (ts.isIdentifier(expression)) return constants.get(expression.text) ?? null;
if (ts.isParenthesizedExpression(expression)) {
return constantString(expression.expression, constants);
}
if (
ts.isBinaryExpression(expression) &&
expression.operatorToken.kind === ts.SyntaxKind.PlusToken
) {
const left = constantString(expression.left, constants);
const right = constantString(expression.right, constants);
return left === null || right === null ? null : left + right;
}
if (ts.isTemplateExpression(expression)) {
let value = expression.head.text;
for (const span of expression.templateSpans) {
const substitution = constantString(span.expression, constants);
if (substitution === null) return null;
value += substitution + span.literal.text;
}
return value;
}
return null;
}
function isPublicEntryPoint(specifier) {
return /workspace-(?:runtime|helper)\/index\.(?:js|ts)$/.test(specifier);
}
@@ -125,6 +205,12 @@ function isHostCapability(specifier) {
function isForbiddenInternal(importer, specifier) {
const target = resolveSpecifier(importer, specifier);
if (!target || !target.includes("/internal/")) return false;
if (target.includes("/provider-probe/internal/")) {
return !(
importer.endsWith("/provider-probe/index.ts") ||
importer.includes("/provider-probe/internal/")
);
}
if (target.includes("/workspace-runtime/command/internal/")) {
return !(
importer.endsWith("/workspace-runtime/command/index.ts") ||
+38 -1
View File
@@ -88,7 +88,7 @@ test("the AST guard rejects computed loading and dynamic host/internal bypasses"
"module-entrypoint",
"nonliteral-module-load",
"nonliteral-module-load",
"nonliteral-module-load",
"workspace-host-access",
"workspace-host-access",
"workspace-host-access",
"workspace-host-access",
@@ -96,6 +96,36 @@ test("the AST guard rejects computed loading and dynamic host/internal bypasses"
);
});
test("the AST guard rejects every statically resolvable provider probe internal import", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"packages/server/src/server/session/computed.ts",
'import { createService as direct } from "../provider-probe/internal/service.js";\n' +
'import { createService as alias } from "@getpaseo/server/provider-probe/internal/service.js";\n' +
'export { createService } from "../provider-probe/internal/service.js";\n' +
'const internal = "../provider-probe/" + "internal/service.js";\n' +
"const load = require;\n" +
'void require("../provider-probe/internal/service.js");\n' +
'void import("../provider-probe/" + "internal/service.js");\n' +
'void import(`../provider-probe/${"internal"}/service.js`);\n' +
"void import(internal);\n" +
"void load(internal);\n" +
"void direct; void alias;\n",
);
const violations = await findWorkspaceBoundaryViolations(root);
assert.equal(violations.length, 8);
assert.deepEqual(new Set(violations.map(({ rule }) => rule)), new Set(["module-entrypoint"]));
assert.deepEqual(
new Set(violations.map((violation) => violation.import)),
new Set([
"../provider-probe/internal/service.js",
"@getpaseo/server/provider-probe/internal/service.js",
]),
);
});
test("owned integrations, explicit legacy code, and fixture contract imports pass", async (t) => {
const root = await fixtureRoot(t);
await copyFile(path.join(repoRoot, ".oxlintrc.json"), path.join(root, ".oxlintrc.json"));
@@ -104,6 +134,13 @@ test("owned integrations, explicit legacy code, and fixture contract imports pas
"packages/server/src/server/workspace-runtime/index.ts",
'import { service } from "./internal/service.js"; void service;\n',
);
await source(
root,
"packages/server/src/server/provider-probe/index.ts",
'import { createService } from "./internal/service.js";\n' +
'export { createProbeStore } from "./internal/probe-store.js";\n' +
"void createService;\n",
);
await source(
root,
"packages/server/src/server/workspace-runtime/command/index.ts",