diff --git a/docs/architecture.md b/docs/architecture.md index 6af406f975..08f3fd8d66 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/docker.md b/docs/docker.md index 15e22081bb..920d5ed7d8 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 diff --git a/docs/glossary.md b/docs/glossary.md index c0caff555b..267a5a6ef7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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). diff --git a/docs/providers.md b/docs/providers.md index a8e05895f8..9c1cfe3ae4 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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. --- diff --git a/docs/qa.md b/docs/qa.md index 25176f80fe..242ea4f4dc 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -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 diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts index 9e0bfbb821..f668ee06bf 100644 --- a/packages/docker-workspace-runtime/src/index.ts +++ b/packages/docker-workspace-runtime/src/index.ts @@ -173,6 +173,7 @@ async function create( "run", "--rm", ...mounts, + ...dockerBindMountArguments(bindMounts), image, "/bin/sh", "-c", diff --git a/packages/server/src/server/provider-probe/index.ts b/packages/server/src/server/provider-probe/index.ts index 1225a55e3e..43568d6d92 100644 --- a/packages/server/src/server/provider-probe/index.ts +++ b/packages/server/src/server/provider-probe/index.ts @@ -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 | null>; }; runtime: { create(input: CreateWorkspaceInput): Promise; diff --git a/packages/server/src/server/provider-probe/internal/service.ts b/packages/server/src/server/provider-probe/internal/service.ts index b4f13bf860..301d686a90 100644 --- a/packages/server/src/server/provider-probe/internal/service.ts +++ b/packages/server/src/server/provider-probe/internal/service.ts @@ -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; runtimeId: string; runtimeConfiguration: unknown; }): Promise { - 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 { diff --git a/packages/server/src/server/provider-probe/provider-probe.posix.test.ts b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts index 5d4ae982c7..1b6a3f454d 100644 --- a/packages/server/src/server/provider-probe/provider-probe.posix.test.ts +++ b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts @@ -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> = { 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, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5eebdbe386..f36ac8d03a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index eb28ff73f7..6f874158be 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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; archive(projectId: string, archivedAt: string): Promise; @@ -651,6 +656,7 @@ function createSessionForWorkspaceTests( ? createNoopWorkspaceGitService({ renameBranch: options.renameCurrentBranch }) : (options.workspaceGitService ?? createNoopWorkspaceGitService()); const providerSnapshotManager = createProviderSnapshotManagerStub().manager; + const defaultProjects = new Map(); 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(); + 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) }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 3ff8a8e7ad..6de5bd2b1a 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -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, diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 1f51a0c4a1..77f2fb939c 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -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", () => { diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 1848b42aca..8aea9ee167 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -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"); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 03c765b63d..0b2470296c 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -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, +): 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; } diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index f797eb277b..f7aa7c8d18 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -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 } diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 5c890c388f..930c128a94 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -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[]; diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index 1b292904d7..2d78121f6b 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -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; diff --git a/packages/workspace-runtime-contract/README.md b/packages/workspace-runtime-contract/README.md index 9f43b14923..23e893a600 100644 --- a/packages/workspace-runtime-contract/README.md +++ b/packages/workspace-runtime-contract/README.md @@ -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. diff --git a/packages/workspace-runtime-contract/examples/v1.json b/packages/workspace-runtime-contract/examples/v1.json index d0c35abeba..5b53d037b5 100644 --- a/packages/workspace-runtime-contract/examples/v1.json +++ b/packages/workspace-runtime-contract/examples/v1.json @@ -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", diff --git a/public-docs/configuration.md b/public-docs/configuration.md index 562981f2d6..01cdfa1b25 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -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`. diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index aa810c9bd6..dfd4815484 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -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 diff --git a/scripts/check-workspace-runtime-boundaries.mjs b/scripts/check-workspace-runtime-boundaries.mjs index 4dab903e54..b8252cf84e 100644 --- a/scripts/check-workspace-runtime-boundaries.mjs +++ b/scripts/check-workspace-runtime-boundaries.mjs @@ -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, "", "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") || diff --git a/scripts/workspace-runtime-boundaries.test.mjs b/scripts/workspace-runtime-boundaries.test.mjs index 30293fbf76..35e611494d 100644 --- a/scripts/workspace-runtime-boundaries.test.mjs +++ b/scripts/workspace-runtime-boundaries.test.mjs @@ -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",