diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index fedb199610..e8bc46da04 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -68,10 +68,18 @@ function buildSessionsQueriesConfig(args: { visible: boolean; client: RecentProviderSessionsClient | null; cwd: string | null | undefined; + workspaceId?: string | null; hostDisconnectedMessage?: string; }): SessionsQueryConfig[] { - const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } = - args; + const { + providersToFetch, + sessionsQueryRoot, + visible, + client, + cwd, + workspaceId, + hostDisconnectedMessage, + } = args; if (providersToFetch === null) return []; const enabled = visible && Boolean(client); return providersToFetch.map((provider) => ({ @@ -82,6 +90,7 @@ function buildSessionsQueriesConfig(args: { throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); } return await client.fetchRecentProviderSessions({ + ...(workspaceId ? { workspaceId } : {}), ...(cwd ? { cwd } : {}), providers: [provider], limit: PER_PROVIDER_LIMIT, @@ -274,6 +283,7 @@ export function ImportSessionSheet({ const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { cwd, + workspaceId, enabled: visible, }); const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget"); @@ -294,8 +304,8 @@ export function ImportSessionSheet({ ); const sessionsQueryRoot = useMemo( - () => ["recent-provider-sessions", cwd ?? null] as const, - [cwd], + () => ["recent-provider-sessions", workspaceId ?? null, cwd ?? null] as const, + [cwd, workspaceId], ); const queriesConfig = useMemo( @@ -306,9 +316,10 @@ export function ImportSessionSheet({ visible, client, cwd, + workspaceId, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [providersToFetch, sessionsQueryRoot, visible, client, cwd, t], + [providersToFetch, sessionsQueryRoot, visible, client, cwd, workspaceId, t], ); const queries = useQueries({ queries: queriesConfig }); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 642f080469..48fbcb8dfe 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -330,6 +330,7 @@ function pickDesktopModel({ type AgentControlsSlice = { provider: string; cwd: string | null; + workspaceId: string | undefined; runtimeModelId: string | null; model: string | null | undefined; features: AgentFeature[] | undefined; @@ -349,6 +350,7 @@ function selectAgentControlsSlice( return { provider: currentAgent.provider, cwd: currentAgent.cwd, + workspaceId: currentAgent.workspaceId, runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, features: currentAgent.features, @@ -1444,7 +1446,10 @@ export const AgentControls = memo(function AgentControls({ isRefreshing: snapshotIsRefreshing, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + } = useProvidersSnapshot(serverId, { + cwd: agent?.cwd, + workspaceId: agent?.workspaceId, + }); const snapshotSelectedEntry = useMemo( () => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider), diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 4534dd1500..a277faa6b6 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -253,6 +253,7 @@ export function useLiveAgentModeControl( return { provider: agent.provider, cwd: agent.cwd, + workspaceId: agent.workspaceId, currentModeId: agent.currentModeId, }; }), @@ -265,7 +266,10 @@ export function useLiveAgentModeControl( const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const { updatePreferences } = useFormPreferences(); const toast = useToast(); - const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd }); + const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { + cwd: slice?.cwd, + workspaceId: slice?.workspaceId, + }); const providerDefinitions = useMemo(() => { if (!slice?.provider) return []; diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 995a5382f3..c8077e1fb6 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -28,6 +28,7 @@ type AttachmentUpdater = | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); interface AgentInputDraftComposerOptions { + workspaceId?: string | null; initialServerId: string | null; initialValues?: CreateAgentInitialValues; initialFeatureValues?: Record; @@ -69,6 +70,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr isVisible: composerOptions?.isVisible ?? false, isCreateFlow: true, onlineServerIds: composerOptions?.onlineServerIds ?? [], + workspaceId: composerOptions?.workspaceId, }); const draftKey = useMemo( () => diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e8c992b4e8..12867a7c01 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -365,6 +365,7 @@ export function WorkspaceDraftAgentTab({ const draftInput = useAgentInputDraft({ draftKey: draftStoreKey, composer: { + workspaceId, initialServerId: serverId, initialValues: draftInitialValues, initialFeatureValues: draftSetup?.featureValues, diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index 2722c68747..8136735f32 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -41,12 +41,12 @@ describe("provider snapshot cache", () => { await cache.write({ serverId: "server-1", - cwd: "/repo", + scope: { type: "legacy-cwd", cwd: "/repo" }, hash: "snapshot-hash", generatedAt: "2026-08-04T00:00:00.000Z", compactSnapshot: compactProviderSnapshot(entries), }); - const cached = await cache.read("server-1", "/repo"); + const cached = await cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" }); expect(cached?.entries).toEqual(entries); expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe( @@ -57,9 +57,41 @@ describe("provider snapshot cache", () => { it("discards an invalid cache record", async () => { const storage = createStorage(); const cache = createProviderSnapshotCache(storage); - storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json"); + storage.values.set( + '@paseo/provider-snapshot/v1:["server-1",{"type":"legacy-cwd","cwd":"/repo"}]', + "not json", + ); - await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); + await expect(cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" })).resolves.toBeNull(); expect(storage.values.size).toBe(0); }); + + it("keeps selected workspaces with the same cwd structurally isolated", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + const snapshot = (provider: "claude" | "codex") => + compactProviderSnapshot([{ provider, status: "ready", enabled: true, models: [] }]); + + await cache.write({ + serverId: "server-1", + scope: { type: "workspace", workspaceId: "workspace-a" }, + hash: "hash-a", + generatedAt: "2026-08-11T00:00:00.000Z", + compactSnapshot: snapshot("claude"), + }); + await cache.write({ + serverId: "server-1", + scope: { type: "workspace", workspaceId: "workspace-b" }, + hash: "hash-b", + generatedAt: "2026-08-11T00:00:01.000Z", + compactSnapshot: snapshot("codex"), + }); + + await expect( + cache.read("server-1", { type: "workspace", workspaceId: "workspace-a" }), + ).resolves.toMatchObject({ hash: "hash-a" }); + await expect( + cache.read("server-1", { type: "workspace", workspaceId: "workspace-b" }), + ).resolves.toMatchObject({ hash: "hash-b" }); + }); }); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index c7da601123..b6fd29835b 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -26,11 +26,15 @@ export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } +export type ProviderSnapshotCacheScope = + | { type: "workspace"; workspaceId: string } + | { type: "legacy-cwd"; cwd: string | null }; + export interface ProviderSnapshotCache { - read(serverId: string, cwd: string | null): Promise; + read(serverId: string, scope: ProviderSnapshotCacheScope): Promise; write(input: { serverId: string; - cwd: string | null; + scope: ProviderSnapshotCacheScope; hash: string; generatedAt: string; compactSnapshot: CompactProviderSnapshot; @@ -44,8 +48,8 @@ export class ProviderSnapshotCacheMissError extends Error { } } -function cacheKey(serverId: string, cwd: string | null): string { - return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; +function cacheKey(serverId: string, scope: ProviderSnapshotCacheScope): string { + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, scope])}`; } function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { @@ -85,8 +89,8 @@ export function createProviderSnapshotCache( storage: ProviderSnapshotStorage, ): ProviderSnapshotCache { return { - async read(serverId, cwd) { - const key = cacheKey(serverId, cwd); + async read(serverId, scope) { + const key = cacheKey(serverId, scope); let value: string | null; try { value = await storage.getItem(key); @@ -118,7 +122,7 @@ export function createProviderSnapshotCache( compactSnapshot: input.compactSnapshot, }; try { - await storage.setItem(cacheKey(input.serverId, input.cwd), JSON.stringify(stored)); + await storage.setItem(cacheKey(input.serverId, input.scope), JSON.stringify(stored)); } catch (error) { if (!(error instanceof Error)) throw error; } diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 5a59be037c..3f1c6e94d6 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -11,8 +11,15 @@ export function providersSnapshotQueryRoot(serverId: string | null) { return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const; } -export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { +export function providersSnapshotQueryKey( + serverId: string | null, + cwd?: string | null, + workspaceId?: string | null, +) { const normalizedCwd = normalizeProvidersSnapshotCwd(cwd); + if (workspaceId) { + return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "workspace", workspaceId] as const; + } return normalizedCwd ? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const) : ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const); @@ -22,10 +29,12 @@ export function providersSnapshotRequestOptions(input: { cwd?: string | null; providers?: AgentProvider[]; ifNoneMatch?: string; + workspaceId?: string | null; }) { const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd); return { ...(normalizedCwd ? { cwd: normalizedCwd } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input.providers ? { providers: input.providers } : {}), ...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}), }; diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index d3fb4b49cf..69f527a1dd 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -14,6 +14,7 @@ import { providersSnapshotQueryKey, providersSnapshotQueryRoot, } from "@/data/providers-snapshot"; +import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache"; import type { WorkspaceGitClient } from "@/git/workspace-git"; type ProvidersSnapshotUpdateMessage = Extract< @@ -189,7 +190,11 @@ export function applyProvidersSnapshotUpdate(input: { if (input.message.type !== "providers_snapshot_update") { return; } - const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); + const queryKey = providersSnapshotQueryKey( + input.serverId, + input.message.payload.cwd, + input.message.payload.workspaceId, + ); input.queryClient.setQueryData(queryKey, { entries: input.message.payload.entries, generatedAt: input.message.payload.generatedAt, @@ -199,7 +204,12 @@ export function applyProvidersSnapshotUpdate(input: { if (compactSnapshot && snapshotHash) { void (input.cache ?? providerSnapshotCache).write({ serverId: input.serverId, - cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), + scope: input.message.payload.workspaceId + ? { type: "workspace", workspaceId: input.message.payload.workspaceId } + : ({ + type: "legacy-cwd", + cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), + } satisfies ProviderSnapshotCacheScope), hash: snapshotHash, generatedAt: input.message.payload.generatedAt, compactSnapshot, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 29e90a6a9f..73c8edd0f0 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -41,6 +41,7 @@ import { export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export interface UseAgentFormStateOptions { + workspaceId?: string | null; initialServerId?: string | null; initialValues?: FormInitialValues; isVisible?: boolean; @@ -272,7 +273,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg error: snapshotError, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir }); + } = useProvidersSnapshot(formState.serverId, { + cwd: formState.workingDir, + workspaceId: options.workspaceId, + }); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 620668e1fb..c040a3e205 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -185,7 +185,7 @@ describe("fetchProvidersSnapshot", () => { expect(cache.writes).toEqual([ { serverId, - cwd: "/repo-a", + scope: { type: "legacy-cwd", cwd: "/repo-a" }, hash: "next-hash", generatedAt: "2026-01-02T00:00:00.000Z", compactSnapshot, @@ -369,7 +369,7 @@ describe("applyProvidersSnapshotUpdate", () => { expect(cache.writes).toEqual([ { serverId, - cwd: "/repo-a", + scope: { type: "legacy-cwd", cwd: "/repo-a" }, hash: "push-hash", generatedAt: "2026-01-01T00:00:01.000Z", compactSnapshot, diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 9dfdae6f37..1fa647200e 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -21,6 +21,7 @@ import { providersSnapshotQueryRoot, providersSnapshotRequestOptions, } from "@/data/providers-snapshot"; +import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache"; type GetProvidersSnapshotResult = Awaited>; type RefreshProvidersSnapshotResult = Awaited>; @@ -32,16 +33,29 @@ export type ProvidersSnapshotClient = Pick< "getProvidersSnapshot" | "refreshProvidersSnapshot" >; +function providerSnapshotCacheScope( + cwd: string | null, + workspaceId?: string | null, +): ProviderSnapshotCacheScope { + return workspaceId ? { type: "workspace", workspaceId } : { type: "legacy-cwd", cwd }; +} + export async function fetchProvidersSnapshot(input: { client: ProvidersSnapshotClient; serverId: string; cwd: string | null; + workspaceId?: string | null; cache?: ProviderSnapshotCache; }): Promise { const cache = input.cache ?? providerSnapshotCache; - const cached = await cache.read(input.serverId, input.cwd); + const scope = providerSnapshotCacheScope(input.cwd, input.workspaceId); + const cached = await cache.read(input.serverId, scope); const snapshot = await input.client.getProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), + providersSnapshotRequestOptions({ + cwd: input.cwd, + workspaceId: input.workspaceId, + ifNoneMatch: cached?.hash, + }), ); if (snapshot.notModified) { if (!cached) { @@ -52,7 +66,7 @@ export async function fetchProvidersSnapshot(input: { if (snapshot.compactSnapshot && snapshot.snapshotHash) { await cache.write({ serverId: input.serverId, - cwd: input.cwd, + scope, hash: snapshot.snapshotHash, generatedAt: snapshot.generatedAt, compactSnapshot: snapshot.compactSnapshot, @@ -66,19 +80,28 @@ export async function refreshAndApplyProvidersSnapshot(input: { queryClient: QueryClient; serverId: string; cwd: string | null; + workspaceId?: string | null; providers?: AgentProvider[]; cache?: ProviderSnapshotCache; }): Promise { const refreshResult = await input.client.refreshProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), + providersSnapshotRequestOptions({ + cwd: input.cwd, + workspaceId: input.workspaceId, + providers: input.providers, + }), ); const snapshot = await fetchProvidersSnapshot({ client: input.client, serverId: input.serverId, cwd: input.cwd, + workspaceId: input.workspaceId, cache: input.cache, }); - input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); + input.queryClient.setQueryData( + providersSnapshotQueryKey(input.serverId, input.cwd, input.workspaceId), + snapshot, + ); void input.queryClient.invalidateQueries({ queryKey: agentCommandsQueryRoot(input.serverId), exact: false, @@ -122,6 +145,7 @@ interface UseProvidersSnapshotResult { interface UseProvidersSnapshotOptions { enabled?: boolean; cwd?: string | null; + workspaceId?: string | null; } export function useProvidersSnapshot( @@ -139,7 +163,10 @@ export function useProvidersSnapshot( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, ); - const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]); + const queryKey = useMemo( + () => providersSnapshotQueryKey(serverId, cwd, options.workspaceId), + [cwd, options.workspaceId, serverId], + ); const snapshotQuery = useReplicaQuery({ queryKey, @@ -149,7 +176,12 @@ export function useProvidersSnapshot( if (!client || !serverId) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return fetchProvidersSnapshot({ client, serverId, cwd }); + return fetchProvidersSnapshot({ + client, + serverId, + cwd, + workspaceId: options.workspaceId, + }); }, }); @@ -163,6 +195,7 @@ export function useProvidersSnapshot( queryClient, serverId, cwd, + workspaceId: options.workspaceId, providers, }); }, @@ -206,13 +239,14 @@ export function useProvidersSnapshot( export function prefetchProvidersSnapshot( serverId: string, client: DaemonClient, - options: { cwd?: string | null } = {}, + options: { cwd?: string | null; workspaceId?: string | null } = {}, ): void { const cwd = normalizeProvidersSnapshotCwd(options.cwd); - const queryKey = providersSnapshotQueryKey(serverId, cwd); + const queryKey = providersSnapshotQueryKey(serverId, cwd, options.workspaceId); void singletonQueryClient.prefetchQuery({ queryKey, staleTime: Infinity, - queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }), + queryFn: () => + fetchProvidersSnapshot({ client, serverId, cwd, workspaceId: options.workspaceId }), }); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index db4391b06b..60d98e1628 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1768,12 +1768,16 @@ function WorkspaceScreenContent({ ) { return; } - prefetchProvidersSnapshot(normalizedServerId, client, { cwd: workspaceDirectory }); + prefetchProvidersSnapshot(normalizedServerId, client, { + cwd: workspaceDirectory, + workspaceId: normalizedWorkspaceId, + }); }, [ client, isConnected, isRouteFocused, normalizedServerId, + normalizedWorkspaceId, supportsProvidersSnapshot, workspaceDirectory, ]); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index dbbdf0f5de..1b912a1618 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2129,6 +2129,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "fetch_recent_provider_sessions_request", requestId: resolvedRequestId, + ...(options?.workspaceId ? { workspaceId: options.workspaceId } : {}), ...(options?.cwd ? { cwd: options.cwd } : {}), ...(options?.providers ? { providers: options.providers } : {}), ...(options?.since ? { since: options.since } : {}), @@ -4791,6 +4792,7 @@ export class DaemonClient { async getProvidersSnapshot(options?: { cwd?: string; + workspaceId?: string; ifNoneMatch?: string; requestId?: string; }): Promise { @@ -4799,6 +4801,7 @@ export class DaemonClient { message: { type: "get_providers_snapshot_request", cwd: options?.cwd, + workspaceId: options?.workspaceId, ifNoneMatch: options?.ifNoneMatch, }, responseType: "get_providers_snapshot_response", @@ -4922,6 +4925,7 @@ export class DaemonClient { async refreshProvidersSnapshot(options?: { cwd?: string; + workspaceId?: string; providers?: AgentProvider[]; requestId?: string; }): Promise { @@ -4930,6 +4934,7 @@ export class DaemonClient { message: { type: "refresh_providers_snapshot_request", cwd: options?.cwd, + workspaceId: options?.workspaceId, providers: options?.providers, }, responseType: "refresh_providers_snapshot_response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d6177b4faa..a5769c2d68 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1170,6 +1170,7 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({ export const FetchRecentProviderSessionsRequestMessageSchema = z.object({ type: z.literal("fetch_recent_provider_sessions_request"), requestId: z.string(), + workspaceId: z.string().optional(), cwd: z.string().optional(), providers: z.array(z.string()).optional(), since: z.string().optional(), @@ -1364,6 +1365,7 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({ export const GetProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("get_providers_snapshot_request"), cwd: z.string().optional(), + workspaceId: z.string().optional(), // COMPAT(compactProviderSnapshots): old daemons ignore this field and return a full snapshot. ifNoneMatch: z.string().optional(), requestId: z.string(), @@ -1372,6 +1374,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({ export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("refresh_providers_snapshot_request"), cwd: z.string().optional(), + workspaceId: z.string().optional(), providers: z.array(AgentProviderSchema).optional(), requestId: z.string(), }); @@ -5119,6 +5122,7 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({ type: z.literal("providers_snapshot_update"), payload: z.object({ cwd: z.string().optional(), + workspaceId: z.string().optional(), entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 82a8e3e3c3..dba4b071d9 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -34,9 +34,11 @@ import type { AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, + FetchCatalogOptions, ImportProviderSessionInput, ImportProviderSessionContext, ResolveAgentDefaultModeInput, + ProviderWorkspace, } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; @@ -47,6 +49,49 @@ interface Deferred { reject: (reason?: unknown) => void; } +function createTestProviderWorkspace(commandAvailable: boolean): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable(command) { + if (!commandAvailable) throw new Error(`Provider command '${command}' was not found`); + return "/provider"; + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState() { + return { entries: [] }; + }, + async readStateText() { + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} + function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -1804,6 +1849,79 @@ test("createAgent passes daemon launch env through the provider launch context", }); }); +test("selected workspace availability fails closed without probing or launching on the host", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-provider-")); + const availabilityScopes: Array = []; + let createCalls = 0; + class RuntimeScopedClient extends TestAgentClient { + override async isAvailable(options?: FetchCatalogOptions): Promise { + availabilityScopes.push(options); + return false; + } + + override async createSession(config: AgentSessionConfig): Promise { + createCalls += 1; + return new TestAgentSession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new RuntimeScopedClient() }, + logger, + resolveProviderWorkspace: async () => createTestProviderWorkspace(false), + }); + + try { + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "selected-workspace", + }), + ).rejects.toThrow("not available in the selected workspace runtime"); + expect(availabilityScopes).toEqual([ + expect.objectContaining({ + scope: "workspace", + workspaceId: "selected-workspace", + }), + ]); + expect(createCalls).toBe(0); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("selected workspace without a bound capability never checks host availability", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-missing-workspace-capability-")); + let hostAvailabilityCalls = 0; + let createCalls = 0; + class HostAvailableClient extends TestAgentClient { + override async isAvailable(): Promise { + hostAvailabilityCalls += 1; + return true; + } + + override async createSession(config: AgentSessionConfig): Promise { + createCalls += 1; + return new TestAgentSession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new HostAvailableClient() }, + logger, + resolveProviderWorkspace: async () => undefined, + }); + + try { + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "selected-without-capability", + }), + ).rejects.toThrow("workspace runtime capability is unavailable"); + expect(hostAvailabilityCalls).toBe(0); + expect(createCalls).toBe(0); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("createAgent passes persistSession to provider create options", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -1856,6 +1974,7 @@ test("createAgent persists workspaceId on the stored record and emits it in the }, registry: storage, logger, + resolveProviderWorkspace: async () => createTestProviderWorkspace(true), idFactory: () => "00000000-0000-4000-8000-0000000000a1", }); @@ -2852,12 +2971,14 @@ test("importProviderSession imports the selected session without listing and pub } const client = new ImportClient(); + const workspace = createTestProviderWorkspace(true); const manager = new AgentManager({ clients: { codex: client, }, registry: storage, logger, + resolveProviderWorkspace: async () => workspace, }); manager.subscribe((event) => events.push(event), { replayState: false }); @@ -2876,6 +2997,7 @@ test("importProviderSession imports the selected session without listing and pub PASEO_AGENT_ID: imported.id, PASEO_AGENT_CWD: workdir, }, + workspace, }); expect(imported.lifecycle).toBe("idle"); expect(imported.historyPrimed).toBe(true); @@ -8821,6 +8943,20 @@ class RecordingPersistedAgentsClient implements AgentClient { } } +test("selected import discovery without a bound capability never reads host provider state", async () => { + const client = new RecordingPersistedAgentsClient("claude"); + const manager = new AgentManager({ + clients: { claude: client }, + logger, + resolveProviderWorkspace: async () => undefined, + }); + + await expect( + manager.listImportableSessions({ workspaceId: "selected-without-capability" }), + ).rejects.toThrow("workspace runtime capability is unavailable"); + expect(client.calls).toBe(0); +}); + test.each([ [ "disabled", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ebc090dda7..8a4c4ca9ed 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -43,6 +43,7 @@ import { type ImportedTimelineEntry, type ImportableProviderSession, type ListImportableSessionsOptions, + type ProviderWorkspace, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; @@ -203,6 +204,7 @@ interface HydrateTimelineOptions { } export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & { + workspaceId?: string; /** * When set, only providers in this set are scanned, in addition to the * built-in importable allowlist + enabled + non-derived rules. @@ -277,10 +279,10 @@ export interface AgentManagerOptions { agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; logger: Logger; - resolveWorkspaceExecution?: ( + resolveProviderWorkspace?: ( workspaceId: string, cwd: string, - ) => Promise; + ) => Promise; } export interface WaitForAgentOptions { @@ -646,7 +648,7 @@ export class AgentManager { private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void; private logger: Logger; private readonly rescueTimeouts: Required; - private readonly resolveWorkspaceExecution?: AgentManagerOptions["resolveWorkspaceExecution"]; + private readonly resolveProviderWorkspace?: AgentManagerOptions["resolveProviderWorkspace"]; private acceptingAgentRegistrations = true; constructor(options: AgentManagerOptions) { @@ -660,7 +662,7 @@ export class AgentManager { this.configurePaseoTools(options); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); - this.resolveWorkspaceExecution = options.resolveWorkspaceExecution; + this.resolveProviderWorkspace = options.resolveProviderWorkspace; this.rescueTimeouts = { reloadSessionCloseMs: options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS, @@ -868,6 +870,20 @@ export class AgentManager { async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, ): Promise { + let workspace: ProviderWorkspace | undefined; + if (options?.workspaceId) { + if (!this.resolveProviderWorkspace) { + throw new Error(`workspace runtime capability is unavailable: ${options.workspaceId}`); + } + const resolved = await this.resolveProviderWorkspace( + options.workspaceId, + options.cwd ?? process.cwd(), + ); + if (resolved === undefined) { + throw new Error(`workspace runtime capability is unavailable: ${options.workspaceId}`); + } + workspace = resolved ?? undefined; + } const providerEntries = Array.from(this.clients.entries()).filter( ([provider, client]) => client.capabilities.supportsSessionListing && @@ -881,6 +897,7 @@ export class AgentManager { await client.listImportableSessions!({ limit: options?.limit, cwd: options?.cwd, + workspace, }) ).map((session) => Object.assign(session, { provider })); } catch (error) { @@ -1098,9 +1115,9 @@ export class AgentManager { options?.env, ); this.requireEnabledProvider(storedConfig.provider); - const client = await this.requireAvailableClient({ - provider: storedConfig.provider, - }); + const client = options.workspaceId + ? this.requireClient(storedConfig.provider) + : await this.requireAvailableClient({ provider: storedConfig.provider }); const launchContext = await this.buildLaunchContext( resolvedAgentId, client, @@ -1108,6 +1125,13 @@ export class AgentManager { options?.env, options.workspaceId, ); + await this.requireWorkspaceProviderAvailable( + client, + storedConfig.provider, + storedConfig.cwd, + options.workspaceId, + launchContext, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); @@ -1180,8 +1204,7 @@ export class AgentManager { ); const client = this.requireClient(handle.provider); - const available = await client.isAvailable(); - if (!available) { + if (!options?.workspaceId && !(await client.isAvailable())) { throw new Error( `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, ); @@ -1193,6 +1216,13 @@ export class AgentManager { undefined, options?.workspaceId, ); + await this.requireWorkspaceProviderAvailable( + client, + storedConfig.provider, + storedConfig.cwd, + options?.workspaceId, + launchContext, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( handle, @@ -1228,7 +1258,7 @@ export class AgentManager { const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession"); this.requireEnabledProvider(input.provider); - const client = await this.requireAvailableClient({ provider: input.provider }); + const client = this.requireClient(input.provider); if (!client.importSession) { throw new Error(`Provider '${input.provider}' does not support importing sessions`); } @@ -1247,6 +1277,13 @@ export class AgentManager { undefined, input.workspaceId, ); + await this.requireWorkspaceProviderAvailable( + client, + input.provider, + storedConfig.cwd, + input.workspaceId, + launchContext, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { @@ -1574,7 +1611,7 @@ export class AgentManager { await registry.upsert(archivedRecord); - await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + await this.archiveNativeSessionBestEffort(record); if (this.agents.has(record.id)) { this.notifyAgentState(record.id); @@ -1851,7 +1888,7 @@ export class AgentManager { const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); await registry.upsert(nextRecord); - await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + await this.archiveNativeSessionBestEffort(record); if (this.agents.has(agentId)) { this.notifyAgentState(agentId); @@ -1878,7 +1915,7 @@ export class AgentManager { return false; } - await this.unarchiveNativeSession(record.provider, record.persistence); + await this.unarchiveNativeSession(record); await registry.upsert({ ...record, @@ -4519,8 +4556,15 @@ export class AgentManager { PASEO_AGENT_CWD: cwd, }, }; - if (workspaceId && this.resolveWorkspaceExecution) { - context.workspaceExecution = await this.resolveWorkspaceExecution(workspaceId, cwd); + if (workspaceId) { + if (!this.resolveProviderWorkspace) { + throw new Error(`workspace runtime capability is unavailable: ${workspaceId}`); + } + const workspace = await this.resolveProviderWorkspace(workspaceId, cwd); + if (workspace === undefined) { + throw new Error(`workspace runtime capability is unavailable: ${workspaceId}`); + } + if (workspace) context.workspace = workspace; } if ( this.paseoToolsEnabled && @@ -4539,6 +4583,27 @@ export class AgentManager { return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; } + private async requireWorkspaceProviderAvailable( + client: AgentClient, + provider: AgentProvider, + cwd: string, + workspaceId: string | undefined, + launchContext: AgentLaunchContext, + ): Promise { + if (!workspaceId) return; + if (!launchContext.workspace) return; + const available = await client.isAvailable({ + scope: "workspace", + cwd, + workspaceId, + workspace: launchContext.workspace, + force: false, + }); + if (!available) { + throw new Error(`Provider '${provider}' is not available in the selected workspace runtime.`); + } + } + private async requireAvailableClient(options: { provider: AgentProvider }): Promise { const client = this.clients.get(options.provider); if (!client) { @@ -4588,15 +4653,20 @@ export class AgentManager { return client; } - async archiveNativeSessionBestEffort( - provider: AgentProvider, - persistence: AgentPersistenceHandle | null | undefined, - ): Promise { + async archiveNativeSessionBestEffort(record: StoredAgentRecord): Promise { + const { provider, persistence } = record; if (!persistence) return; const client = this.clients.get(provider); if (!client?.archiveNativeSession) return; try { - await client.archiveNativeSession(persistence); + const launchContext = await this.buildLaunchContext( + record.id, + client, + record.cwd, + undefined, + record.workspaceId, + ); + await client.archiveNativeSession(persistence, launchContext); } catch (error) { this.logger.warn( { error, provider, sessionId: persistence.sessionId }, @@ -4605,14 +4675,19 @@ export class AgentManager { } } - private async unarchiveNativeSession( - provider: AgentProvider, - persistence: AgentPersistenceHandle | null | undefined, - ): Promise { + private async unarchiveNativeSession(record: StoredAgentRecord): Promise { + const { provider, persistence } = record; if (!persistence) return; const client = this.clients.get(provider); if (!client?.unarchiveNativeSession) return; - await client.unarchiveNativeSession(persistence); + const launchContext = await this.buildLaunchContext( + record.id, + client, + record.cwd, + undefined, + record.workspaceId, + ); + await client.unarchiveNativeSession(persistence, launchContext); } private requireAgent(id: string): LiveManagedAgent { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7add5cd02c..f3a675f9a7 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -5,9 +5,10 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; -import type { WorkspaceProcess, WorkspaceProcessInput } from "../workspace-runtime/index.js"; +import type { ProviderWorkspace } from "./providers/workspace/index.js"; export type { AgentProviderNotice }; +export type { ProviderWorkspace } from "./providers/workspace/index.js"; export type AgentProvider = string; @@ -522,6 +523,7 @@ export interface AgentSlashCommand { export interface ListImportableSessionsOptions { limit?: number; + workspace?: ProviderWorkspace; /** * Optional cwd hint. Providers that can cheaply pre-filter importable * sessions by working directory should do so before doing expensive work. @@ -598,9 +600,7 @@ export interface AgentLaunchContext { * AgentSessionConfig; providers may adapt it to their native tool surface. */ paseoTools?: PaseoToolCatalog; - workspaceExecution?: { - run(input: Omit): Promise; - }; + workspace?: ProviderWorkspace; } export interface AgentCreateSessionOptions { @@ -676,6 +676,8 @@ export type FetchCatalogOptions = | { scope: "workspace"; cwd: string; + workspaceId?: string; + workspace?: ProviderWorkspace; force: boolean; timeoutMs?: number; }; @@ -730,18 +732,24 @@ export interface AgentClient { * Check if this provider is available (CLI binary is installed). * Returns true if available, false otherwise. */ - isAvailable(): Promise; + isAvailable(options?: FetchCatalogOptions): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; /** * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). * Called when Paseo archives an agent so the provider's own UI reflects the same state. */ - archiveNativeSession?(handle: AgentPersistenceHandle): Promise; + archiveNativeSession?( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise; /** * Unarchive a durable native session in the provider. * Called before Paseo clears its archived flag so provider resume can succeed. */ - unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise; + unarchiveNativeSession?( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise; /** * Release any provider-owned resources held by this client (background * processes, sockets, cached subprocesses, etc.). Called when the daemon diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index e8547d27ed..96bb4f5965 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -18,6 +18,7 @@ function createRealAgentManager(storage: AgentStorage): AgentManager { return new AgentManager({ clients: createTestAgentClients(), registry: storage, + resolveProviderWorkspace: async () => null, logger, }); } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index b31a858f34..17dc689119 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -253,6 +253,7 @@ async function resolveSessionCreateAgent( // introduced by this validation). const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: builtSessionConfig.cwd, + workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, provider: builtSessionConfig.provider, requestedMode: builtSessionConfig.modeId, featureValues: builtSessionConfig.featureValues, @@ -339,6 +340,7 @@ async function resolveMcpCreateAgent( input, provider, resolvedCwd, + workspaceId: intent.workspaceId, parentAgent, }); @@ -387,11 +389,13 @@ async function resolveMcpProviderCreateConfig(params: { input: CreateAgentFromMcpInput; provider: string; resolvedCwd: string; + workspaceId: string; parentAgent: ManagedAgent | null; }): Promise<{ modeId?: string; featureValues?: Record }> { const passthroughConfig = params.input.config; return params.dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: params.resolvedCwd, + workspaceId: params.workspaceId, provider: params.provider, requestedMode: params.input.mode ?? passthroughConfig?.modeId, featureValues: params.input.features ?? passthroughConfig?.featureValues, diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index b1281c27bb..092c2f2cd9 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -132,6 +132,7 @@ export async function listImportableProviderSessions( limit: limit + importedSessions.count, providerFilter, cwd: request.cwd, + workspaceId: request.workspaceId, }); let filteredAlreadyImportedCount = 0; const candidates: ManagedImportableProviderSession[] = []; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b2c0062783..63c14e0e7a 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -9,6 +9,7 @@ import type { AgentModelDefinition, AgentProvider, FetchCatalogOptions, + ProviderWorkspace, ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; @@ -29,6 +30,16 @@ const TEST_CAPABILITIES = { } as const; const TEST_REFRESH_TIMEOUT_MS = 120_000; +function deferred() { + let complete!: (value: T) => void; + let fail!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + complete = resolvePromise; + fail = rejectPromise; + }); + return { promise, resolve: complete, reject: fail }; +} + // Builds an AgentClient that can be injected via the public extraClients option. // extraClients is the only injection surface the manager exposes for tests. function createExtraClient( @@ -700,51 +711,9 @@ describe("ProviderSnapshotManager public surface", () => { }); test("getProviderDiagnostic starts provider diagnostics before waiting for snapshot refresh", async () => { - vi.useFakeTimers(); - let diagnosticStarted = false; - const manager = new ProviderSnapshotManager({ - logger: createTestLogger(), - refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, - extraClients: { - codex: createExtraClient("codex", { - isAvailable: async () => true, - fetchCatalog: async () => new Promise(() => {}), - getDiagnostic: async () => { - diagnosticStarted = true; - return { diagnostic: "codex diagnostics available" }; - }, - }), - }, - }); - try { - const diagnosticRequest = manager.getProviderDiagnostic("codex"); - expect(diagnosticStarted).toBe(true); - - const diagnosticOrBlocked = Promise.race([ - diagnosticRequest.then(() => ({ type: "diagnostic" as const })), - new Promise<{ type: "blocked" }>((finish) => { - setTimeout(() => finish({ type: "blocked" }), 1); - }), - ]); - await vi.advanceTimersByTimeAsync(1); - await expect(diagnosticOrBlocked).resolves.toEqual({ type: "blocked" }); - - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS - 1); - const result = await diagnosticRequest; - expect(result.diagnostic).toContain("codex diagnostics available"); - expect(result.diagnostic).toContain( - `Status: Error: Timed out refreshing Codex after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); - } finally { - manager.destroy(); - vi.useRealTimers(); - } - }); - - test("getProviderDiagnostic starts snapshot refresh even when provider diagnostics hang", async () => { - vi.useFakeTimers(); - let diagnosticStarted = false; - let snapshotStarted = false; + const diagnosticStarted = deferred(); + const catalogStarted = deferred(); + const catalog = deferred<{ models: AgentModelDefinition[]; modes: AgentMode[] }>(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, @@ -752,42 +721,71 @@ describe("ProviderSnapshotManager public surface", () => { codex: createExtraClient("codex", { isAvailable: async () => true, fetchCatalog: async () => { - snapshotStarted = true; - return new Promise(() => {}); + catalogStarted.resolve(); + return catalog.promise; }, getDiagnostic: async () => { - diagnosticStarted = true; - return new Promise(() => {}); + diagnosticStarted.resolve(); + return { diagnostic: "codex diagnostics available" }; }, }), }, }); try { const diagnosticRequest = manager.getProviderDiagnostic("codex"); - await vi.advanceTimersByTimeAsync(0); - - expect(diagnosticStarted).toBe(true); - expect(snapshotStarted).toBe(true); - - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS); + await Promise.all([diagnosticStarted.promise, catalogStarted.promise]); + catalog.resolve({ + models: [{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }], + modes: [], + }); const result = await diagnosticRequest; - expect(result.diagnostic).toContain( - `Error: Timed out collecting Codex diagnostic after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); - expect(result.diagnostic).toContain( - `Status: Error: Timed out refreshing Codex after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); + expect(result.diagnostic).toContain("codex diagnostics available"); + expect(result.diagnostic).toContain("Status: Ready"); + } finally { + manager.destroy(); + } + }); + + test("getProviderDiagnostic starts snapshot refresh even when provider diagnostics hang", async () => { + const diagnosticStarted = deferred(); + const snapshotStarted = deferred(); + const diagnostic = deferred<{ diagnostic: string }>(); + const catalog = deferred<{ models: AgentModelDefinition[]; modes: AgentMode[] }>(); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => { + snapshotStarted.resolve(); + return catalog.promise; + }, + getDiagnostic: async () => { + diagnosticStarted.resolve(); + return diagnostic.promise; + }, + }), + }, + }); + try { + const diagnosticRequest = manager.getProviderDiagnostic("codex"); + await Promise.all([diagnosticStarted.promise, snapshotStarted.promise]); + catalog.resolve({ models: [], modes: [] }); + diagnostic.resolve({ diagnostic: "barrier diagnostic" }); + const result = await diagnosticRequest; + expect(result.diagnostic).toContain("barrier diagnostic"); + expect(result.diagnostic).toContain("Status: Ready"); } finally { manager.destroy(); - vi.useRealTimers(); } }); test("getProviderDiagnostic reports provider diagnostic timeout while preserving snapshot details", async () => { - vi.useFakeTimers(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, + refreshTimeoutMs: 1_000, + diagnosticTimeoutMs: 1, extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true, @@ -800,43 +798,32 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const diagnosticRequest = manager.getProviderDiagnostic("codex"); - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS); - - const result = await diagnosticRequest; - expect(result.diagnostic).toContain( - `Error: Timed out collecting Codex diagnostic after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); + const result = await manager.getProviderDiagnostic("codex"); + expect(result.diagnostic).toContain("Error: Timed out collecting Codex diagnostic after 1ms"); expect(result.diagnostic).toContain("Models: 1"); expect(result.diagnostic).toContain("Status: Ready"); } finally { manager.destroy(); - vi.useRealTimers(); } }); test("getProviderDiagnostic reports a stuck catalog refresh inside the diagnostic", async () => { await withEnv("PASEO_ENABLE_MOCK_SLOW", "true", async () => { - vi.useFakeTimers(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), isDev: true, - refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, + refreshTimeoutMs: 1, }); try { - const diagnosticRequest = manager.getProviderDiagnostic("mock-slow"); - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS); - - const result = await diagnosticRequest; + const result = await manager.getProviderDiagnostic("mock-slow"); expect(result.provider).toBe("mock-slow"); expect(result.diagnostic).toContain("Mock slow provider"); expect(result.diagnostic).toContain("Models: —"); expect(result.diagnostic).toContain( - `Status: Error: Timed out refreshing Mock Slow Provider after ${TEST_REFRESH_TIMEOUT_MS}ms`, + "Status: Error: Timed out refreshing Mock Slow Provider after 1ms", ); } finally { manager.destroy(); - vi.useRealTimers(); } }); }); @@ -1235,6 +1222,75 @@ describe("ProviderSnapshotManager lifecycle", () => { }); describe("ProviderSnapshotManager cwd routing", () => { + test("keys selected workspace probes by workspace id and runtime reconstruction", async () => { + const workspaces = new Map([ + ["workspace-a", createProviderWorkspace()], + ["workspace-b", createProviderWorkspace()], + ]); + const probes: string[] = []; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + resolveProviderWorkspace: async (workspaceId) => workspaces.get(workspaceId) ?? null, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async (options) => { + if (options.scope !== "workspace" || !options.workspaceId || !options.workspace) { + throw new Error("Expected a bound provider workspace"); + } + probes.push(options.workspaceId); + return { + models: [{ provider: "codex", id: options.workspaceId, label: options.workspaceId }], + modes: [], + }; + }, + }), + }, + }); + + try { + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-a", + providers: ["codex"], + }); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-b", + providers: ["codex"], + }); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-a", + providers: ["codex"], + }); + expect(probes).toEqual(["workspace-a", "workspace-b"]); + expect( + manager.getSnapshot("/same/cwd", "workspace-a").find((entry) => entry.provider === "codex") + ?.models?.[0]?.id, + ).toBe("workspace-a"); + expect( + manager.getSnapshot("/same/cwd", "workspace-b").find((entry) => entry.provider === "codex") + ?.models?.[0]?.id, + ).toBe("workspace-b"); + + workspaces.set("workspace-a", createProviderWorkspace()); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-a", + providers: ["codex"], + }); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-b", + providers: ["codex"], + }); + expect(probes).toEqual(["workspace-a", "workspace-b", "workspace-a"]); + } finally { + manager.destroy(); + } + }); + test("settings refresh passes the semantic global scope to providers", async () => { const fetchCatalog = vi.fn(async () => ({ models: [] as AgentModelDefinition[], @@ -1344,3 +1400,45 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); }); + +function createProviderWorkspace(): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable() { + throw new Error("not used"); + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState() { + return { entries: [] }; + }, + async readStateText() { + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 7b9033bf24..a0f1342774 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -15,6 +15,7 @@ import { type AgentProvider, type FetchCatalogOptions, type ProviderSnapshotEntry, + type ProviderWorkspace, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; @@ -87,7 +88,11 @@ function omitProviderOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } -type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; +type ProviderSnapshotChangeListener = ( + entries: ProviderSnapshotEntry[], + cwd: string, + workspaceId?: string, +) => void; export interface ProviderSnapshotManagerOptions { logger: Logger; @@ -99,36 +104,47 @@ export interface ProviderSnapshotManagerOptions { extraClients?: Partial>; refreshTimeoutMs?: number; diagnosticTimeoutMs?: number; + resolveProviderWorkspace?: (workspaceId: string) => Promise; } interface ProviderSnapshotRefreshOptions { cwd: string; + workspaceId?: string; providers?: AgentProvider[]; } interface ProviderSnapshotWarmUpOptions { cwd?: string | null; + workspaceId?: string; providers?: AgentProvider[]; } interface ProviderSnapshotReadOptions { cwd?: string | null; + workspaceId?: string; providers?: AgentProvider[]; wait?: boolean; } +interface ProviderSnapshotRequestOptions { + cwd?: string | null; + workspaceId?: string; +} + interface ApplyMutableProviderConfigOptions { removeProviders?: readonly string[]; } interface ProviderSnapshotProviderOptions { cwd?: string | null; + workspaceId?: string; provider: AgentProvider; wait?: boolean; } export interface ResolveProviderCreateConfigOptions { cwd?: string | null; + workspaceId?: string; provider: AgentProvider; requestedMode: string | undefined; featureValues: Record | undefined; @@ -175,7 +191,14 @@ interface ProviderLoad { promise: Promise; } -type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string }; +type ProviderCatalogScope = + | { scope: "global" } + | { + scope: "workspace"; + cwd: string; + workspaceId?: string; + workspace?: ProviderWorkspace; + }; interface ProviderSnapshotTarget { snapshotCwd: string; @@ -194,6 +217,13 @@ export class ProviderSnapshotManager { private readonly managedProcesses?: ManagedProcessRegistry; private readonly isDev: boolean; private readonly extraClients: Partial>; + private readonly resolveProviderWorkspace?: ProviderSnapshotManagerOptions["resolveProviderWorkspace"]; + private readonly workspaceSnapshotKeys = new Map(); + private readonly workspaceTargets = new Map(); + private readonly snapshotDisplayCwds = new Map(); + private readonly snapshotWorkspaceIds = new Map(); + private readonly runtimeTokens = new WeakMap(); + private nextRuntimeToken = 0; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private baseProviderOverrides: Record | undefined; @@ -206,6 +236,7 @@ export class ProviderSnapshotManager { this.managedProcesses = options.managedProcesses; this.isDev = options.isDev === true; this.extraClients = options.extraClients ?? {}; + this.resolveProviderWorkspace = options.resolveProviderWorkspace; this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; @@ -218,17 +249,41 @@ export class ProviderSnapshotManager { this.providerClients = { ...this.extraClients } as Record; } - getSnapshot(cwd?: string): ProviderSnapshotEntry[] { - const target = resolveProviderSnapshotTarget(cwd); + getSnapshot(cwd?: string, workspaceId?: string): ProviderSnapshotEntry[] { + const target = workspaceId + ? (this.workspaceTargets.get(workspaceId) ?? + createWorkspaceSnapshotTarget(resolveSnapshotCwd(cwd))) + : resolveProviderSnapshotTarget(cwd); return this.getSnapshotForTarget(target); } + async readSnapshot(options: ProviderSnapshotRequestOptions): Promise { + try { + await this.warmUpSnapshotForCwd(options); + return this.getSnapshot(options.cwd ?? undefined, options.workspaceId); + } catch (error) { + if (!options.workspaceId) throw error; + const message = error instanceof Error ? error.message : String(error); + const unavailable: ProviderSnapshotEntry[] = []; + for (const entry of entriesToArray( + this.getOrCreateSnapshot(`workspace-unavailable:${options.workspaceId}`), + )) { + unavailable.push( + entry.enabled + ? { ...entry, status: "error", error: message } + : { ...entry, status: "unavailable" }, + ); + } + return unavailable; + } + } + async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { const snapshotCwd = resolveSnapshotCwd(options.cwd); - const target = createWorkspaceSnapshotTarget(snapshotCwd); + const target = await this.createWorkspaceTarget(snapshotCwd, options.workspaceId); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); - this.emitChange(snapshotCwd); + this.resetSnapshotToLoading(target.snapshotCwd, providers, { preserveExisting: false }); + this.emitChange(target.snapshotCwd); await this.refreshProviders(target, providers ?? this.getProviderIds()); } @@ -247,7 +302,9 @@ export class ProviderSnapshotManager { } async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise { - const target = resolveProviderSnapshotTarget(options.cwd); + const target = options.workspaceId + ? await this.createWorkspaceTarget(resolveSnapshotCwd(options.cwd), options.workspaceId) + : resolveProviderSnapshotTarget(options.cwd); const snapshotCwd = target.snapshotCwd; const providers = this.resolveRefreshProviders(options.providers); if (options.providers && providers?.length === 0) { @@ -311,10 +368,17 @@ export class ProviderSnapshotManager { } async listProviders(input: ProviderSnapshotReadOptions = {}): Promise { - const target = resolveProviderSnapshotTarget(input.cwd); if (input.wait) { - await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers }); + await this.warmUpSnapshotForCwd({ + cwd: input.cwd, + workspaceId: input.workspaceId, + providers: input.providers, + }); } + const target = input.workspaceId + ? (this.workspaceTargets.get(input.workspaceId) ?? + createWorkspaceSnapshotTarget(resolveSnapshotCwd(input.cwd))) + : resolveProviderSnapshotTarget(input.cwd); const providerFilter = input.providers ? new Set(input.providers) : null; const entries = this.getSnapshotForTarget(target); return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; @@ -403,6 +467,7 @@ export class ProviderSnapshotManager { ): Promise { const entry = await this.getReadyProvider({ cwd: input.cwd, + workspaceId: input.workspaceId, provider: input.provider, wait: true, }); @@ -834,8 +899,9 @@ export class ProviderSnapshotManager { } const client = this.ensureClient(provider, definition); + const catalogOptions = createFetchCatalogOptions(catalogScope, force); const available = await withTimeout( - client.isAvailable(), + client.isAvailable(catalogOptions), this.refreshTimeoutMs, `Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`, ); @@ -844,7 +910,6 @@ export class ProviderSnapshotManager { return; } - const catalogOptions = createFetchCatalogOptions(catalogScope, force); const catalog = await withTimeout( definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client), this.refreshTimeoutMs, @@ -862,6 +927,10 @@ export class ProviderSnapshotManager { fetchedAt: new Date().toISOString(), }); } catch (error) { + if (isProviderCommandUnavailableError(error)) { + setEntry({ ...base, status: "unavailable", enabled: true }); + return; + } const emitted = setEntry({ ...base, status: "error", @@ -877,6 +946,42 @@ export class ProviderSnapshotManager { } } + private async createWorkspaceTarget( + cwd: string, + workspaceId?: string, + ): Promise { + if (!workspaceId) return createWorkspaceSnapshotTarget(cwd); + if (!this.resolveProviderWorkspace) { + throw new Error(`Provider workspace resolver is unavailable: ${workspaceId}`); + } + const workspace = await this.resolveProviderWorkspace(workspaceId); + if (workspace === undefined) { + throw new Error(`Workspace runtime capability is unavailable: ${workspaceId}`); + } + if (workspace === null) return createWorkspaceSnapshotTarget(cwd); + let token = this.runtimeTokens.get(workspace); + if (!token) { + token = ++this.nextRuntimeToken; + this.runtimeTokens.set(workspace, token); + } + const snapshotKey = `workspace:${workspaceId}:${token}`; + const previous = this.workspaceSnapshotKeys.get(workspaceId); + if (previous && previous !== snapshotKey) { + this.snapshots.delete(previous); + this.providerLoads.delete(previous); + this.snapshotWorkspaceIds.delete(previous); + } + this.workspaceSnapshotKeys.set(workspaceId, snapshotKey); + const target = { + snapshotCwd: snapshotKey, + catalogScope: { scope: "workspace", cwd, workspaceId, workspace }, + } satisfies ProviderSnapshotTarget; + this.workspaceTargets.set(workspaceId, target); + this.snapshotDisplayCwds.set(snapshotKey, cwd); + this.snapshotWorkspaceIds.set(snapshotKey, workspaceId); + return target; + } + private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined { return this.providerLoads.get(cwdKey)?.get(provider); } @@ -906,7 +1011,12 @@ export class ProviderSnapshotManager { if (!snapshot) { return; } - this.events.emit("change", entriesToArray(snapshot), cwdKey); + this.events.emit( + "change", + entriesToArray(snapshot), + this.snapshotDisplayCwds.get(cwdKey) ?? cwdKey, + this.snapshotWorkspaceIds.get(cwdKey), + ); } private getOrCreateSnapshot(cwdKey: string): Map { @@ -969,6 +1079,14 @@ export class ProviderSnapshotManager { } } +function isProviderCommandUnavailableError(error: unknown): boolean { + const message = toErrorMessage(error); + return ( + /^Provider command '.+' was not found in the workspace$/u.test(message) || + / command '.+' not found$/u.test(message) + ); +} + export function resolveSnapshotCwd(cwd?: string | null): string { const trimmed = cwd?.trim(); if (!trimmed) { @@ -1015,7 +1133,13 @@ function createFetchCatalogOptions( ): FetchCatalogOptions { return scope.scope === "global" ? { scope: "global", force } - : { scope: "workspace", cwd: scope.cwd, force }; + : { + scope: "workspace", + cwd: scope.cwd, + workspaceId: scope.workspaceId, + workspace: scope.workspace, + force, + }; } export function isGlobalProviderSnapshotKey(cwd: string): boolean { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 09ca53ac28..3b16406849 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -91,12 +91,12 @@ import { type ListImportableSessionsOptions, type McpServerConfig, type ProviderCatalog, + type ProviderWorkspace, type ResolveAgentCreateConfigInput, type ResolveAgentCreateConfigResult, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; -import type { WorkspaceProcess } from "../../workspace-runtime/index.js"; import { isDefaultAgentCreateConfigUnattended, resolveDefaultAgentCreateConfig, @@ -104,7 +104,6 @@ import { import { importSessionFromPersistence } from "../provider-session-import.js"; import { checkProviderLaunchAvailable, - createProviderEnv, createProviderEnvSpec, resolveProviderLaunch, type ProviderRuntimeSettings, @@ -122,6 +121,12 @@ import { truncateForDiagnostic, } from "./diagnostic-utils.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; +import { + providerWorkspaceEnvironment, + providerWorkspaceFromCatalogOptions, + resolveWorkspaceCommand, + spawnWorkspaceProviderProcess, +} from "./workspace/index.js"; const ACP_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; @@ -463,7 +468,7 @@ interface ACPAgentSessionOptions { handle?: AgentPersistenceHandle; agentId?: string; launchEnv?: Record; - workspaceExecution?: AgentLaunchContext["workspaceExecution"]; + workspace?: ProviderWorkspace; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; terminateProcess?: ProcessTerminator; @@ -531,29 +536,6 @@ interface TerminalEntry { rejectExit: (error: Error) => void; } -async function terminateWorkspaceProcess(process: WorkspaceProcess): Promise { - process.kill("SIGTERM"); - if (await exitsWithin(process.exited, 2_000)) return; - process.kill("SIGKILL"); - await process.exited; -} - -async function exitsWithin(exit: Promise, timeoutMs: number): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(false), timeoutMs); - }); - const result = await Promise.race([exit.then(() => true as const), timeout]); - if (timer) clearTimeout(timer); - return result; -} - -function toExactEnvironment(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); -} - export interface ConfigOptionSelector { id: string; label: string; @@ -880,7 +862,11 @@ export class ACPAgentClient implements AgentClient { ): Promise { this.assertProvider(config); const session = new ACPAgentSession( - { ...config, provider: this.provider }, + { + ...config, + provider: this.provider, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + }, { provider: this.provider, logger: this.logger, @@ -901,7 +887,7 @@ export class ACPAgentClient implements AgentClient { capabilities: this.capabilities, agentId: launchContext?.agentId, launchEnv: launchContext?.env, - workspaceExecution: launchContext?.workspaceExecution, + workspace: launchContext?.workspace, extensionCommandsParser: this.extensionCommandsParser, waitForInitialCommands: this.waitForInitialCommands, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, @@ -932,6 +918,7 @@ export class ACPAgentClient implements AgentClient { provider: this.provider, cwd, }; + if (launchContext?.workspace) mergedConfig.cwd = launchContext.workspace.cwd; const session = new ACPAgentSession(mergedConfig, { provider: this.provider, logger: this.logger, @@ -953,7 +940,7 @@ export class ACPAgentClient implements AgentClient { handle, agentId: launchContext?.agentId, launchEnv: launchContext?.env, - workspaceExecution: launchContext?.workspaceExecution, + workspace: launchContext?.workspace, extensionCommandsParser: this.extensionCommandsParser, waitForInitialCommands: this.waitForInitialCommands, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, @@ -963,12 +950,14 @@ export class ACPAgentClient implements AgentClient { } async fetchCatalog(options: FetchCatalogOptions): Promise { - const cwd = options.scope === "global" ? homedir() : options.cwd; + const workspace = providerWorkspaceFromCatalogOptions(options); + const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd); const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS; let probe: UninitializedACPProcess | null = null; try { const catalogProbe = (async () => { const initializedProbe = await this.spawnProcess(PROBE_ENV, { + workspace, initializeTimeoutMs: timeoutMs, onSpawned: (spawned) => { probe = spawned; @@ -1053,7 +1042,7 @@ export class ACPAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const probe = await this.spawnProcess(PROBE_ENV); + const probe = await this.spawnProcess(PROBE_ENV, { workspace: options?.workspace }); try { if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { return []; @@ -1068,7 +1057,7 @@ export class ACPAgentClient implements AgentClient { // Filter by working directory at the source. Without this the agent // returns globally-recent sessions, which the `limit` below can // truncate before the current directory's sessions are reached. - ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(options?.cwd ? { cwd: options.workspace?.cwd ?? options.cwd } : {}), }), ); for (const session of page.sessions) { @@ -1101,9 +1090,10 @@ export class ACPAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { try { - await this.resolveLaunchCommand(); + const workspace = options ? providerWorkspaceFromCatalogOptions(options) : undefined; + await this.resolveLaunchCommand(workspace); return true; } catch { return false; @@ -1115,9 +1105,10 @@ export class ACPAgentClient implements AgentClient { options?: { initializeTimeoutMs?: number; onSpawned?: (probe: UninitializedACPProcess) => void; + workspace?: ProviderWorkspace; }, ): Promise { - const transport = await this.spawnTransport(launchEnv); + const transport = await this.spawnTransport(launchEnv, options?.workspace); const probe: UninitializedACPProcess = { child: transport.child, connection: transport.connection, @@ -1138,8 +1129,20 @@ export class ACPAgentClient implements AgentClient { } } - protected async spawnTransport(launchEnv?: Record): Promise { - const { command, args } = await this.resolveLaunchCommand(); + protected async spawnTransport( + launchEnv?: Record, + workspace?: ProviderWorkspace, + ): Promise { + const { command, args } = await this.resolveLaunchCommand(workspace); + if (workspace) { + const child = await spawnWorkspaceProviderProcess({ + workspace, + argv: [command, ...args], + env: providerWorkspaceEnvironment([this.runtimeSettings?.env, launchEnv]), + purpose: { kind: "provider-probe", provider: this.provider }, + }); + return this.createTransport(child, workspace); + } const child = spawnProcess(command, args, { cwd: process.cwd(), ...createProviderEnvSpec({ @@ -1149,7 +1152,13 @@ export class ACPAgentClient implements AgentClient { stdio: ["pipe", "pipe", "pipe"], }); assertChildWithPipes(child); + return this.createTransport(child); + } + private createTransport( + child: ChildProcessWithoutNullStreams, + workspace?: ProviderWorkspace, + ): ACPProcessTransport { const stderrChunks: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); @@ -1172,7 +1181,7 @@ export class ACPAgentClient implements AgentClient { Readable.toWeb(child.stdout), { logger: this.logger, provider: this.provider }, ); - const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); + const connection = new ClientSideConnection(() => this.buildProbeClient(workspace), stream); return { child, @@ -1218,17 +1227,22 @@ export class ACPAgentClient implements AgentClient { } } - protected buildProbeClient(): ACPClient { + protected buildProbeClient(workspace?: ProviderWorkspace): ACPClient { return { async requestPermission(): Promise { return { outcome: { outcome: "cancelled" } }; }, async sessionUpdate(): Promise {}, async readTextFile(params: ReadTextFileRequest) { + if (workspace) return { content: await readWorkspaceTextFile(workspace, params.path) }; const content = await fs.readFile(params.path, "utf8"); return { content }; }, async writeTextFile(params: WriteTextFileRequest) { + if (workspace) { + await writeWorkspaceTextFile(workspace, params.path, params.content); + return {}; + } await fs.mkdir(path.dirname(params.path), { recursive: true }); await fs.writeFile(params.path, params.content, "utf8"); return {}; @@ -1365,17 +1379,19 @@ export class ACPAgentClient implements AgentClient { } } - protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { + protected async resolveLaunchCommand( + workspace?: ProviderWorkspace, + ): Promise<{ command: string; args: string[] }> { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, defaultBinary: this.defaultCommand[0], }); - const availability = await checkProviderLaunchAvailable(prefix); - if (!availability.available) { - throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); - } + const resolvedCommand = workspace + ? await resolveWorkspaceCommand(workspace, prefix.command) + : (await checkProviderLaunchAvailable(prefix)).resolvedPath; + if (!resolvedCommand) throw new Error(`${this.provider} command '${prefix.command}' not found`); return { - command: prefix.command, + command: resolvedCommand, args: [...prefix.args, ...this.defaultCommand.slice(1)], }; } @@ -1433,7 +1449,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { ) => Promise; private readonly agentId?: string; private readonly launchEnv?: Record; - private readonly workspaceExecution?: AgentLaunchContext["workspaceExecution"]; + private readonly workspace?: ProviderWorkspace; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly pendingPermissions = new Map(); private pendingUserMessage: PendingUserMessage | null = null; @@ -1493,7 +1509,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.availableModes = options.defaultModes; this.agentId = options.agentId; this.launchEnv = options.launchEnv; - this.workspaceExecution = options.workspaceExecution; + this.workspace = options.workspace; this.initialHandle = options.handle; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; @@ -2372,7 +2388,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { } async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { - const raw = await fs.readFile(params.path, "utf8"); + const raw = this.workspace + ? await readWorkspaceTextFile(this.workspace, params.path) + : await fs.readFile(params.path, "utf8"); if (!params.line && !params.limit) { return { content: raw }; } @@ -2383,6 +2401,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { } async writeTextFile(params: WriteTextFileRequest): Promise> { + if (this.workspace) { + await writeWorkspaceTextFile(this.workspace, params.path, params.content); + return {}; + } await fs.mkdir(path.dirname(params.path), { recursive: true }); await fs.writeFile(params.path, params.content, "utf8"); return {}; @@ -2421,39 +2443,34 @@ export class ACPAgentSession implements AgentSession, ACPClient { rejectExit, }; - if (this.workspaceExecution) { + if (this.workspace) { const relativeCwd = path.relative(this.config.cwd, cwd); if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { throw new Error(`ACP terminal cwd escapes workspace: ${cwd}`); } - const runtimeProcess = await this.workspaceExecution.run({ + const child = await this.workspace.launch({ cwd: relativeCwd || undefined, argv: [terminalCommand.command, ...terminalCommand.args], - env: toExactEnvironment( - createProviderEnv({ - runtimeSettings: this.runtimeSettings, - overlays: commandEnvOverlays, - }), - ), + environment: [this.runtimeSettings?.env, ...commandEnvOverlays], purpose: { kind: "terminal", terminalId }, }); const entry: TerminalEntry = { ...entryBase, - terminate: () => terminateWorkspaceProcess(runtimeProcess), + terminate: () => terminateChildProcess(child, 2_000, this.terminateProcess), }; - runtimeProcess.stdout.on("data", (chunk: Buffer | string) => + child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()), ); - runtimeProcess.stderr.on("data", (chunk: Buffer | string) => + child.stderr.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()), ); - void runtimeProcess.exited.then(({ code, signal }) => { + child.once("error", rejectExit); + child.once("exit", (code, signal) => { const exit = { exitCode: code, signal }; entry.exit = exit; resolveExit(exit); - return undefined; - }, rejectExit); - runtimeProcess.stdin.end(); + }); + child.stdin.end(); this.terminalEntries.set(terminalId, entry); return { terminalId }; } @@ -2530,13 +2547,24 @@ export class ACPAgentSession implements AgentSession, ACPClient { commandConfig: this.runtimeSettings?.command, defaultBinary: this.defaultCommand[0], }); - const availability = await checkProviderLaunchAvailable(prefix); - if (!availability.available) { - throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); - } - - const command = prefix.command; + const command = this.workspace + ? await resolveWorkspaceCommand(this.workspace, prefix.command) + : (await checkProviderLaunchAvailable(prefix)).resolvedPath; + if (!command) throw new Error(`${this.provider} command '${prefix.command}' not found`); const args = [...prefix.args, ...this.defaultCommand.slice(1)]; + if (this.workspace) { + const child = await spawnWorkspaceProviderProcess({ + workspace: this.workspace, + argv: [command, ...args], + env: providerWorkspaceEnvironment([this.runtimeSettings?.env, this.launchEnv]), + purpose: { + kind: "agent", + agentId: this.agentId ?? "unidentified-agent", + provider: this.provider, + }, + }); + return await this.initializeSpawnedChild(child); + } const child = spawnProcess(command, args, { cwd: this.config.cwd, ...createProviderEnvSpec({ @@ -2546,7 +2574,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { stdio: ["pipe", "pipe", "pipe"], }); assertChildWithPipes(child); + return await this.initializeSpawnedChild(child); + } + private async initializeSpawnedChild( + child: ChildProcessWithoutNullStreams, + ): Promise { const stderrChunks: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); @@ -3737,3 +3770,30 @@ async function terminateChildProcess( child.stderr.destroy(); } } + +async function readWorkspaceTextFile( + workspace: ProviderWorkspace, + requestedPath: string, +): Promise { + return workspace.readWorkspaceText(toWorkspaceRelativePath(requestedPath)); +} + +async function writeWorkspaceTextFile( + workspace: ProviderWorkspace, + requestedPath: string, + content: string, +): Promise { + await workspace.writeWorkspaceText(toWorkspaceRelativePath(requestedPath), content); +} + +function toWorkspaceRelativePath(requestedPath: string): string { + const normalized = path.normalize(requestedPath); + if ( + path.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) + ) { + throw new Error(`ACP file path escapes the selected workspace: ${requestedPath}`); + } + return normalized; +} diff --git a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts index 41468e2f0d..b3ba943161 100644 --- a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts +++ b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts @@ -1,4 +1,5 @@ -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { chmod, copyFile, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -7,6 +8,13 @@ import { describe, expect, test } from "vitest"; import { createWorkspaceRuntimeService } from "../../workspace-runtime/index.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ACPAgentSession } from "./acp-agent.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import { bindProviderWorkspace } from "./workspace/index.js"; + +const fixtureAgent = new URL( + "../../test-utils/fixtures/workspace-runtime-acp-agent.mjs", + import.meta.url, +); const posixDescribe = describe.runIf(process.platform !== "win32"); @@ -48,9 +56,14 @@ posixDescribe("ACP workspace terminal execution", () => { supportsMcp: false, supportsSlashCommands: false, }, - workspaceExecution: { - run: (input) => runtime.run({ workspaceId: "acp-workspace", ...input }), - }, + workspace: bindProviderWorkspace({ + runtime: await runtime.bind("acp-workspace"), + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(["opencode"]), + }, + }), }, ); @@ -73,4 +86,95 @@ posixDescribe("ACP workspace terminal execution", () => { await rm(root, { recursive: true, force: true }); } }); + + test.each(["local", "worktree"] as const)( + "discovers and launches an ACP provider through the selected %s workspace", + async (selectedRuntimeId) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-provider-runtime-")); + const cwd = path.join(root, "workspace"); + await mkdir(cwd); + await copyFile(fixtureAgent, path.join(cwd, "fixture-agent.mjs")); + await chmod(path.join(cwd, "fixture-agent.mjs"), 0o755); + await writeFile(path.join(cwd, "committed.txt"), "before\n"); + execFileSync("git", ["init", "-b", "main"], { cwd }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd }); + execFileSync("git", ["add", "."], { cwd }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd }); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, persistedRuntimeId) => + runtimeIds.set(workspaceId, persistedRuntimeId), + }); + await service.create({ + workspaceId: "provider-workspace", + runtimeId: selectedRuntimeId, + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: + selectedRuntimeId === "local" + ? { kind: "existing" } + : { kind: "branch", branchName: "provider-worktree", baseRef: "main" }, + }); + const workspace = bindProviderWorkspace({ + runtime: await service.bind("provider-workspace"), + cwd: ".", + policy: { + environment: { + type: "inherit-sanitized-host", + hostEnvironment: { + ...process.env, + PASEO_DAEMON_ONLY_PROVIDER_ENV: "daemon-visible", + }, + }, + sharedHostProviders: new Set(["opencode"]), + }, + }); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: ["./fixture-agent.mjs"], + }); + + try { + await expect( + client.isAvailable({ + scope: "workspace", + cwd, + workspaceId: "provider-workspace", + workspace, + force: false, + }), + ).resolves.toBe(true); + await expect( + client.fetchCatalog({ + scope: "workspace", + cwd, + workspaceId: "provider-workspace", + workspace, + force: false, + timeoutMs: 2_000, + }), + ).resolves.toMatchObject({ models: [{ id: "fixture-model" }] }); + const session = await client.createSession( + { provider: "acp", cwd }, + { agentId: "fixture-agent", workspace }, + ); + await expect(session.run("runtime edit")).resolves.toMatchObject({ + finalText: "fixture completed: runtime edit", + }); + await expect(workspace.readWorkspaceText("stdio-agent-output.txt")).resolves.toBe( + "runtime edit\n", + ); + await expect(workspace.readWorkspaceText("stdio-agent-env.txt")).resolves.toBe( + "daemon-visible\n", + ); + await session.close(); + } finally { + await service.destroy("provider-workspace"); + await rm(root, { recursive: true, force: true }); + } + }, + 15_000, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts b/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts index 0ccc74b055..45cce2b613 100644 --- a/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; -import type { ChildProcess } from "node:child_process"; +import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process"; +import { PassThrough } from "node:stream"; import type { Options, Query, @@ -11,6 +12,8 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js"; import * as spawnUtils from "../../../../utils/spawn.js"; import { ClaudeAgentClient } from "./agent.js"; import type { ClaudeQueryInput } from "./query.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; +import type { ProviderWorkspaceLaunchInput } from "../workspace/index.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -36,6 +39,8 @@ function createQueryMock(events: unknown[]): Query { function createChildProcessStub(): ChildProcess { const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); child.stderr = new EventEmitter() as ChildProcess["stderr"]; return child; } @@ -102,4 +107,48 @@ describe("Claude spawn override", () => { const spawnOptions = claudeSpawnCall?.[2]; expect(spawnOptions?.shell).toBe(false); }); + + test("forwards the Claude SDK abort signal into selected workspace placement", async () => { + let capturedOptions: Options | undefined; + let resolveLaunch!: (launch: ProviderWorkspaceLaunchInput) => void; + const launched = new Promise((resolve) => { + resolveLaunch = resolve; + }); + const child = createChildProcessStub() as ChildProcessWithoutNullStreams; + const workspace = { + cwd: ".", + async resolveExecutable(command: string) { + return command; + }, + launchDeferred(input: ProviderWorkspaceLaunchInput | Promise) { + void Promise.resolve(input).then(resolveLaunch); + return child; + }, + } as ProviderWorkspace; + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + return createQueryMock([]); + }); + const client = new ClaudeAgentClient({ logger: createTestLogger(), queryFactory }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd() }, + { agentId: "selected-claude", workspace }, + ); + const controller = new AbortController(); + + try { + await session.listCommands?.(); + capturedOptions?.spawnClaudeCodeProcess?.({ + command: "node", + args: ["claude.js"], + cwd: process.cwd(), + env: {}, + signal: controller.signal, + } satisfies ClaudeSpawnOptions); + await expect(launched).resolves.toMatchObject({ signal: controller.signal }); + } finally { + child.emit("exit", 0, null); + await session.close(); + } + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 2abba3a6eb..3fae8de5b6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -17,12 +17,60 @@ import { } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; -import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { + AgentSession, + AgentTimelineItem, + AgentStreamEvent, + ProviderWorkspace, +} from "../../agent-sdk-types.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; } +function createStateWorkspace(overrides: Partial = {}): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable(command) { + return command; + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState(statePath) { + return { path: statePath, entries: [] }; + }, + async readStateText() { + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + ...overrides, + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -1815,6 +1863,143 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("deletes an ephemeral selected-runtime transcript through provider state access", async () => { + const sessionId = "selected-ephemeral"; + const transcript = `.claude/projects/project/${sessionId}.jsonl`; + const removeStateFile = vi.fn(async () => undefined); + const workspace = createStateWorkspace({ + findStateFile: async () => transcript, + removeStateFile, + }); + const queryFactory = createQueryFactoryForTurns([ + [ + { type: "system", subtype: "init", session_id: sessionId, permissionMode: "default" }, + { + type: "result", + subtype: "success", + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: "done", + stop_reason: null, + total_cost_usd: 0, + usage: {}, + permission_denials: [], + uuid: `${sessionId}-result`, + session_id: sessionId, + }, + ], + ]); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => { + throw new Error("selected runtime must resolve the executable"); + }, + }); + const session = await client.createSession( + { provider: "claude", cwd: "/host/path-that-must-not-be-read" }, + { agentId: "selected-agent", workspace }, + { persistSession: false }, + ); + + await session.run("turn"); + await session.close(); + + expect(removeStateFile).toHaveBeenCalledWith(transcript); + }); + + test("loads selected-runtime resume and sidechain files through provider state access", async () => { + const sessionId = "selected-resume"; + const transcript = `.claude/projects/project/${sessionId}.jsonl`; + const sidechainDirectory = `.claude/projects/project/${sessionId}/subagents`; + const sidechain = `${sidechainDirectory}/agent-child.jsonl`; + const readStateText = vi.fn(async (statePath: string) => { + if (statePath === transcript) return ""; + if (statePath === sidechain) return ""; + throw new Error(`Unexpected state read: ${statePath}`); + }); + const workspace = createStateWorkspace({ + findStateFile: async () => transcript, + readStateText, + async listState(statePath) { + if (statePath.endsWith("/workflows")) return { path: statePath, entries: [] }; + if (statePath === sidechainDirectory) { + return { + path: statePath, + entries: [ + { + name: "agent-child.jsonl", + path: sidechain, + kind: "file", + size: 0, + modifiedAt: "2026-08-11T00:00:00.000Z", + }, + ], + }; + } + throw new Error(`Unexpected state listing: ${statePath}`); + }, + }); + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => { + throw new Error("selected runtime must resolve the executable"); + }, + }); + const session = await client.resumeSession( + { provider: "claude", sessionId, metadata: { cwd: "/host/path-that-must-not-be-read" } }, + undefined, + { agentId: "selected-agent", workspace }, + ); + + await Array.fromAsync(session.streamHistory()); + + expect(readStateText).toHaveBeenCalledWith(transcript); + expect(readStateText).toHaveBeenCalledWith(sidechain); + await session.close(); + }); + + test("treats a proven missing selected-runtime transcript as empty history", async () => { + const workspace = createStateWorkspace({ + findStateFile: async () => null, + }); + const client = new ClaudeAgentClient({ logger }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "missing-transcript", metadata: { cwd: "/host/cwd" } }, + undefined, + { agentId: "selected-agent", workspace }, + ); + + await expect(Array.fromAsync(session.streamHistory())).resolves.toEqual([]); + await session.close(); + }); + + test.each([ + ["runtime listing", { findStateFile: async () => Promise.reject(new Error("runtime paused")) }], + [ + "transcript read", + { + findStateFile: async () => ".claude/projects/project/selected-failure.jsonl", + readStateText: async () => Promise.reject(new Error("permission denied")), + }, + ], + ])("fails selected-runtime resume history closed on %s failure", async (_name, overrides) => { + const workspace = createStateWorkspace(overrides); + const client = new ClaudeAgentClient({ logger }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "selected-failure", metadata: { cwd: "/host/cwd" } }, + undefined, + { agentId: "selected-agent", workspace }, + ); + + await expect(Array.fromAsync(session.streamHistory())).rejects.toThrow( + /runtime paused|permission denied/, + ); + await session.close(); + }); + test("preserves the persisted session jsonl on close when persistSession is undefined", async () => { const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-persist-")); const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 96fa62aa89..fc047eb498 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -32,6 +32,7 @@ import { } from "./task-notification-tool-call.js"; import { findClaudeModel, + getClaudeModels, getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId, resolveConfiguredClaudeModel, @@ -120,6 +121,7 @@ import { type ListImportableSessionsOptions, type McpServerConfig, type ProviderCatalog, + type ProviderWorkspace, type ResolveAgentDefaultModeInput, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; @@ -135,6 +137,12 @@ import { withTimeout } from "../../../../utils/promise-timeout.js"; import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; import { execCommand } from "../../../../utils/spawn.js"; import { composeSystemPromptParts } from "../../system-prompt.js"; +import { + isProviderStateNotFoundError, + providerWorkspaceEnvironment, + providerWorkspaceFromCatalogOptions, + runWorkspaceProviderCommand, +} from "../workspace/index.js"; const fsPromises = promises; const CLAUDE_SETTING_SOURCES: NonNullable = [ @@ -385,6 +393,16 @@ export interface ClaudeContentChunk { [key: string]: unknown; } +function renderClaudeToolResultImage( + image: ProviderImageOutput, + selectedWorkspace: boolean, +): AgentTimelineItem | null { + return renderProviderImageOutputAsAssistantMarkdown( + image, + selectedWorkspace ? {} : { materialize: materializeProviderImage }, + ); +} + interface ClaudeAgentClientOptions { defaults?: { agents?: Record }; logger: Logger; @@ -405,6 +423,7 @@ interface ClaudeAgentSessionOptions { logger: Logger; queryFactory?: ClaudeQueryFactory; resolveBinary: () => Promise; + workspace?: ProviderWorkspace; } type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; @@ -1507,7 +1526,8 @@ export class ClaudeAgentClient implements AgentClient { persistSession: options?.persistSession, logger: this.logger, queryFactory: this.queryFactory, - resolveBinary: this.resolveBinary, + resolveBinary: launchContext?.workspace ? async () => "claude" : this.resolveBinary, + workspace: launchContext?.workspace, }); } @@ -1535,25 +1555,28 @@ export class ClaudeAgentClient implements AgentClient { launchEnv: launchContext?.env, logger: this.logger, queryFactory: this.queryFactory, - resolveBinary: this.resolveBinary, + resolveBinary: launchContext?.workspace ? async () => "claude" : this.resolveBinary, + workspace: launchContext?.workspace, }); } - async fetchCatalog(_options: FetchCatalogOptions): Promise { - // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. + async fetchCatalog(options: FetchCatalogOptions): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); let claudeCodeVersion: string | undefined; try { - claudeCodeVersion = await this.resolveVersion(); + claudeCodeVersion = workspace + ? await resolveWorkspaceClaudeCodeVersion(workspace, this.runtimeSettings) + : await this.resolveVersion(); } catch (error) { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } - const models = await getClaudeModelsWithSettings( - this.logger, - this.configDir, - claudeCodeVersion, - ); + const models = workspace + ? getClaudeModels(claudeCodeVersion) + : await getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion); const modes = detectIneligibleAutoModeTransport( - createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), + workspace + ? providerWorkspaceEnvironment([this.runtimeSettings?.env]) + : createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), ) ? DEFAULT_MODES.filter((mode) => mode.id !== "auto") : DEFAULT_MODES; @@ -1584,6 +1607,7 @@ export class ClaudeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace) return []; const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) @@ -1612,11 +1636,22 @@ export class ClaudeAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { const launch = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, defaultBinary: "claude", }); + if (options) { + try { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } catch { + return false; + } + } const availability = await checkProviderLaunchAvailable(launch); return availability.available; } @@ -1700,6 +1735,26 @@ export async function resolveClaudeCodeVersion( return version.join("."); } +async function resolveWorkspaceClaudeCodeVersion( + workspace: ProviderWorkspace, + runtimeSettings?: ProviderRuntimeSettings, +): Promise { + const launch = await resolveProviderLaunch({ + commandConfig: runtimeSettings?.command, + defaultBinary: "claude", + }); + const executable = await workspace.resolveExecutable(launch.command); + const { stdout, stderr } = await runWorkspaceProviderCommand({ + workspace, + argv: [executable, ...launch.args, "--version"], + env: runtimeSettings?.env, + provider: "claude", + }); + const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`); + if (!version) throw new Error("Unable to parse Claude Code version from --version output"); + return version.join("."); +} + async function resolveClaudeAuth( launch: ResolvedProviderLaunch, availability: { resolvedPath: string | null }, @@ -2009,6 +2064,7 @@ class ClaudeAgentSession implements AgentSession { private readonly logger: Logger; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; + private readonly workspace?: ProviderWorkspace; private query: Query | null = null; private childProcess: ChildProcess | null = null; private input: AsyncMessageInput | null = null; @@ -2027,7 +2083,8 @@ class ClaudeAgentSession implements AgentSession { private readonly timelineAssembler = new TimelineAssembler(); private readonly taskProtocolSource = new ClaudeTaskProtocolSource({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, - readWorkflowResult: readClaudeWorkflowResultFile, + readWorkflowResult: (outputFile) => + this.workspace ? undefined : readClaudeWorkflowResultFile(outputFile), }); private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, @@ -2044,6 +2101,7 @@ class ClaudeAgentSession implements AgentSession { { type: "provider_subagent" } >[] = []; private historyPending = false; + private historyLoadPromise: Promise = Promise.resolve(); private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; private cancelCurrentTurn: (() => void) | null = null; @@ -2075,6 +2133,7 @@ class ClaudeAgentSession implements AgentSession { this.logger = options.logger.child({ agentId: this.agentId }); this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary; + this.workspace = options.workspace; this.contextUsage = new ClaudeContextUsageState( findClaudeModel(this.config.model)?.contextWindowMaxTokens, ); @@ -2086,7 +2145,7 @@ class ClaudeAgentSession implements AgentSession { } this.claudeSessionId = handle.sessionId; this.persistence = handle; - this.loadPersistedHistory(handle.sessionId); + this.historyLoadPromise = this.loadPersistedHistory(handle.sessionId); } else { this.claudeSessionId = null; this.persistence = null; @@ -2264,6 +2323,7 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.historyLoadPromise; if ( !this.historyPending || (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) @@ -2549,10 +2609,11 @@ class ClaudeAgentSession implements AgentSession { // (see `claude --help`), so the SDK's persistSession=false is silently dropped // in stream-json mode. Sweep the transcript ourselves so ephemeral runs // (metadata generator, branch-name generator) don't show up as resumable. - const historyPath = this.resolveHistoryPath(this.claudeSessionId); + const historyPath = await this.resolveHistoryPath(this.claudeSessionId); if (historyPath) { try { - await promises.rm(historyPath, { force: true }); + if (this.workspace) await this.workspace.removeStateFile(historyPath); + else await promises.rm(historyPath, { force: true }); } catch (error) { this.logger.warn( { err: error, historyPath, claudeSessionId: this.claudeSessionId }, @@ -2794,7 +2855,7 @@ class ClaudeAgentSession implements AgentSession { this.userMessageIds = []; this.emittedUserMessageIds.clear(); this.rewindTurnAnchors.length = 0; - this.loadPersistedHistory(sessionId); + this.historyLoadPromise = this.loadPersistedHistory(sessionId); if (oldSessionId && oldSessionId !== sessionId) { this.dispatchEvents([ { @@ -2976,6 +3037,8 @@ class ClaudeAgentSession implements AgentSession { runtimeSettings: this.runtimeSettings, launchEnv: this.launchEnv, queryFactory: this.queryFactory, + workspace: this.workspace, + agentId: this.agentId, onChildProcess: (child) => { this.childProcess = child; child.once("exit", (code, signal) => this.handleRuntimeExit(child, code, signal)); @@ -4557,20 +4620,24 @@ class ClaudeAgentSession implements AgentSession { } } - private loadPersistedHistory(sessionId: string): void { + private async loadPersistedHistory(sessionId: string): Promise { try { - const historyPath = this.resolveHistoryPath(sessionId); - if (!historyPath || !fs.existsSync(historyPath)) { - return; - } - const content = fs.readFileSync(historyPath, "utf8"); + const historyPath = await this.resolveHistoryPath(sessionId); + if (!historyPath) return; + const content = this.workspace + ? await this.workspace.readStateText(historyPath) + : fs.readFileSync(historyPath, "utf8"); const restoredProviderSubagentIds = this.ingestPersistedSidechains( content, - readClaudeSidechainHistory(historyPath), + this.workspace + ? await readClaudeSidechainHistoryFromWorkspace(historyPath, this.workspace) + : readClaudeSidechainHistory(historyPath), ); this.ingestPersistedHistory(content, restoredProviderSubagentIds); - } catch { - // ignore history load failures + } catch (error) { + if (!this.workspace || isProviderStateNotFoundError(error)) return; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load selected Claude history: ${message}`, { cause: error }); } } @@ -4707,7 +4774,10 @@ class ClaudeAgentSession implements AgentSession { } } - private resolveHistoryPath(sessionId: string): string | null { + private async resolveHistoryPath(sessionId: string): Promise { + if (this.workspace) { + return this.workspace.findStateFile(".claude/projects", `${sessionId}.jsonl`); + } const cwd = this.config.cwd; if (!cwd) return null; const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); @@ -4927,9 +4997,7 @@ class ClaudeAgentSession implements AgentSession { } for (const image of images) { - const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, { - materialize: materializeProviderImage, - }); + const imageItem = renderClaudeToolResultImage(image, Boolean(this.workspace)); if (imageItem) { items.push(imageItem); } @@ -5327,6 +5395,7 @@ class ClaudeAgentSession implements AgentSession { } private detectFileKind(filePath: string): string { + if (this.workspace) return "update"; try { return fs.existsSync(filePath) ? "update" : "add"; } catch { @@ -5479,11 +5548,11 @@ interface ClaudeSidechainHistory { const CLAUDE_SUBAGENT_META_FILE = /^agent-(.+)\.meta\.json$/; function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory { - const sessionDirectory = path.join( - path.dirname(historyPath), - path.basename(historyPath, ".jsonl"), + const sessionDirectory = path.posix.join( + path.posix.dirname(historyPath), + path.posix.basename(historyPath, ".jsonl"), ); - const sidechainDirectory = path.join(sessionDirectory, "subagents"); + const sidechainDirectory = path.posix.join(sessionDirectory, "subagents"); const history: ClaudeSidechainHistory = { contents: [], workflowContents: [], @@ -5535,12 +5604,83 @@ function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory return history; } +async function readClaudeSidechainHistoryFromWorkspace( + historyPath: string, + workspace: ProviderWorkspace, +): Promise { + const sessionDirectory = path.join( + path.dirname(historyPath), + path.basename(historyPath, ".jsonl"), + ); + const sidechainDirectory = path.join(sessionDirectory, "subagents"); + const history: ClaudeSidechainHistory = { + contents: [], + workflowContents: [], + workflowSidechainContentsByRunId: new Map(), + metaByAgentId: new Map(), + }; + try { + const workflows = await workspace.listState(path.posix.join(sessionDirectory, "workflows")); + for (const entry of workflows.entries) { + if (entry.kind !== "file" || !entry.name.endsWith(".json")) continue; + try { + history.workflowContents.push(await workspace.readStateText(entry.path)); + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + } + } + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + } + + const directories = [sidechainDirectory]; + while (directories.length > 0) { + const directory = directories.pop()!; + let entries; + try { + entries = (await workspace.listState(directory)).entries; + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + continue; + } + for (const entry of entries) { + if (entry.kind === "directory") { + directories.push(entry.path); + continue; + } + if (entry.name.endsWith(".jsonl")) { + const contents = await workspace.readStateText(entry.path); + recordClaudeSidechainContent(history, sidechainDirectory, entry.path, contents); + continue; + } + const metaMatch = CLAUDE_SUBAGENT_META_FILE.exec(entry.name); + if (!metaMatch?.[1]) continue; + try { + const meta = parseClaudeSubagentMeta(await workspace.readStateText(entry.path)); + if (meta) history.metaByAgentId.set(metaMatch[1], meta); + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + } + } + } + return history; +} + function recordClaudeSidechainContents( history: ClaudeSidechainHistory, sidechainDirectory: string, entryPath: string, ): void { const contents = fs.readFileSync(entryPath, "utf8"); + recordClaudeSidechainContent(history, sidechainDirectory, entryPath, contents); +} + +function recordClaudeSidechainContent( + history: ClaudeSidechainHistory, + sidechainDirectory: string, + entryPath: string, + contents: string, +): void { const relativeParts = path.relative(sidechainDirectory, entryPath).split(path.sep); const workflowRunId = relativeParts[0] === "workflows" && relativeParts.length >= 3 ? relativeParts[1] : undefined; diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index ccd511816a..ce8d3de976 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -8,6 +8,8 @@ import { } from "../../provider-launch-config.js"; import { buildSelfNodeCommand } from "../../../paseo-env.js"; import { spawnProcess } from "../../../../utils/spawn.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; +import { providerWorkspaceEnvironment, resolveWorkspaceCommand } from "../workspace/index.js"; // Keep the raw SDK query import in this module only. Claude process launch behavior // must stay shared between production and tests so Windows .cmd/.bat handling cannot @@ -23,6 +25,8 @@ export interface ClaudeQueryContext { queryFactory?: ClaudeQueryFactory; /** Called with the spawned child process so the caller can tree-kill it on close. */ onChildProcess?: (child: ChildProcess) => void; + workspace?: ProviderWorkspace; + agentId?: string; } function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams { @@ -58,7 +62,7 @@ function applyRuntimeSettingsToClaudeOptions( options: ClaudeOptions, context: ClaudeQueryContext, ): ClaudeOptions { - const { runtimeSettings, launchEnv, onChildProcess } = context; + const { runtimeSettings, launchEnv, onChildProcess, workspace } = context; return { ...options, spawnClaudeCodeProcess: (spawnOptions) => { @@ -84,6 +88,29 @@ function applyRuntimeSettingsToClaudeOptions( : null; const command = selfNodeCommand?.command ?? resolved.command; const args = selfNodeCommand?.args ?? resolved.args; + if (workspace) { + const workspaceArgs = isDefaultRuntime ? resolved.args.slice(1) : args; + const child = workspace.launchDeferred( + (async () => ({ + argv: [ + await resolveWorkspaceCommand(workspace, isDefaultRuntime ? "claude" : command), + ...workspaceArgs, + ], + environment: [providerWorkspaceEnvironment([runtimeSettings?.env, launchEnv])], + purpose: { + kind: "agent" as const, + agentId: context.agentId ?? "unidentified-agent", + provider: "claude", + }, + signal: spawnOptions.signal, + }))(), + ); + onChildProcess?.(child); + if (typeof options.stderr === "function") { + child.stderr.on("data", (chunk: Buffer | string) => options.stderr?.(chunk.toString())); + } + return child; + } const child = spawnProcess(command, args, { cwd: spawnOptions.cwd, ...(selfNodeCommand diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index be08a231ce..b878acf5ec 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -14,6 +14,7 @@ import type { AgentSessionConfig, AgentSlashCommand, AgentStreamEvent, + ProviderWorkspace, } from "../agent-sdk-types.js"; import { buildCodexAppServerEnv, @@ -84,6 +85,66 @@ function createConfig(overrides: Partial = {}): AgentSession }; } +function createCodexProviderWorkspace( + child: ChildProcessWithoutNullStreams, + options: { prompt?: string; onLaunch?: () => void } = {}, +): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable() { + return "codex"; + }, + async launch() { + options.onLaunch?.(); + return child; + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + return { stdout: "codex-cli 99.0.0", stderr: "" }; + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState(statePath) { + return { + path: statePath, + entries: options.prompt + ? [ + { + name: "selected.md", + path: ".codex/prompts/selected.md", + kind: "file" as const, + size: options.prompt.length, + modifiedAt: "2026-08-11T00:00:00.000Z", + }, + ] + : [], + }; + }, + async readStateText() { + if (options.prompt !== undefined) return options.prompt; + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} + function createSession( configOverrides: Partial = {}, options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {}, @@ -1140,6 +1201,27 @@ describe("Codex app-server provider", () => { appServer.assertNoErrors(); }); + test("unarchives a selected Codex thread through the bound launch capability", async () => { + const appServer = createFakeCodexAppServer({ + "thread/unarchive": () => ({ thread: { id: "selected-thread" } }), + }); + let launchCalls = 0; + const workspace = createCodexProviderWorkspace(appServer.child, { + onLaunch: () => { + launchCalls += 1; + }, + }); + const provider = new CodexAppServerAgentClient(createTestLogger()); + + await provider.unarchiveNativeSession( + { provider: "codex", sessionId: "selected-thread" }, + { agentId: "selected-agent", workspace }, + ); + + expect(launchCalls).toBe(1); + appServer.assertNoErrors(); + }); + test("unarchives a persisted Codex thread using sessionId when nativeHandle is absent", async () => { const threadRequests: Array<{ method: string; params: unknown }> = []; const appServer = createFakeCodexAppServer({ @@ -1675,6 +1757,108 @@ describe("Codex app-server provider", () => { }); }); + test("lists selected-runtime custom prompts through provider state access", async () => { + const appServer = createFakeCodexAppServer(); + const workspace = createCodexProviderWorkspace(appServer.child, { + prompt: "---\ndescription: Runtime prompt\nargument-hint: topic\n---\nExplain $ARGUMENTS", + }); + const provider = new CodexAppServerAgentClient(createTestLogger()); + const session = await provider.createSession(createConfig({ cwd: "/host/not-selected" }), { + agentId: "selected-codex", + workspace, + }); + + await expect(session.listCommands?.()).resolves.toContainEqual({ + name: "prompts:selected", + description: "Runtime prompt", + argumentHint: "topic", + kind: "command", + }); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("fails selected-runtime custom prompts closed when provider state access fails", async () => { + const appServer = createFakeCodexAppServer(); + const workspace = createCodexProviderWorkspace(appServer.child); + workspace.listState = async () => { + throw new Error("runtime paused"); + }; + const provider = new CodexAppServerAgentClient(createTestLogger()); + const session = await provider.createSession(createConfig({ cwd: "/host/not-selected" }), { + agentId: "selected-codex-failure", + workspace, + }); + + await expect(session.listCommands?.()).rejects.toThrow("runtime paused"); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("rejects a selected-runtime image turn before turn/start when state materialization fails", async () => { + const turnStart = vi.fn(() => ({})); + const appServer = createFakeCodexAppServer({ "turn/start": turnStart }); + const workspace = createCodexProviderWorkspace(appServer.child); + workspace.materializeStateFile = async () => { + throw new Error("runtime paused while materializing image"); + }; + const session = await createProviderWithFakeAppServer(appServer).createSession( + createConfig({ cwd: "/host/not-selected" }), + { agentId: "selected-codex-image-failure", workspace }, + ); + + await expect( + session.startTurn([{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 }]), + ).rejects.toThrow("runtime paused while materializing image"); + expect(turnStart).not.toHaveBeenCalled(); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("rejects a selected-runtime custom prompt turn before turn/start when listing fails", async () => { + const turnStart = vi.fn(() => ({})); + const appServer = createFakeCodexAppServer({ "turn/start": turnStart }); + const workspace = createCodexProviderWorkspace(appServer.child); + workspace.listState = async () => { + throw new Error("runtime paused while listing prompts"); + }; + const session = await createProviderWithFakeAppServer(appServer).createSession( + createConfig({ cwd: "/host/not-selected" }), + { agentId: "selected-codex-prompt-failure", workspace }, + ); + + await expect(session.startTurn("/prompts:selected")).rejects.toThrow( + "runtime paused while listing prompts", + ); + expect(turnStart).not.toHaveBeenCalled(); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("keeps a genuinely unknown selected-runtime prompt as plain prompt text", async () => { + const turnStart = vi.fn(() => ({})); + const appServer = createFakeCodexAppServer({ "turn/start": turnStart }); + const workspace = createCodexProviderWorkspace(appServer.child); + const session = await createProviderWithFakeAppServer(appServer).createSession( + createConfig({ cwd: "/host/not-selected" }), + { agentId: "selected-codex-unknown-prompt", workspace }, + ); + + await session.startTurn("/prompts:missing keep literal"); + + expect(turnStart).toHaveBeenCalledWith( + expect.objectContaining({ + input: [expect.objectContaining({ type: "text", text: "/prompts:missing keep literal" })], + }), + ); + await session.close(); + appServer.assertNoErrors(); + }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { const commands = await listCommandsFromFakeCodex([ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 98f5aa8430..b5c0c0d3b0 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -33,6 +33,7 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderWorkspace, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import type { Logger } from "pino"; @@ -103,6 +104,14 @@ import { CodexProviderOptionsSchema, type CodexProviderOptions, } from "./codex/options.js"; +import { + providerWorkspaceEnvironment, + providerWorkspaceFromCatalogOptions, + rejectSelectedProviderWorkspaceFailure, + resolveWorkspaceCommand, + runWorkspaceProviderCommand, + spawnWorkspaceProviderProcess, +} from "./workspace/index.js"; function assertChildWithPipes( child: ChildProcess, @@ -495,11 +504,20 @@ export async function findDefaultCodexBinary(): Promise { return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); } -async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ +async function resolveCodexLaunchPrefix( + runtimeSettings?: ProviderRuntimeSettings, + workspace?: ProviderWorkspace, +): Promise<{ command: string; args: string[]; }> { const launch = await resolveCodexLaunch(runtimeSettings); + if (workspace) { + return { + command: await resolveWorkspaceCommand(workspace, launch.command), + args: launch.args, + }; + } const availability = await checkCodexLaunchAvailable(launch); if (!availability.available) { throw new Error( @@ -643,13 +661,19 @@ function parseFrontMatter(markdown: string): { return { frontMatter, body }; } -async function listCodexCustomPrompts(): Promise { +async function listCodexCustomPrompts(workspace?: ProviderWorkspace): Promise { const codexHome = resolveCodexHomeDir(); const promptsDir = path.join(codexHome, "prompts"); - let entries: Dirent[]; + let entries: Array<{ name: string; isFile(): boolean }>; try { - entries = await fs.readdir(promptsDir, { withFileTypes: true }); - } catch { + entries = workspace + ? (await workspace.listState(".codex/prompts")).entries.map((entry) => ({ + name: entry.name, + isFile: () => entry.kind === "file", + })) + : await fs.readdir(promptsDir, { withFileTypes: true }); + } catch (error) { + rejectSelectedProviderWorkspaceFailure(workspace, error, { allowMissingState: true }); return []; } @@ -659,11 +683,16 @@ async function listCodexCustomPrompts(): Promise { const parsedCommands = await Promise.all( mdEntries.map(async (entry): Promise => { const name = entry.name.slice(0, -".md".length); - const fullPath = path.join(promptsDir, entry.name); + const fullPath = workspace + ? `.codex/prompts/${entry.name}` + : path.join(promptsDir, entry.name); let content: string; try { - content = await fs.readFile(fullPath, "utf8"); - } catch { + content = workspace + ? await workspace.readStateText(fullPath) + : await fs.readFile(fullPath, "utf8"); + } catch (error) { + rejectSelectedProviderWorkspaceFailure(workspace, error, { allowMissingState: true }); return null; } const parsed = parseFrontMatter(content); @@ -3010,6 +3039,8 @@ type CodexAppServerUserInput = export async function codexAppServerTurnInputFromPrompt( prompt: CodexPromptInput, logger: Logger, + workspace?: ProviderWorkspace, + onMaterializedStateFile?: (path: string) => void, ): Promise { if (typeof prompt === "string") { return [toCodexTextInput(prompt)]; @@ -3030,12 +3061,24 @@ export async function codexAppServerTurnInputFromPrompt( } if (block.type === "image") { try { - const filePath = materializeProviderImage({ - data: block.data, - mimeType: block.mimeType, - }).path; + const selectedImageData = block.data.match(/^data:[^;]+;base64,(.*)$/)?.[1] ?? block.data; + const stateFile = workspace + ? await workspace.materializeStateFile({ + name: "attachment.bin", + content: selectedImageData, + encoding: "base64", + }) + : null; + const filePath = stateFile + ? stateFile.path + : materializeProviderImage({ + data: block.data, + mimeType: block.mimeType, + }).path; + if (stateFile) onMaterializedStateFile?.(stateFile.statePath); output.push({ type: "localImage", path: filePath }); } catch (error) { + rejectSelectedProviderWorkspaceFailure(workspace, error); const message = error instanceof Error ? error.message : String(error); logger.warn({ message }, "Failed to write Codex image attachment"); output.push({ @@ -3231,6 +3274,7 @@ export class CodexAppServerAgentSession implements AgentSession { name: string; } | null = null; private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null; + private readonly materializedStateFiles = new Set(); constructor( config: AgentSessionConfig, @@ -3243,6 +3287,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly autoReviewEnabled: boolean = false, private readonly agentId?: string, private readonly initialResumePurpose: "interactive" | "history" = "interactive", + private readonly workspace?: ProviderWorkspace, ) { this.logger = logger.child({ module: "agent", @@ -3784,6 +3829,7 @@ export class CodexAppServerAgentSession implements AgentSession { const commands = await this.listCommands(); return commands.some((command) => command.name === parsed.commandName) ? parsed : null; } catch (error) { + rejectSelectedProviderWorkspaceFailure(this.workspace, error); this.logger.warn( { err: error, commandName: parsed.commandName }, "Failed to resolve slash command; falling back to plain prompt input", @@ -3798,9 +3844,12 @@ export class CodexAppServerAgentSession implements AgentSession { ): Promise { if (commandName.startsWith("prompts:")) { const promptName = commandName.slice("prompts:".length); - const codexHome = resolveCodexHomeDir(); - const promptPath = path.join(codexHome, "prompts", `${promptName}.md`); - const raw = await fs.readFile(promptPath, "utf8"); + const promptPath = this.workspace + ? `.codex/prompts/${promptName}.md` + : path.join(resolveCodexHomeDir(), "prompts", `${promptName}.md`); + const raw = this.workspace + ? await this.workspace.readStateText(promptPath) + : await fs.readFile(promptPath, "utf8"); const parsed = parseFrontMatter(raw); return expandCodexCustomPrompt(parsed.body, args); } @@ -4482,6 +4531,14 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingForegroundTurnIdentification?.resolve(null); this.pendingForegroundTurnIdentification = null; await this.disposeClient(); + if (this.workspace) { + await Promise.all( + [...this.materializedStateFiles].map((statePath) => + this.workspace!.removeStateFile(statePath), + ), + ); + this.materializedStateFiles.clear(); + } this.currentThreadId = null; } @@ -4509,7 +4566,7 @@ export class CodexAppServerAgentSession implements AgentSession { } async listCommands(): Promise { - const prompts = await listCodexCustomPrompts(); + const prompts = await listCodexCustomPrompts(this.workspace); if (!this.connected) { await this.connect(); } else { @@ -4522,7 +4579,7 @@ export class CodexAppServerAgentSession implements AgentSession { kind: "skill" as const, })); const fallbackSkills = - this.cachedSkills === null + this.cachedSkills === null && !this.workspace ? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService) : []; const builtin: AgentSlashCommand[] = [ @@ -4799,7 +4856,12 @@ export class CodexAppServerAgentSession implements AgentSession { if (typeof prompt === "string") { return [toCodexTextInput(prompt)]; } - return await codexAppServerTurnInputFromPrompt(prompt, this.logger); + return await codexAppServerTurnInputFromPrompt( + prompt, + this.logger, + this.workspace, + (statePath) => this.materializedStateFiles.add(statePath), + ); } private emitEvent(event: AgentStreamEvent): void { @@ -6452,6 +6514,8 @@ export class CodexAppServerAgentClient implements AgentClient { readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; private goalsEnabledPromise: Promise | null = null; private autoReviewEnabledPromise: Promise | null = null; + private readonly goalsEnabledByRuntime = new WeakMap>(); + private readonly autoReviewEnabledByRuntime = new WeakMap>(); constructor( private readonly logger: Logger, @@ -6469,7 +6533,14 @@ export class CodexAppServerAgentClient implements AgentClient { }; } - private resolveGoalsEnabled(): Promise { + private resolveGoalsEnabled(workspace?: ProviderWorkspace): Promise { + if (workspace) { + const cached = this.goalsEnabledByRuntime.get(workspace); + if (cached) return cached; + const pending = this.resolveVersionCapability(workspace, CODEX_GOALS_MIN_VERSION, "goals"); + this.goalsEnabledByRuntime.set(workspace, pending); + return pending; + } if (!this.goalsEnabledPromise) { this.goalsEnabledPromise = (async () => { try { @@ -6494,7 +6565,18 @@ export class CodexAppServerAgentClient implements AgentClient { return this.goalsEnabledPromise; } - private resolveAutoReviewEnabled(): Promise { + private resolveAutoReviewEnabled(workspace?: ProviderWorkspace): Promise { + if (workspace) { + const cached = this.autoReviewEnabledByRuntime.get(workspace); + if (cached) return cached; + const pending = this.resolveVersionCapability( + workspace, + CODEX_AUTO_REVIEW_MIN_VERSION, + "auto-review", + ); + this.autoReviewEnabledByRuntime.set(workspace, pending); + return pending; + } if (!this.autoReviewEnabledPromise) { this.autoReviewEnabledPromise = (async () => { try { @@ -6521,9 +6603,9 @@ export class CodexAppServerAgentClient implements AgentClient { private async spawnAppServer( launchEnv?: Record, - options?: { goalsEnabled?: boolean; agentId?: string }, + options?: { goalsEnabled?: boolean; agentId?: string; workspace?: ProviderWorkspace }, ): Promise { - const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); + const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings, options?.workspace); const args = [...launchPrefix.args, "app-server"]; if (options?.goalsEnabled) { args.push("--enable", "goals"); @@ -6537,6 +6619,16 @@ export class CodexAppServerAgentClient implements AgentClient { }, "provider.codex.spawn", ); + if (options?.workspace) { + return await spawnWorkspaceProviderProcess({ + workspace: options.workspace, + argv: [launchPrefix.command, ...args], + env: providerWorkspaceEnvironment([this.runtimeSettings?.env, launchEnv]), + purpose: options.agentId + ? { kind: "agent", agentId: options.agentId, provider: CODEX_PROVIDER } + : { kind: "provider-probe", provider: CODEX_PROVIDER }, + }); + } const child = spawnProcess(launchPrefix.command, args, { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], @@ -6549,6 +6641,26 @@ export class CodexAppServerAgentClient implements AgentClient { return child; } + private async resolveVersionCapability( + workspace: ProviderWorkspace, + minimum: readonly [number, number, number], + feature: string, + ): Promise { + try { + const launch = await resolveCodexLaunchPrefix(this.runtimeSettings, workspace); + const { stdout, stderr } = await runWorkspaceProviderCommand({ + workspace, + argv: [launch.command, ...launch.args, "--version"], + env: this.runtimeSettings?.env, + provider: CODEX_PROVIDER, + }); + return codexVersionAtLeast(`${stdout}\n${stderr}`, minimum); + } catch (error) { + this.logger.warn({ err: error, feature }, "Failed to probe workspace Codex version gate"); + return false; + } + } + async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, @@ -6561,20 +6673,30 @@ export class CodexAppServerAgentClient implements AgentClient { // TODO: Honor persistSession=false if app-server adds support, or route // utility generations through `codex exec --ephemeral` in a larger change. } - const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; - const goalsEnabled = await this.resolveGoalsEnabled(); - const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const sessionConfig: AgentSessionConfig = { + ...config, + provider: CODEX_PROVIDER, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + }; + const goalsEnabled = await this.resolveGoalsEnabled(launchContext?.workspace); + const autoReviewEnabled = await this.resolveAutoReviewEnabled(launchContext?.workspace); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + workspace: launchContext?.workspace, + }), this.sessionDeps(), options?.persistSession === false, goalsEnabled, autoReviewEnabled, launchContext?.agentId, + "interactive", + launchContext?.workspace, ); await session.connect(); return session; @@ -6593,20 +6715,26 @@ export class CodexAppServerAgentClient implements AgentClient { provider: CODEX_PROVIDER, cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), }; - const goalsEnabled = await this.resolveGoalsEnabled(); - const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + merged.cwd = launchContext?.workspace?.cwd ?? merged.cwd; + const goalsEnabled = await this.resolveGoalsEnabled(launchContext?.workspace); + const autoReviewEnabled = await this.resolveAutoReviewEnabled(launchContext?.workspace); const session = new CodexAppServerAgentSession( merged, handle, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + workspace: launchContext?.workspace, + }), this.sessionDeps(), false, goalsEnabled, autoReviewEnabled, launchContext?.agentId, options?.purpose ?? "interactive", + launchContext?.workspace, ); await session.connect(); return session; @@ -6615,7 +6743,7 @@ export class CodexAppServerAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(undefined, { workspace: options?.workspace }); const client = this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? new CodexAppServerClient(child, this.logger); @@ -6628,15 +6756,18 @@ export class CodexAppServerAgentClient implements AgentClient { // thread/list returns the cheap `cwd` field. Fetch a wider window when // filtering since most threads will be from other cwds, then keep the // local realpath-aware filter for symlink-equivalent workspace paths. - const listLimit = options?.cwd ? Math.max(limit, 50) : limit; + const requestedCwd = options?.workspace?.cwd ?? options?.cwd; + const listLimit = requestedCwd ? Math.max(limit, 50) : limit; const response = toObjectRecord( await client.request("thread/list", { limit: listLimit, - ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(requestedCwd ? { cwd: requestedCwd } : {}), }), ); const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : []; - const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); + const threads = options?.workspace + ? allThreads + : filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { const threadId = typeof thread.id === "string" ? thread.id : ""; const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd(); @@ -6670,10 +6801,11 @@ export class CodexAppServerAgentClient implements AgentClient { }); } - async fetchCatalog(_options: FetchCatalogOptions): Promise { + async fetchCatalog(options: FetchCatalogOptions): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); const [models, autoReviewEnabled] = await Promise.all([ - this.fetchModelsFromAppServer(), - this.resolveAutoReviewEnabled(), + this.fetchModelsFromAppServer(workspace), + this.resolveAutoReviewEnabled(workspace), ]); return { models, @@ -6688,9 +6820,11 @@ export class CodexAppServerAgentClient implements AgentClient { return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID; } - private async fetchModelsFromAppServer(): Promise { + private async fetchModelsFromAppServer( + workspace?: ProviderWorkspace, + ): Promise { // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(undefined, { workspace }); const client = new CodexAppServerClient(child, this.logger); try { @@ -6719,11 +6853,16 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + async archiveNativeSession( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(launchContext?.env, { + workspace: launchContext?.workspace, + }); const client = new CodexAppServerClient(child, this.logger); try { @@ -6735,11 +6874,16 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + async unarchiveNativeSession( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(launchContext?.env, { + workspace: launchContext?.workspace, + }); const client = new CodexAppServerClient(child, this.logger); try { @@ -6762,8 +6906,19 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { const launch = await resolveCodexLaunch(this.runtimeSettings); + if (options) { + try { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } catch { + return false; + } + } const availability = await checkCodexLaunchAvailable(launch); return availability.available; } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 095d4e10a8..db1876ee75 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,7 +1,7 @@ import type { Logger } from "pino"; import { z } from "zod"; -import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; +import type { AgentCapabilityFlags, FetchCatalogOptions } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, @@ -84,14 +84,10 @@ export class GenericACPAgentClient extends ACPAgentClient { this.diagnosticPhaseTimeoutMs = options.diagnosticPhaseTimeoutMs; } - protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { - return { - command: this.command[0], - args: this.command.slice(1), - }; - } - - override async isAvailable(): Promise { + override async isAvailable(options?: FetchCatalogOptions): Promise { + if (options?.scope === "workspace" && options.workspace) { + return super.isAvailable(options); + } const launch = await this.resolveConfiguredLaunch(); const availability = await checkProviderLaunchAvailable(launch); return availability.available; diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index f3f6c7f972..0d2e66d87e 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -33,6 +33,7 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderWorkspace, type ToolCallDetail, } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; @@ -69,6 +70,7 @@ import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; +import { providerWorkspaceFromCatalogOptions } from "../workspace/index.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js"; @@ -185,6 +187,7 @@ interface OmpAgentSessionOptions { noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; paseoTools?: PaseoToolCatalog; + workspace?: ProviderWorkspace; /** * When false (resumed sessions), replayed session events are dropped until * the first prompt or agent_start so history is not re-emitted as live @@ -309,7 +312,13 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +function renderTextOnlyImageHint( + image: { data: string; mimeType: string }, + selectedWorkspace: boolean, +): string { + if (selectedWorkspace) { + return "[Image attachment omitted: selected runtime model has no image input]"; + } try { const materialized = materializeProviderImage({ data: image.data, @@ -323,7 +332,7 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str function convertPromptInput( prompt: AgentPromptInput, - options: { model: OmpModel | null | undefined }, + options: { model: OmpModel | null | undefined; workspace?: ProviderWorkspace }, ): OmpPromptPayload { if (typeof prompt === "string") { return { text: prompt }; @@ -347,7 +356,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(renderTextOnlyImageHint(block, Boolean(options.workspace))); } continue; } @@ -446,6 +455,8 @@ function buildResumeStartInput(input: { input.resumeConfig.config.systemPrompt, input.resumeConfig.config.daemonAppendSystemPrompt, ), + workspace: input.launchContext?.workspace, + agentId: input.launchContext?.agentId, }; } @@ -866,6 +877,7 @@ export class OmpAgentSession implements AgentSession { private readonly subagentCardTracker: OmpSubagentCardTracker; private lastTodoItem: Extract | null = null; private state: OmpSessionState; + private readonly workspace?: ProviderWorkspace; private readonly currentModeId: string | null; private readonly providerIdleScheduler: OmpProviderIdleScheduler; private readonly noTurnScheduler: OmpNoTurnScheduler; @@ -881,6 +893,7 @@ export class OmpAgentSession implements AgentSession { this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.paseoTools = options.paseoTools; + this.workspace = options.workspace; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); @@ -951,7 +964,10 @@ export class OmpAgentSession implements AgentSession { throw new Error("An OMP turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = convertPromptInput(prompt, { + model: this.state.model, + workspace: this.workspace, + }); const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; @@ -1029,6 +1045,7 @@ export class OmpAgentSession implements AgentSession { sessionFile: this.state.sessionFile, runtimeSession: this.runtimeSession, provider: this.provider, + workspace: this.workspace, }); for (const item of mapOmpTodoState(this.state)) { yield { @@ -2231,7 +2248,9 @@ export class OmpAgentClient implements AgentClient { ): Promise { const launchMode = this.resolveLaunchMode(config.modeId); const runtimeSession = await this.runtime.startSession({ - cwd: config.cwd, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + workspace: launchContext?.workspace, + agentId: launchContext?.agentId, protocolMode: "rpc-ui", model: config.model, thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, @@ -2254,6 +2273,7 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, paseoTools: launchContext?.paseoTools, + workspace: launchContext?.workspace, }); } catch (error) { await runtimeSession.close().catch(() => undefined); @@ -2296,6 +2316,7 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, paseoTools: launchContext?.paseoTools, + workspace: launchContext?.workspace, live: false, }); } catch (error) { @@ -2306,8 +2327,11 @@ export class OmpAgentClient implements AgentClient { async fetchCatalog(options: FetchCatalogOptions): Promise { const launchMode = this.resolveLaunchMode(undefined); + const workspace = providerWorkspaceFromCatalogOptions(options); + const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd); const runtimeSession = await this.runtime.startSession({ - cwd: options.scope === "global" ? homedir() : options.cwd, + cwd, + workspace, protocolMode: "rpc-ui", modeId: launchMode.modeId, extraArgs: launchMode.extraArgs, @@ -2331,6 +2355,7 @@ export class OmpAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace) return []; return await listOmpImportableSessions({ ...options, sessionDir: this.providerParams.sessionDir, @@ -2339,7 +2364,9 @@ export class OmpAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { - const importConfig = await readOmpImportSessionConfig(input.providerHandleId); + const importConfig = context.launchContext?.workspace + ? {} + : await readOmpImportSessionConfig(input.providerHandleId); return importSessionFromPersistence({ provider: this.provider, request: input, @@ -2349,9 +2376,16 @@ export class OmpAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { try { const launch = await this.resolveOmpLaunch(); + if (options) { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } const availability = await checkProviderLaunchAvailable(launch); return availability.available; } catch { diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index b080a541b6..b2070ef629 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -37,6 +37,11 @@ import { type OmpSessionStats, type OmpSubagentSubscriptionLevel, } from "./rpc-types.js"; +import { + providerWorkspaceEnvironment, + resolveWorkspaceCommand, + spawnWorkspaceProviderProcess, +} from "../workspace/index.js"; const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"]; const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands"; @@ -67,6 +72,16 @@ export class OmpCliRuntime implements OmpRuntime { session: input, }); const [command, ...args] = launch.argv; + const workspaceChild = input.workspace + ? await spawnWorkspaceProviderProcess({ + workspace: input.workspace, + argv: [await resolveWorkspaceCommand(input.workspace, command), ...args], + env: providerWorkspaceEnvironment([this.options.runtimeSettings?.env, input.env]), + purpose: input.agentId + ? { kind: "agent", agentId: input.agentId, provider: "omp" } + : { kind: "provider-probe", provider: "omp" }, + }) + : null; const processLaunch: JsonlRpcLaunch = { command, args, @@ -74,11 +89,14 @@ export class OmpCliRuntime implements OmpRuntime { env: launch.env, }; const spawn = this.spawnProcess; + let runtimeSpawn; + if (workspaceChild) runtimeSpawn = () => workspaceChild; + else if (spawn) runtimeSpawn = () => spawn(launch); const processOptions = { launch: processLaunch, logger: this.options.logger, diagnosticName: "OMP RPC", - ...(spawn ? { spawn: () => spawn(launch) } : {}), + ...(runtimeSpawn ? { spawn: runtimeSpawn } : {}), }; const process = new JsonlRpcProcess(processOptions); return new OmpCliRuntimeSession(process, this.commandsRpcName); diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index db67f0cbe3..57e589baf0 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -1,9 +1,9 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; -import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentStreamEvent, ProviderWorkspace } from "../../agent-sdk-types.js"; import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./message-history.js"; import type { OmpAgentMessage } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; @@ -26,7 +26,82 @@ async function collectHistory( return events; } +function stateWorkspace(content: string): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable(command) { + return command; + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState(path) { + return { path, entries: [] }; + }, + async readStateText() { + return content; + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} + describe("OMP history mapper", () => { + test("reads a selected session transcript through provider state access", async () => { + const workspace = stateWorkspace( + JSON.stringify({ + type: "message", + id: "selected-user", + parentId: null, + message: { role: "user", content: "selected runtime history" }, + }), + ); + const readStateText = vi.spyOn(workspace, "readStateText"); + const events: AgentStreamEvent[] = []; + + for await (const event of streamOmpHistory({ + sessionFile: "/runtime/home/.omp/agent/sessions/project/session.jsonl", + provider: "omp", + workspace, + })) { + events.push(event); + } + + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { + type: "user_message", + text: "selected runtime history", + messageId: "selected-user", + }, + }), + ); + expect(readStateText).toHaveBeenCalledWith(".omp/agent/sessions/project/session.jsonl"); + }); + test("coalesces replayed subagent poll calls by target set", async () => { const events = await collectHistory([ { diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 7147e35e59..af8a794268 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; -import { basename, extname, join } from "node:path"; -import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { basename, extname, isAbsolute, join } from "node:path"; +import type { AgentProvider, AgentStreamEvent, ProviderWorkspace } from "../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js"; import type { OmpAgentMessage } from "./rpc-types.js"; @@ -46,6 +46,7 @@ export async function* streamOmpHistory(input: { runtimeSession?: OmpRuntimeSession; provider: AgentProvider; visitedSessionFiles?: Set; + workspace?: ProviderWorkspace; }): AsyncGenerator { if (!input.sessionFile) { return; @@ -60,6 +61,7 @@ export async function* streamOmpHistory(input: { entries = await readActiveOmpEntryChain( input.sessionFile, input.runtimeSession?.activeBranchEntryId, + input.workspace, ); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -87,7 +89,12 @@ export async function* streamOmpHistory(input: { } } for (const transcript of readSubagentTranscripts(messages, input.sessionFile)) { - yield* replaySubagentTranscript(transcript, input.provider, visitedSessionFiles); + yield* replaySubagentTranscript( + transcript, + input.provider, + visitedSessionFiles, + input.workspace, + ); } } @@ -95,13 +102,16 @@ async function* replaySubagentTranscript( transcript: OmpSubagentTranscript, provider: AgentProvider, visitedSessionFiles: Set, + workspace?: ProviderWorkspace, ): AsyncGenerator { - const childEntries = await readActiveOmpEntryChain(transcript.sessionFile).catch( - (error: unknown) => { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; - throw error; - }, - ); + const childEntries = await readActiveOmpEntryChain( + transcript.sessionFile, + undefined, + workspace, + ).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + }); const resolvedModel = extractOmpSubagentModel(childEntries); const firstTimestamp = normalizeProviderReplayTimestamp(childEntries[0]?.timestamp); yield subagentUpsert(transcript, provider, "running", firstTimestamp, resolvedModel); @@ -109,6 +119,7 @@ async function* replaySubagentTranscript( sessionFile: transcript.sessionFile, provider, visitedSessionFiles, + workspace, })) { if (event.type === "timeline") { yield { @@ -293,8 +304,11 @@ function stripExtension(filePath: string): string { export async function readActiveOmpEntryChain( sessionFile: string, activeEntryId?: string, + workspace?: ProviderWorkspace, ): Promise { - const content = await readFile(sessionFile, "utf8"); + const content = workspace + ? await workspace.readStateText(toOmpProviderStatePath(sessionFile)) + : await readFile(sessionFile, "utf8"); const entries = content.split("\n").flatMap((line) => { if (!line.trim()) return []; try { @@ -320,6 +334,17 @@ export async function readActiveOmpEntryChain( return chain.toReversed(); } +function toOmpProviderStatePath(sessionFile: string): string { + if (!isAbsolute(sessionFile)) return sessionFile.split("\\").join("/"); + const normalized = sessionFile.split("\\").join("/"); + const marker = "/.omp/"; + const markerIndex = normalized.indexOf(marker); + if (markerIndex < 0) { + throw new Error(`OMP selected-runtime session path is outside provider state: ${sessionFile}`); + } + return normalized.slice(markerIndex + 1); +} + function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { const message = entry.message; if (message && typeof message.role === "string") { diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index 3a8d90c61c..eb5a948e8a 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -13,6 +13,7 @@ import type { OmpThinkingLevel, } from "./rpc-types.js"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; export interface OmpRuntimeLaunch { cwd: string; @@ -29,6 +30,8 @@ export interface OmpRuntimeLaunch { } export interface OmpStartSessionInput { + workspace?: ProviderWorkspace; + agentId?: string; cwd: string; env?: Record; protocolMode?: "rpc" | "rpc-ui"; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6711f556aa..f4bb284093 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -79,6 +79,7 @@ import { toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; +import { providerWorkspaceFromCatalogOptions } from "./workspace/index.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; @@ -1408,6 +1409,10 @@ export class OpenCodeAgentClient implements AgentClient { } async fetchCatalog(options: FetchCatalogOptions): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace && !workspace.allowsHostService("opencode")) { + throw new Error("OpenCode is unavailable in the selected workspace runtime"); + } const acquisition = options.force ? await this.serverManager.acquireNew() : await this.serverManager.acquireCurrent(); @@ -1461,6 +1466,7 @@ export class OpenCodeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace && !options.workspace.allowsHostService("opencode")) return []; const acquisition = await this.serverManager.acquireCurrent(); const { url } = acquisition.server; const client = this.createOpenCodeClient({ @@ -1561,7 +1567,15 @@ export class OpenCodeAgentClient implements AgentClient { } } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { + if (options) { + try { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace && !workspace.allowsHostService("opencode")) return false; + } catch { + return false; + } + } const launch = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, defaultBinary: "opencode", diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 04adc29b92..d5214d3e10 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -36,6 +36,7 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderWorkspace, type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; @@ -61,6 +62,7 @@ import { type PiCapturedUserMessageEntry, } from "./history-mapper.js"; import { materializeProviderImage } from "../provider-image-output.js"; +import { providerWorkspaceFromCatalogOptions } from "../workspace/index.js"; import { PiCliRuntime } from "./cli-runtime.js"; import { revertPiConversation } from "./rewind.js"; import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; @@ -221,8 +223,9 @@ interface PiRpcAgentSessionOptions { initialState: PiSessionState; capabilities: AgentCapabilityFlags; currentModeId?: string | null; - cleanup?: () => void; + cleanup?: () => void | Promise; extensionTimeoutMs?: number; + workspace?: ProviderWorkspace; } interface PiResumeConfig { @@ -250,7 +253,7 @@ interface PiMcpConfigFile { interface PiTempFile { path: string; - cleanup: () => void; + cleanup: () => void | Promise; } interface PiCapturedEntry extends PiCapturedUserMessageEntry { @@ -399,7 +402,13 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean { return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +function renderTextOnlyImageHint( + image: { data: string; mimeType: string }, + selectedWorkspace: boolean, +): string { + if (selectedWorkspace) { + return "[Image attachment omitted: selected runtime model has no image input]"; + } try { const materialized = materializeProviderImage({ data: image.data, @@ -413,7 +422,7 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str function convertPromptInput( prompt: AgentPromptInput, - options: { model: PiModel | null | undefined }, + options: { model: PiModel | null | undefined; workspace?: ProviderWorkspace }, ): PiPromptPayload { if (typeof prompt === "string") { return { text: prompt }; @@ -437,7 +446,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(renderTextOnlyImageHint(block, Boolean(options.workspace))); } continue; } @@ -532,6 +541,8 @@ function buildResumeStartInput(input: { thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, mcpConfigPath: input.mcpConfig?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, + workspace: input.launchContext?.workspace, + agentId: input.launchContext?.agentId, }; } @@ -621,12 +632,11 @@ function createPiMcpConfigFile( }; } -function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { - const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); - const filePath = join(dir, "paseo-integration.mjs"); - writeFileSync( - filePath, - ` +async function createPiPaseoExtensionFile( + systemPrompt?: string, + workspace?: ProviderWorkspace, +): Promise { + const content = ` function decodePayload(encoded) { return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } @@ -752,27 +762,47 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { }, }); } -`.trimStart(), - "utf8", - ); +`.trimStart(); + if (workspace) { + const stateFile = await workspace.materializeStateFile({ + name: "paseo-integration.mjs", + content, + }); + return { path: stateFile.path, cleanup: stateFile.remove }; + } + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); + const filePath = join(dir, "paseo-integration.mjs"); + writeFileSync(filePath, content, "utf8"); return { path: filePath, cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } -function combineCleanup(cleanups: Array<(() => void) | undefined>): (() => void) | undefined { - const activeCleanups = cleanups.filter((cleanup): cleanup is () => void => Boolean(cleanup)); +function combineCleanup( + cleanups: Array<(() => void | Promise) | undefined>, +): (() => Promise) | undefined { + const activeCleanups = cleanups.filter((cleanup): cleanup is () => void | Promise => + Boolean(cleanup), + ); if (activeCleanups.length === 0) { return undefined; } - return () => { + return async () => { for (const cleanup of activeCleanups) { - cleanup(); + await cleanup(); } }; } +async function cleanupPiSessionFiles( + mcpConfig: PiMcpConfigFile | null, + paseoExtension: PiTempFile | null, +): Promise { + await mcpConfig?.cleanup(); + await paseoExtension?.cleanup(); +} + function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) { return false; @@ -1259,6 +1289,7 @@ export class PiRpcAgentSession implements AgentSession { this.capabilities = options.capabilities; this.provider = PI_PROVIDER; this.currentModeId = options.currentModeId ?? null; + this.workspace = options.workspace; this.cleanup = options.cleanup; this.lastKnownThinkingOptionId = normalizePiThinkingOption(options.config.thinkingOptionId) ?? @@ -1273,6 +1304,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly runtimeSession: PiRuntimeSession; private readonly config: AgentSessionConfig; + private readonly workspace?: ProviderWorkspace; private readonly cleanup?: () => void; private readonly extensionTimeoutMs: number; @@ -1297,7 +1329,10 @@ export class PiRpcAgentSession implements AgentSession { throw new Error("A Pi turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = convertPromptInput(prompt, { + model: this.state.model, + workspace: this.workspace, + }); const turnId = randomUUID(); this.activeTurnId = turnId; this.lastInterruptedTurnId = null; @@ -1530,7 +1565,7 @@ export class PiRpcAgentSession implements AgentSession { await this.runtimeSession.close(); } finally { this.rejectAllExtensionResults(new Error("Pi session closed")); - this.cleanup?.(); + await this.cleanup?.(); } } @@ -2375,14 +2410,22 @@ export class PiRpcAgentClient implements AgentClient { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); - const paseoExtension = createPiPaseoExtensionFile( + const mcpConfig = await this.prepareMcpConfig( + config.cwd, + config.mcpServers, + mcpEnv, + launchContext?.workspace, + ); + const paseoExtension = await createPiPaseoExtensionFile( composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), + launchContext?.workspace, ); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession({ - cwd: config.cwd, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + workspace: launchContext?.workspace, + agentId: launchContext?.agentId, model: config.model, thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, @@ -2392,8 +2435,7 @@ export class PiRpcAgentClient implements AgentClient { extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, }); } catch (error) { - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } try { @@ -2404,11 +2446,11 @@ export class PiRpcAgentClient implements AgentClient { capabilities: capabilitiesForSession(mcpConfig !== null), cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + workspace: launchContext?.workspace, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } } @@ -2434,12 +2476,14 @@ export class PiRpcAgentClient implements AgentClient { resumeConfig.cwd, resumeConfig.config.mcpServers, mcpEnv, + launchContext?.workspace, ); - const paseoExtension = createPiPaseoExtensionFile( + const paseoExtension = await createPiPaseoExtensionFile( composeSystemPromptParts( resumeConfig.config.systemPrompt, resumeConfig.config.daemonAppendSystemPrompt, ), + launchContext?.workspace, ); let runtimeSession: PiRuntimeSession; try { @@ -2453,8 +2497,7 @@ export class PiRpcAgentClient implements AgentClient { }), ); } catch (error) { - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } try { @@ -2465,18 +2508,21 @@ export class PiRpcAgentClient implements AgentClient { capabilities: capabilitiesForSession(mcpConfig !== null), cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + workspace: launchContext?.workspace, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } } async fetchCatalog(options: FetchCatalogOptions): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); + const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd); const runtimeSession = await this.runtime.startSession({ - cwd: options.scope === "global" ? homedir() : options.cwd, + cwd, + workspace, }); try { const models = transformPiModels( @@ -2497,6 +2543,7 @@ export class PiRpcAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace) return []; return await listPiImportableSessions({ ...options, sessionDir: this.providerParams.sessionDir, @@ -2505,7 +2552,9 @@ export class PiRpcAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { - const importConfig = await readPiImportSessionConfig(input.providerHandleId); + const importConfig = context.launchContext?.workspace + ? {} + : await readPiImportSessionConfig(input.providerHandleId); return importSessionFromPersistence({ provider: this.provider, request: input, @@ -2515,9 +2564,16 @@ export class PiRpcAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { try { const launch = await this.resolvePiLaunch(); + if (options) { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } const availability = await checkProviderLaunchAvailable(launch); return availability.available; } catch { @@ -2555,10 +2611,14 @@ export class PiRpcAgentClient implements AgentClient { cwd: string, servers: Record | undefined, env: Record | undefined, + workspace?: ProviderWorkspace, ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } + if (workspace) { + throw new Error("Pi MCP configuration is unavailable in a selected workspace runtime"); + } if (!(await this.detectMcpAdapter(cwd, env))) { return null; } diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 66cff0daf6..d6245648b6 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -24,6 +24,11 @@ import type { PiSessionState, PiSessionStats, } from "./rpc-types.js"; +import { + providerWorkspaceEnvironment, + resolveWorkspaceCommand, + spawnWorkspaceProviderProcess, +} from "../workspace/index.js"; const DEFAULT_PI_COMMAND: [string, ...string[]] = [ process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi", @@ -64,6 +69,16 @@ export class PiCliRuntime implements PiRuntime { session: input, }); const [command, ...args] = launch.argv; + const workspaceChild = input.workspace + ? await spawnWorkspaceProviderProcess({ + workspace: input.workspace, + argv: [await resolveWorkspaceCommand(input.workspace, command), ...args], + env: providerWorkspaceEnvironment([this.options.runtimeSettings?.env, input.env]), + purpose: input.agentId + ? { kind: "agent", agentId: input.agentId, provider: "pi" } + : { kind: "provider-probe", provider: "pi" }, + }) + : null; const processLaunch: JsonlRpcLaunch = { command, args, @@ -71,11 +86,14 @@ export class PiCliRuntime implements PiRuntime { env: launch.env, }; const spawn = this.spawnProcess; + let runtimeSpawn; + if (workspaceChild) runtimeSpawn = () => workspaceChild; + else if (spawn) runtimeSpawn = () => spawn(launch); const processOptions = { launch: processLaunch, logger: this.options.logger, diagnosticName: "Pi RPC", - ...(spawn ? { spawn: () => spawn(launch) } : {}), + ...(runtimeSpawn ? { spawn: runtimeSpawn } : {}), }; const process = new JsonlRpcProcess(processOptions); return new PiCliRuntimeSession(process, this.commandsRpcName); diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 2d4800c144..f057b29209 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -8,6 +8,7 @@ import type { PiSessionStats, } from "./rpc-types.js"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; export interface PiRuntimeLaunch { cwd: string; @@ -26,6 +27,8 @@ export interface PiRuntimeLaunch { export interface PiStartSessionInput { cwd: string; + workspace?: ProviderWorkspace; + agentId?: string; env?: Record; protocolMode?: "rpc" | "rpc-ui"; model?: string; diff --git a/packages/server/src/server/agent/providers/workspace/index.ts b/packages/server/src/server/agent/providers/workspace/index.ts new file mode 100644 index 0000000000..f64cd264c5 --- /dev/null +++ b/packages/server/src/server/agent/providers/workspace/index.ts @@ -0,0 +1,530 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import nodePath from "node:path"; +import { PassThrough } from "node:stream"; + +import type { ProcessEnvRecord } from "../../../paseo-env.js"; +import { createProviderEnv } from "../../provider-launch-config.js"; +import type { + BoundWorkspaceRuntime, + WorkspaceProcess, + WorkspaceProcessPurpose, +} from "../../../workspace-runtime/index.js"; + +export interface ProviderPlacementPolicy { + environment: + | { type: "inherit-sanitized-host"; hostEnvironment: ProcessEnvRecord } + | { type: "isolated"; baseEnvironment?: ProcessEnvRecord }; + sharedHostProviders: ReadonlySet; +} + +export function resolveProviderPlacementPolicy(input: { + runtimeId: string; + hostEnvironment: ProcessEnvRecord; + isolatedEnvironment?: ProcessEnvRecord; +}): ProviderPlacementPolicy { + const hostPlaced = input.runtimeId === "local" || input.runtimeId === "worktree"; + return { + environment: hostPlaced + ? { type: "inherit-sanitized-host", hostEnvironment: input.hostEnvironment } + : { type: "isolated", baseEnvironment: input.isolatedEnvironment }, + sharedHostProviders: hostPlaced ? new Set(["opencode"]) : new Set(), + }; +} + +export interface ProviderWorkspaceLaunchInput { + argv: readonly [string, ...string[]]; + cwd?: string; + environment?: readonly (ProcessEnvRecord | undefined)[]; + purpose: WorkspaceProcessPurpose; + signal?: AbortSignal; +} + +export class ProviderStateNotFoundError extends Error { + constructor(readonly statePath: string) { + super(`Provider state was not found: ${statePath}`); + this.name = "ProviderStateNotFoundError"; + } +} + +export function isProviderStateNotFoundError(error: unknown): error is ProviderStateNotFoundError { + return error instanceof ProviderStateNotFoundError; +} + +/** Preserve legacy fallbacks while preventing selected workspace capability failures from hiding. */ +export function rejectSelectedProviderWorkspaceFailure( + workspace: ProviderWorkspace | undefined, + error: unknown, + options: { allowMissingState?: boolean } = {}, +): void { + if (!workspace) return; + if (options.allowMissingState && isProviderStateNotFoundError(error)) return; + throw error; +} + +export interface MaterializedProviderStateFile { + /** Opaque runtime-local path passed only to the provider process. */ + readonly path: string; + /** Root-relative state path retained by the provider workspace authority. */ + readonly statePath: string; + remove(): Promise; +} + +/** Provider-owned placement capability. Adapters never receive a workspace runtime or its root. */ +export interface ProviderWorkspace { + readonly cwd: string; + resolveExecutable(command: string): Promise; + launch(input: ProviderWorkspaceLaunchInput): Promise; + launchDeferred( + input: ProviderWorkspaceLaunchInput | Promise, + ): ChildProcessWithoutNullStreams; + runProbe(input: { + argv: readonly [string, ...string[]]; + environment?: readonly (ProcessEnvRecord | undefined)[]; + provider: string; + }): Promise<{ stdout: string; stderr: string }>; + readWorkspaceText(path: string): Promise; + writeWorkspaceText(path: string, content: string): Promise; + listState(path: string): ReturnType; + readStateText(path: string): Promise; + findStateFile(root: string, fileName: string): Promise; + materializeStateFile(input: { + name: string; + content: string | Uint8Array | AsyncIterable; + encoding?: "utf8" | "base64"; + }): Promise; + removeStateFile(path: string): Promise; + allowsHostService(provider: string): boolean; +} + +export function bindProviderWorkspace(input: { + runtime: BoundWorkspaceRuntime; + cwd: string; + policy: ProviderPlacementPolicy; +}): ProviderWorkspace { + const environment = ( + overlays: readonly (ProcessEnvRecord | undefined)[] = [], + ): Record => { + const baseEnv = + input.policy.environment.type === "inherit-sanitized-host" + ? input.policy.environment.hostEnvironment + : (input.policy.environment.baseEnvironment ?? {}); + return createProviderEnv({ baseEnv, overlays: [...overlays] }) as Record; + }; + const run = (launch: ProviderWorkspaceLaunchInput): Promise => + input.runtime.run({ + cwd: launch.cwd ?? (input.cwd === "." ? undefined : input.cwd), + argv: launch.argv, + env: environment(launch.environment), + purpose: launch.purpose, + }); + + return { + cwd: input.cwd, + async resolveExecutable(command) { + const resolved = await input.runtime.resolveCommand(command); + if (!resolved) + throw new Error(`Provider command '${command}' was not found in the workspace`); + return resolved; + }, + async launch(launch) { + return wrapWorkspaceProcess(await run(launch), launch.signal); + }, + launchDeferred(launch) { + return wrapDeferredWorkspaceProcess( + Promise.resolve(launch).then(async (resolved) => ({ + process: await run(resolved), + signal: resolved.signal, + })), + ); + }, + async runProbe(probe) { + const process = await run({ + argv: probe.argv, + environment: probe.environment, + purpose: { kind: "provider-probe", provider: probe.provider }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(process.stdout), + collect(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`${probe.provider} probe failed: ${stderr || exit.code || exit.signal}`); + } + return { stdout, stderr }; + }, + async readWorkspaceText(path) { + return collect((await input.runtime.files.read(path)).chunks); + }, + async writeWorkspaceText(path, content) { + const result = await input.runtime.files.write({ path, contents: Buffer.from(content) }); + if (result.status !== "written") { + throw new Error( + result.status === "error" ? result.error : `Workspace file write conflicted: ${path}`, + ); + } + }, + async listState(statePath) { + const relativePath = requireRelativeStatePath(statePath); + try { + return await input.runtime.homeFiles.list(relativePath); + } catch (error) { + if (isKnownMissingFileError(error)) throw new ProviderStateNotFoundError(relativePath); + throw error; + } + }, + async readStateText(path) { + const statePath = requireRelativeStatePath(path); + const state = await input.runtime.homeFiles.stat(statePath); + if (state.status === "missing") throw new ProviderStateNotFoundError(statePath); + if (state.status === "error") throw new Error(state.error); + return collect((await input.runtime.homeFiles.read(statePath)).chunks); + }, + async findStateFile(root, fileName) { + const stateRoot = requireRelativeStatePath(root); + const pending = [stateRoot]; + while (pending.length > 0) { + const directory = pending.shift()!; + let listing; + try { + listing = await this.listState(directory); + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + continue; + } + for (const entry of listing.entries) { + if (entry.kind === "file" && entry.name === fileName) return entry.path; + if (entry.kind === "directory") pending.push(entry.path); + } + } + return null; + }, + async materializeStateFile(stateFile) { + const node = await this.resolveExecutable("node"); + const safeName = stateFile.name.replace(/[^A-Za-z0-9._-]/g, "-"); + const relativePath = `.paseo/provider-state/${randomUUID()}-${safeName}`; + const script = [ + 'const fs=require("fs"),fsp=require("fs/promises"),os=require("os"),path=require("path"),stream=require("stream/promises");', + "(async()=>{const root=await fsp.realpath(os.homedir()),relative=process.argv[1],target=path.resolve(root,relative),parent=path.dirname(target);", + 'if(path.isAbsolute(relative)||relative.split(/[\\\\/]+/u).includes(".."))throw new Error("Provider state path must be root-relative");', + 'await fsp.mkdir(parent,{recursive:true});const canonicalParent=await fsp.realpath(parent),rel=path.relative(root,canonicalParent);if(rel.startsWith("..")||path.isAbsolute(rel))throw new Error("Provider state path escapes its root");', + 'const handle=await fsp.open(target,"wx",0o600);try{await stream.pipeline(process.stdin,fs.createWriteStream(target,{fd:handle.fd,autoClose:false}));await handle.sync();}catch(error){await fsp.unlink(target).catch(()=>{});throw error;}finally{await handle.close();}process.stdout.write(target);})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const process = await run({ + argv: [node, "-e", script, relativePath], + purpose: { kind: "provider-probe", provider: "provider-state" }, + }); + const stdout = collect(process.stdout); + const stderr = collect(process.stderr); + try { + await writeProcessInput(process, toStateContent(stateFile)); + const [runtimePath, diagnostics, exit] = await Promise.all([ + stdout, + stderr, + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error( + `Provider state materialization failed: ${diagnostics || exit.code || exit.signal}`, + ); + } + if (!runtimePath) throw new Error("Provider state materialization returned no path"); + return { + path: runtimePath, + statePath: relativePath, + remove: () => this.removeStateFile(relativePath), + }; + } catch (error) { + await terminateWorkspaceProcess(process, "SIGKILL").catch(() => undefined); + throw error; + } + }, + async removeStateFile(path) { + const statePath = requireRelativeStatePath(path); + const node = await this.resolveExecutable("node"); + const script = [ + 'const f=require("fs/promises"),o=require("os"),p=require("path");', + "(async()=>{const root=await f.realpath(o.homedir()),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', + 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT")return;throw error;}const resolved=p.relative(root,canonical);if(resolved.startsWith("..")||p.isAbsolute(resolved))throw new Error("Provider state symlink escapes its root");const info=await f.lstat(target);if(!info.isFile()&&!info.isSymbolicLink())throw new Error("Provider state removal requires a file");await f.unlink(target);})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const child = await run({ + argv: [node, "-e", script, statePath], + purpose: { kind: "provider-probe", provider: "provider-state" }, + }); + child.stdin.end(); + const [, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + child.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`Provider state removal failed: ${stderr || exit.code || exit.signal}`); + } + }, + allowsHostService(provider) { + return input.policy.sharedHostProviders.has(provider); + }, + }; +} + +export function providerWorkspaceFromCatalogOptions(options: { + scope: "global" | "workspace"; + workspaceId?: string; + workspace?: ProviderWorkspace; +}): ProviderWorkspace | undefined { + if (options.scope !== "workspace") return undefined; + if (options.workspaceId && !options.workspace) { + throw new Error(`workspace runtime capability is unavailable: ${options.workspaceId}`); + } + return options.workspace; +} + +/** Merge explicit provider overlays. Runtime-specific inheritance is applied by the bound capability. */ +export function providerWorkspaceEnvironment( + overlays: readonly (ProcessEnvRecord | undefined)[], +): ProcessEnvRecord { + return Object.assign({}, ...overlays.filter((value): value is ProcessEnvRecord => !!value)); +} + +export function resolveWorkspaceCommand( + workspace: ProviderWorkspace, + command: string, +): Promise { + return workspace.resolveExecutable(command); +} + +export function spawnWorkspaceProviderProcess(input: { + workspace: ProviderWorkspace; + argv: readonly [string, ...string[]]; + env?: ProcessEnvRecord; + purpose: WorkspaceProcessPurpose; +}): Promise { + return input.workspace.launch({ + argv: input.argv, + environment: [input.env], + purpose: input.purpose, + }); +} + +export function runWorkspaceProviderCommand(input: { + workspace: ProviderWorkspace; + argv: readonly [string, ...string[]]; + env?: ProcessEnvRecord; + provider: string; +}): Promise<{ stdout: string; stderr: string }> { + return input.workspace.runProbe({ + argv: input.argv, + environment: [input.env], + provider: input.provider, + }); +} + +function wrapWorkspaceProcess( + process: WorkspaceProcess, + signal?: AbortSignal, +): ChildProcessWithoutNullStreams { + const child = createChildShell(process.stdin, process.stdout, process.stderr, (killSignal) => + process.kill(killSignal), + ); + const unbindAbort = bindAbortSignal(process, child, signal); + forwardExit(process, child, undefined, unbindAbort); + return child; +} + +function wrapDeferredWorkspaceProcess( + processPromise: Promise<{ process: WorkspaceProcess; signal?: AbortSignal }>, +): ChildProcessWithoutNullStreams { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let process: WorkspaceProcess | null = null; + let pendingSignal: NodeJS.Signals | undefined; + const child = createChildShell(stdin, stdout, stderr, (signal) => { + pendingSignal = signal; + process?.kill(signal); + }); + void processPromise.then( + ({ process: launched, signal }) => { + process = launched; + stdin.pipe(launched.stdin); + launched.stdout.pipe(stdout); + launched.stderr.pipe(stderr); + if (pendingSignal) launched.kill(pendingSignal); + const unbindAbort = bindAbortSignal(launched, child, signal); + child.emit("spawn"); + forwardExit( + launched, + child, + () => { + stdout.end(); + stderr.end(); + }, + unbindAbort, + ); + return undefined; + }, + (error) => { + stdin.destroy(error instanceof Error ? error : new Error(String(error))); + stdout.end(); + stderr.end(); + child.emit("error", error); + return undefined; + }, + ); + return child; +} + +function createChildShell( + stdin: NodeJS.WritableStream, + stdout: NodeJS.ReadableStream, + stderr: NodeJS.ReadableStream, + kill: (signal?: NodeJS.Signals) => void, +): ChildProcessWithoutNullStreams { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + Object.assign(child, { + stdin, + stdout, + stderr, + pid: undefined, + exitCode: null, + signalCode: null, + killed: false, + kill(signal?: NodeJS.Signals) { + (child as unknown as { killed: boolean }).killed = true; + kill(signal); + return true; + }, + }); + return child; +} + +function forwardExit( + process: WorkspaceProcess, + child: ChildProcessWithoutNullStreams, + beforeEmit?: () => void, + beforeExit?: () => void, +): void { + void process.exited.then( + ({ code, signal }) => { + const mutableChild = child as unknown as { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }; + mutableChild.exitCode = code; + mutableChild.signalCode = signal; + beforeEmit?.(); + beforeExit?.(); + child.emit("exit", code, signal); + child.emit("close", code, signal); + return undefined; + }, + (error) => child.emit("error", error), + ); +} + +function bindAbortSignal( + process: WorkspaceProcess, + child: ChildProcessWithoutNullStreams, + signal: AbortSignal | undefined, +): () => void { + if (!signal) return () => undefined; + let handled = false; + const onAbort = () => { + if (handled) return; + handled = true; + (child as unknown as { killed: boolean }).killed = true; + void terminateWorkspaceProcess(process, "SIGTERM").catch(() => undefined); + const error = new Error("The operation was aborted") as Error & { code: string }; + error.name = "AbortError"; + error.code = "ABORT_ERR"; + child.emit("error", error); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) queueMicrotask(onAbort); + return () => signal.removeEventListener("abort", onAbort); +} + +async function terminateWorkspaceProcess( + process: WorkspaceProcess, + initialSignal: NodeJS.Signals, +): Promise { + process.kill(initialSignal); + if (await exitsWithin(process, 1_000)) return; + if (initialSignal !== "SIGKILL") process.kill("SIGKILL"); + if (!(await exitsWithin(process, 1_000))) { + throw new Error("Workspace provider process did not terminate after SIGKILL"); + } +} + +async function exitsWithin(process: WorkspaceProcess, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + process.exited.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + timeout.unref(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function requireRelativeStatePath(statePath: string): string { + if ( + !statePath || + nodePath.posix.isAbsolute(statePath) || + nodePath.win32.isAbsolute(statePath) || + statePath.includes("\\") || + statePath.split("/").includes("..") + ) { + throw new Error(`Provider state path must be root-relative: ${statePath}`); + } + return nodePath.posix.normalize(statePath); +} + +function isKnownMissingFileError(error: unknown): boolean { + return error instanceof Error && /\b(?:ENOENT|ENOTDIR)\b/u.test(error.message); +} + +function toStateContent(input: { + content: string | Uint8Array | AsyncIterable; + encoding?: "utf8" | "base64"; +}): Uint8Array | AsyncIterable { + if (typeof input.content !== "string") return input.content; + return Buffer.from(input.content, input.encoding ?? "utf8"); +} + +async function writeProcessInput( + process: WorkspaceProcess, + contents: Uint8Array | AsyncIterable, +): Promise { + if (contents instanceof Uint8Array) { + process.stdin.end(contents); + return; + } + for await (const chunk of contents) { + if (!process.stdin.write(chunk)) await onceDrain(process.stdin); + } + process.stdin.end(); +} + +function onceDrain(stream: NodeJS.WritableStream): Promise { + return new Promise((resolve, reject) => { + stream.once("drain", resolve); + stream.once("error", reject); + }); +} + +async function collect(stream: NodeJS.ReadableStream | AsyncIterable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts new file mode 100644 index 0000000000..ff40dbaa56 --- /dev/null +++ b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts @@ -0,0 +1,227 @@ +import { once } from "node:events"; +import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import type { BoundWorkspaceRuntime } from "../../../workspace-runtime/index.js"; +import { createWorkspaceRuntimeService } from "../../../workspace-runtime/index.js"; +import { bindProviderWorkspace } from "./index.js"; + +const posixDescribe = describe.runIf(process.platform !== "win32"); + +posixDescribe("provider workspace placement capability", () => { + test("streams large binary state without putting content in argv and removes it safely", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-state-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + }); + await service.create({ + workspaceId: "provider-state", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const launches: Array = []; + const bound = await service.bind("provider-state"); + const recordingRuntime: BoundWorkspaceRuntime = { + ...bound, + resolveCommand(command) { + return command === "node" + ? Promise.resolve(process.execPath) + : bound.resolveCommand(command); + }, + run(input) { + launches.push(input.argv); + return bound.run(input); + }, + }; + const workspace = bindProviderWorkspace({ + runtime: recordingRuntime, + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + const contents = Buffer.alloc(192 * 1024, 0xa5); + + try { + const stateFile = await workspace.materializeStateFile({ + name: "large-image.bin", + content: contents, + }); + expect(await readFile(stateFile.path)).toEqual(contents); + expect(launches.flat().join(" ")).not.toContain(contents.toString("base64")); + + await stateFile.remove(); + await expect(access(stateFile.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await service.destroy("provider-state"); + await rm(root, { recursive: true, force: true }); + } + }); + + test("rejects absolute, traversal, and escaping-symlink state removal", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-remove-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + }); + await service.create({ + workspaceId: "provider-remove", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const workspace = bindProviderWorkspace({ + runtime: { + ...(await service.bind("provider-remove")), + resolveCommand: async (command) => + command === "node" + ? process.execPath + : (await service.bind("provider-remove")).resolveCommand(command), + }, + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + const outside = path.join(root, "outside.txt"); + await writeFile(outside, "outside"); + const stateDirectory = path.join( + homedir(), + ".paseo", + "provider-state", + `removal-${process.pid}-${Date.now()}`, + ); + await import("node:fs/promises").then((fs) => fs.mkdir(stateDirectory, { recursive: true })); + const escapeLink = path.join(stateDirectory, "escape-link"); + await symlink(outside, escapeLink); + const relativeLink = path.relative(homedir(), escapeLink); + + try { + await expect(workspace.removeStateFile(outside)).rejects.toThrow(/relative|outside/i); + await expect(workspace.removeStateFile("../outside.txt")).rejects.toThrow( + /relative|outside/i, + ); + await expect(workspace.removeStateFile(relativeLink)).rejects.toThrow(/outside|symlink/i); + await expect(readFile(outside, "utf8")).resolves.toBe("outside"); + } finally { + await rm(stateDirectory, { recursive: true, force: true }); + await service.destroy("provider-remove"); + await rm(root, { recursive: true, force: true }); + } + }); + + test("aborting a deferred selected launch kills the real process and settles its exit", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-abort-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + }); + await service.create({ + workspaceId: "provider-abort", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const workspace = bindProviderWorkspace({ + runtime: { + ...(await service.bind("provider-abort")), + resolveCommand: async (command) => + command === "node" + ? process.execPath + : (await service.bind("provider-abort")).resolveCommand(command), + }, + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + const controller = new AbortController(); + const child = workspace.launchDeferred( + Promise.resolve({ + argv: [ + process.execPath, + "-e", + "process.stdout.write(`${process.pid}\\n`);setInterval(()=>{},1000)", + ], + purpose: { kind: "agent", agentId: "abort-agent", provider: "claude" }, + signal: controller.signal, + }), + ); + child.on("error", () => undefined); + + try { + const [pidChunk] = (await once(child.stdout, "data")) as [Buffer]; + const pid = Number(pidChunk.toString().trim()); + controller.abort(); + const [code, signal] = (await once(child, "close")) as [number | null, NodeJS.Signals | null]; + expect({ code, signal }).toEqual({ code: null, signal: "SIGTERM" }); + expect(() => process.kill(pid, 0)).toThrow(); + } finally { + child.kill("SIGKILL"); + await service.destroy("provider-abort"); + await rm(root, { recursive: true, force: true }); + } + }); + + test("distinguishes missing provider state from a paused runtime", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-state-error-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + }); + await service.create({ + workspaceId: "provider-state-error", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const workspace = bindProviderWorkspace({ + runtime: await service.bind("provider-state-error"), + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + + try { + await expect( + workspace.findStateFile( + `.paseo/provider-state/missing-${process.pid}-${Date.now()}`, + "missing.jsonl", + ), + ).resolves.toBeNull(); + await service.pause("provider-state-error"); + await expect( + workspace.findStateFile(".claude/projects", "selected-runtime.jsonl"), + ).rejects.toThrow(/runtime is paused/i); + } finally { + await service.destroy("provider-state-error"); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 642b21b854..540d51e84d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -139,6 +139,10 @@ import { } from "./agent/tools/paseo-tools.js"; import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { + bindProviderWorkspace, + resolveProviderPlacementPolicy, +} from "./agent/providers/workspace/index.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { @@ -400,6 +404,7 @@ export interface PaseoDaemonConfig { command: readonly [string, ...string[]]; options?: Readonly>; helperCommand?: readonly [string, ...string[]]; + providerEnvironment?: Readonly>; } > >; @@ -903,6 +908,22 @@ export async function createPaseoDaemon( logger, }); const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); + const resolveProviderWorkspace = async (workspaceId: string) => { + const workspace = await workspaceRegistry?.get(workspaceId); + if (!workspace) return undefined; + const runtimeId = resolveSelectedWorkspaceRuntimeId(workspace); + if (!runtimeId) return null; + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return bindProviderWorkspace({ + runtime: await workspaceRuntime.bind(workspaceId), + cwd: ".", + policy: resolveProviderPlacementPolicy({ + runtimeId, + hostEnvironment: process.env, + isolatedEnvironment: config.workspaceRuntimes?.[runtimeId]?.providerEnvironment, + }), + }); + }; const providerSnapshotManager = new ProviderSnapshotManager({ logger: providerSnapshotLogger, runtimeSettings: config.agentProviderSettings, @@ -911,6 +932,7 @@ export async function createPaseoDaemon( managedProcesses, isDev: config.isDev === true, extraClients: config.agentClients, + resolveProviderWorkspace, }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ @@ -922,15 +944,7 @@ export async function createPaseoDaemon( workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, mcpAuthToken: agentMcpAuthToken, - resolveWorkspaceExecution: async (workspaceId) => { - const workspace = await workspaceRegistry?.get(workspaceId); - if (!workspace || !resolveSelectedWorkspaceRuntimeId(workspace)) return undefined; - const runtime = workspaceRuntime; - if (!runtime) throw new Error("Workspace runtime is not available"); - return { - run: (input) => runtime.run({ workspaceId, ...input }), - }; - }, + resolveProviderWorkspace, logger, }); diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index 38854d551b..30fa83ff3e 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -1,5 +1,15 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,6 +18,14 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { createWorkspaceRuntimeService } from "../workspace-runtime/index.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "../workspace-registry.js"; const FIXTURE_PROVIDER = "workspace-runtime-fixture"; const FIXTURE_MODEL = "fixture-model"; @@ -252,8 +270,15 @@ async function expectTerminalCommand(workspace: CharacterizedWorkspace): Promise async function expectProviderDiscoveryAndAgentExecution( workspace: CharacterizedWorkspace, ): Promise { - await client.refreshProvidersSnapshot({ cwd: workspace.cwd, providers: [FIXTURE_PROVIDER] }); - const providers = await client.getProvidersSnapshot({ cwd: workspace.cwd }); + await client.refreshProvidersSnapshot({ + cwd: workspace.cwd, + ...(workspace.kind === "local" ? { workspaceId: workspace.id } : {}), + providers: [FIXTURE_PROVIDER], + }); + const providers = await client.getProvidersSnapshot({ + cwd: workspace.cwd, + ...(workspace.kind === "local" ? { workspaceId: workspace.id } : {}), + }); expect(providers.entries).toContainEqual( expect.objectContaining({ provider: FIXTURE_PROVIDER, @@ -341,3 +366,149 @@ describe("current workspace runtime journeys", () => { } }, 120_000); }); + +test("selected worktree provider journey stays behind the public daemon and client boundary", async () => { + const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-selected-worktree-")); + cleanupRoots.push(root); + const repo = path.join(root, "repo"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); + writeFileSync(path.join(repo, "characterized.txt"), "before\n"); + copyFileSync(fixtureAgentPath, path.join(repo, "fixture-agent.mjs")); + chmodSync(path.join(repo, "fixture-agent.mjs"), 0o755); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { + cwd: repo, + }); + const paseoHomeRoot = path.join(root, "daemon-home"); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + mkdirSync(paseoHome, { recursive: true }); + const workspaceId = `selected-worktree-${Date.now()}`; + const seedRuntime = createWorkspaceRuntimeService({ + paseoHome, + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => (id === workspaceId ? "worktree" : null), + persistRuntimeId: async () => undefined, + }); + await seedRuntime.create({ + workspaceId, + runtimeId: "worktree", + project: { id: "selected-worktree-project", source: { kind: "host-directory", path: repo } }, + placement: { + kind: "branch", + branchName: "selected-worktree", + baseRef: "main", + worktreeSlug: workspaceId, + }, + }); + const runtimeState = JSON.parse( + readFileSync( + path.join( + paseoHome, + "workspace-runtimes", + "worktree", + readdirSync(path.join(paseoHome, "workspace-runtimes", "worktree"))[0]!, + ), + "utf8", + ), + ) as { root: string }; + const seededProjectRegistry = new FileBackedProjectRegistry( + path.join(paseoHome, "projects", "projects.json"), + createTestLogger(), + ); + const seededWorkspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + await seededProjectRegistry.initialize(); + await seededWorkspaceRegistry.initialize(); + const now = new Date().toISOString(); + await seededProjectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "selected-worktree-project", + rootPath: repo, + kind: "git", + displayName: "selected-worktree-project", + createdAt: now, + updatedAt: now, + }), + ); + await seededWorkspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "selected-worktree-project", + cwd: runtimeState.root, + kind: "worktree", + displayName: "selected-worktree", + branch: "selected-worktree", + worktreeRoot: runtimeState.root, + mainRepoRoot: repo, + isPaseoOwnedWorktree: true, + runtime: { runtimeId: "worktree" }, + createdAt: now, + updatedAt: now, + }), + ); + const selectedDaemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + mcpEnabled: false, + providerOverrides: { + [FIXTURE_PROVIDER]: { + extends: "acp", + label: "Workspace Runtime Fixture", + command: [path.join(runtimeState.root, "fixture-agent.mjs")], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + }); + const selectedClient = new DaemonClient({ + url: `ws://127.0.0.1:${selectedDaemon.port}/ws`, + appVersion: "0.3.0-beta.2", + reconnect: { enabled: false }, + }); + try { + await selectedClient.connect(); + await selectedClient.fetchAgents({ + subscribe: { subscriptionId: "selected-worktree-agents" }, + }); + const snapshot = await selectedClient.getProvidersSnapshot({ + cwd: runtimeState.root, + workspaceId, + }); + expect(snapshot.entries).toContainEqual( + expect.objectContaining({ + provider: FIXTURE_PROVIDER, + status: "ready", + models: [expect.objectContaining({ id: FIXTURE_MODEL })], + }), + ); + const agent = await selectedClient.createAgent({ + provider: FIXTURE_PROVIDER, + model: FIXTURE_MODEL, + cwd: runtimeState.root, + workspaceId, + title: "selected worktree fixture", + }); + await selectedClient.sendMessage(agent.id, "selected worktree edit"); + await expect(selectedClient.waitForFinish(agent.id, 30_000)).resolves.toMatchObject({ + status: "idle", + }); + const edited = await selectedClient.readFile( + runtimeState.root, + "stdio-agent-output.txt", + undefined, + workspaceId, + ); + expect(new TextDecoder().decode(edited.bytes)).toBe("selected worktree edit\n"); + await expect( + selectedClient.bindWorkspaceGit({ workspaceId, cwd: runtimeState.root }).getStatus(), + ).resolves.toMatchObject({ isGit: true, isDirty: true }); + } finally { + await selectedClient.close().catch(() => undefined); + await selectedDaemon.close(); + await seedRuntime.destroy(workspaceId); + } +}, 120_000); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 79c04f5a27..7328e629ba 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -92,6 +92,7 @@ const WorkspaceRuntimesConfigSchema = z.record( .min(1) .transform((command) => command as [string, ...string[]]), options: z.record(z.string(), z.unknown()).optional(), + providerEnvironment: z.record(z.string(), z.string()).optional(), helperCommand: z .array(z.string().min(1)) .min(1) diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 63000ca0d0..dd5dde4539 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -12,6 +12,13 @@ import { type ProviderSnapshotManager, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; +import type { + AgentClient, + AgentSessionConfig, + FetchCatalogOptions, +} from "../../agent/agent-sdk-types.js"; +import { ProviderSnapshotManager as RealProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; @@ -56,12 +63,19 @@ function makeSubsystem(options: MakeOptions = {}) { listDraftFeatures: async () => [], ...options.host, }; + const snapshot = options.snapshot ?? {}; const providerSnapshotManager = createStub({ + readSnapshot: async ({ cwd, workspaceId }) => { + const getSnapshot = snapshot.getSnapshot as + | ((snapshotCwd?: string, selectedWorkspaceId?: string) => ProviderSnapshotEntry[]) + | undefined; + return getSnapshot?.(cwd ?? undefined, workspaceId) ?? []; + }, on: (_event: string, handler: SnapshotChangeHandler) => { changeHandler = handler; }, off: () => {}, - ...options.snapshot, + ...snapshot, }); const subsystem = new ProviderCatalogSession({ host, @@ -80,6 +94,65 @@ function makeSubsystem(options: MakeOptions = {}) { } describe("ProviderCatalogSession", () => { + it("fails a fresh selected snapshot closed when its runtime capability cannot bind", async () => { + const hostAvailabilityCalls: Array = []; + const codex = { + provider: "codex", + capabilities: {}, + async isAvailable(options?: FetchCatalogOptions) { + hostAvailabilityCalls.push(options); + return true; + }, + async fetchCatalog() { + return { models: [], modes: [] }; + }, + } as unknown as AgentClient; + const manager = new RealProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex }, + resolveProviderWorkspace: async () => { + throw new Error("selected runtime is unavailable"); + }, + }); + const emitted: SessionOutboundMessage[] = []; + const subsystem = new ProviderCatalogSession({ + host: { + emit: (message) => emitted.push(message), + isProviderVisibleToClient: () => true, + supportsCustomModeIcons: () => true, + supportsCompactProviderSnapshots: () => false, + listProviderAvailability: async () => [], + listDraftFeatures: async (_config: AgentSessionConfig) => [], + }, + providerSnapshotManager: manager, + providerUsageService: createStub({}), + logger: createTestLogger(), + }); + + try { + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "selected-first-get", + cwd: "/same-host-cwd", + workspaceId: "selected-docker", + }); + + expect( + findByType(emitted, "get_providers_snapshot_response")?.payload.entries.find( + (entry) => entry.provider === "codex", + ), + ).toMatchObject({ provider: "codex", status: "error" }); + expect(hostAvailabilityCalls).toEqual([]); + } finally { + manager.destroy(); + } + }); it("PUSH gates invisible providers and downgrades unknown mode icons for legacy clients", () => { const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ visibleProviders: new Set(["codex"]), diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index ed7509a916..9ea06d2d2e 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -93,7 +93,11 @@ export class ProviderCatalogSession { } start(): void { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { + const handleProviderSnapshotChange = ( + entries: ProviderSnapshotEntry[], + cwd: string, + workspaceId?: string, + ) => { // COMPAT(providersSnapshot): keep provider visibility gating for older clients. const visibleEntries = entries.filter((entry) => this.host.isProviderVisibleToClient(entry.provider), @@ -106,6 +110,7 @@ export class ProviderCatalogSession { type: "providers_snapshot_update", payload: { ...(snapshotCwd ? { cwd: snapshotCwd } : {}), + ...(workspaceId ? { workspaceId } : {}), entries: [], ...encoded, generatedAt: new Date().toISOString(), @@ -117,6 +122,7 @@ export class ProviderCatalogSession { type: "providers_snapshot_update", payload: { ...(snapshotCwd ? { cwd: snapshotCwd } : {}), + ...(workspaceId ? { workspaceId } : {}), entries: clientEntries, generatedAt: new Date().toISOString(), }, @@ -393,9 +399,12 @@ export class ProviderCatalogSession { ): Promise { // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; - const entries = this.providerSnapshotManager - .getSnapshot(snapshotCwd) - .filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); + const entries = ( + await this.providerSnapshotManager.readSnapshot({ + cwd: snapshotCwd, + workspaceId: msg.workspaceId, + }) + ).filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); const clientEntries = this.downgradeEntryModesForClient(entries); if (this.host.supportsCompactProviderSnapshots()) { @@ -433,6 +442,7 @@ export class ProviderCatalogSession { if (msg.cwd) { await this.providerSnapshotManager.refreshSnapshotForCwd({ cwd: expandTilde(msg.cwd), + workspaceId: msg.workspaceId, providers: msg.providers, }); } else { diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs b/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs index 99f0e4588a..fe668b0021 100644 --- a/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs +++ b/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs @@ -1,76 +1,74 @@ #!/usr/bin/env node -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { Readable, Writable } from "node:stream"; -import { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk"; +import readline from "node:readline"; if (process.argv.includes("--version")) { process.stdout.write("workspace-runtime-fixture 1.0.0\n"); process.exit(0); } -class WorkspaceRuntimeFixtureAgent { - constructor(connection) { - this.connection = connection; - this.sessions = new Map(); - } +const sessions = new Map(); +const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - async initialize() { - return { - protocolVersion: PROTOCOL_VERSION, +for await (const line of lines) { + const message = JSON.parse(line); + if (message.method === "initialize") { + respond(message.id, { + protocolVersion: message.params?.protocolVersion ?? 1, agentCapabilities: { loadSession: false }, - }; + }); + continue; } - - async newSession(params) { - const sessionId = `fixture-session-${this.sessions.size + 1}`; - this.sessions.set(sessionId, { cwd: params.cwd }); - return { + if (message.method === "session/new") { + const modelId = await readFile(path.join(message.params.cwd, "provider-model.txt"), "utf8") + .then((value) => value.trim()) + .catch(() => "fixture-model"); + const sessionId = `fixture-session-${sessions.size + 1}`; + sessions.set(sessionId, { cwd: message.params.cwd }); + respond(message.id, { sessionId, models: { - availableModels: [{ modelId: "fixture-model", name: "Fixture Model" }], - currentModelId: "fixture-model", + availableModels: [{ modelId, name: `Fixture Model ${modelId}` }], + currentModelId: modelId, }, - }; + configOptions: [], + }); + continue; } - - async authenticate() { - return {}; - } - - async setSessionMode() { - return {}; - } - - async unstable_setSessionModel() { - return {}; - } - - async cancel() {} - - async prompt(params) { - const session = this.sessions.get(params.sessionId); - if (!session) throw new Error(`Unknown fixture session: ${params.sessionId}`); - const text = params.prompt + if (message.method === "session/prompt") { + const session = sessions.get(message.params.sessionId); + if (!session) throw new Error(`Unknown fixture session: ${message.params.sessionId}`); + const text = message.params.prompt .filter((block) => block.type === "text") .map((block) => block.text) .join(""); + await writeFile(path.join(session.cwd, "committed.txt"), `${text}\n`); await writeFile(path.join(session.cwd, "stdio-agent-output.txt"), `${text}\n`); - await this.connection.sessionUpdate({ - sessionId: params.sessionId, + await writeFile( + path.join(session.cwd, "stdio-agent-env.txt"), + `${process.env.PASEO_DAEMON_ONLY_PROVIDER_ENV ?? ""}\n`, + ); + notify("session/update", { + sessionId: message.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `fixture completed: ${text}` }, }, }); - return { stopReason: "end_turn" }; + respond(message.id, { stopReason: "end_turn" }); + continue; + } + if (message.method === "session/cancel") { + respond(message.id, {}); } } -const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)); -const connection = new AgentSideConnection( - (agentConnection) => new WorkspaceRuntimeFixtureAgent(agentConnection), - stream, -); -await connection.closed; +function respond(id, result) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); +} + +function notify(method, params) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index ed4708ec7b..3eae85edff 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -31,6 +31,7 @@ interface TestPaseoDaemonOptions { relayConfigCapability?: boolean; agentClients?: Partial>; providerOverrides?: PaseoDaemonConfig["providerOverrides"]; + workspaceRuntimes?: PaseoDaemonConfig["workspaceRuntimes"]; paseoHomeRoot?: string; staticDir?: string; cleanup?: boolean; @@ -176,6 +177,7 @@ async function prepareTestDaemonConfig( isDev: options.isDev, agentClients: options.agentClients ?? createTestAgentClients(), providerOverrides: options.providerOverrides, + workspaceRuntimes: options.workspaceRuntimes, agentStoragePath: path.join(paseoHome, "agents"), relayEnabled: options.relayEnabled ?? false, relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", diff --git a/packages/server/src/server/workspace-helper/executable.mjs b/packages/server/src/server/workspace-helper/executable.mjs index 194bad3e5a..62e8626b99 100644 --- a/packages/server/src/server/workspace-helper/executable.mjs +++ b/packages/server/src/server/workspace-helper/executable.mjs @@ -1,8 +1,9 @@ #!/usr/local/bin/node import { randomUUID } from "node:crypto"; import { constants, watch } from "node:fs"; -import { open, realpath, readdir, rename, stat, unlink } from "node:fs/promises"; +import { access, open, realpath, readdir, rename, stat, unlink } from "node:fs/promises"; import path from "node:path"; +import os from "node:os"; import readline from "node:readline"; const outsideMessage = "Access outside of workspace is not allowed"; @@ -20,7 +21,9 @@ const argument = (name, fallback) => { const index = argv.indexOf(name); return index < 0 ? fallback : argv[index + 1]; }; -const root = argument("--root"); +const configuredRoot = argument("--root"); +const root = configuredRoot === "@home" ? os.homedir() : configuredRoot; +const allowAbsolute = argv.includes("--allow-absolute"); try { if (!operation || !root) throw new Error("Usage: workspace-helper --root "); @@ -29,13 +32,40 @@ try { else if (operation === "fs-read") await read(root, argument("--path")); else if (operation === "fs-write") await write(root, argument("--path")); else if (operation === "watch") await runWatcher(root); - else if (operation === "describe") print({ version: 1, capabilities: ["files", "watch"] }); + else if (operation === "resolve-command") + print({ path: await resolveCommand(root, argument("--name")) }); + else if (operation === "describe") + print({ version: 1, capabilities: ["files", "watch", "resolve-command"] }); else throw new Error(`Unknown workspace-helper command: ${operation}`); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; } +async function resolveCommand(workspaceRoot, name) { + if (!name) throw new Error("--name is required"); + if (name.includes("/") || (process.platform === "win32" && name.includes("\\"))) { + const candidate = path.isAbsolute(name) ? name : path.resolve(workspaceRoot, name); + try { + await access(candidate, constants.X_OK); + return await realpath(candidate); + } catch { + return null; + } + } + const searchPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; + for (const directory of searchPath.split(path.delimiter)) { + const candidate = path.join(directory, name); + try { + await access(candidate, constants.X_OK); + return await realpath(candidate); + } catch { + // Continue through the runtime's own search path. + } + } + return null; +} + async function fileStat(workspaceRoot, relativePath) { try { const scoped = await resolveScoped(workspaceRoot, relativePath); @@ -259,9 +289,11 @@ function isIgnored(relativePath, ignoredPaths) { } async function resolveScoped(workspaceRoot, relativePath = ".") { - if (path.isAbsolute(relativePath)) throw new Error(outsideMessage); const normalizedRoot = path.resolve(workspaceRoot); - const requested = path.resolve(normalizedRoot, relativePath); + if (path.isAbsolute(relativePath) && !allowAbsolute) throw new Error(outsideMessage); + const requested = path.isAbsolute(relativePath) + ? path.resolve(relativePath) + : path.resolve(normalizedRoot, relativePath); if (!contains(normalizedRoot, requested)) throw new Error(outsideMessage); const canonicalRoot = await realpath(normalizedRoot); try { diff --git a/packages/server/src/server/workspace-helper/integration/index.ts b/packages/server/src/server/workspace-helper/integration/index.ts index bb2775e0e6..24f85c3da6 100644 --- a/packages/server/src/server/workspace-helper/integration/index.ts +++ b/packages/server/src/server/workspace-helper/integration/index.ts @@ -13,6 +13,7 @@ export interface WorkspaceHelperProcess { export interface WorkspaceFilesOwner { files: WorkspaceFiles; + resolveCommand(command: string): Promise; verify(): Promise; close(): Promise; } @@ -20,6 +21,7 @@ export interface WorkspaceFilesOwner { /** @package Parent integration SPI. Normal callers consume WorkspaceFiles from the public module. */ export function bindWorkspaceHelper(options: { root: string; + allowAbsolutePaths?: boolean; command: readonly [string, ...string[]]; launch(argv: readonly [string, ...string[]]): Promise; }): WorkspaceFilesOwner { diff --git a/packages/server/src/server/workspace-helper/internal/client.ts b/packages/server/src/server/workspace-helper/internal/client.ts index 067497a700..0358adfb93 100644 --- a/packages/server/src/server/workspace-helper/internal/client.ts +++ b/packages/server/src/server/workspace-helper/internal/client.ts @@ -4,6 +4,7 @@ import { describeSchema, directorySchema, fileStatSchema, + resolvedCommandSchema, watchEventSchema, writeResultSchema, } from "./protocol.js"; @@ -24,6 +25,7 @@ const PROCESS_STOP_TIMEOUT_MS = 1_000; export function createClient(options: { root: string; + allowAbsolutePaths?: boolean; launch(argv: readonly [string, ...string[]]): Promise; command: readonly [string, ...string[]]; }): WorkspaceFilesOwner { @@ -35,7 +37,14 @@ export function createClient(options: { const command = async (name: string, args: readonly string[] = []) => { if (closing) throw new Error("Workspace files client is closed"); - const child = await options.launch([...options.command, name, "--root", options.root, ...args]); + const child = await options.launch([ + ...options.command, + name, + "--root", + options.root, + ...(options.allowAbsolutePaths ? ["--allow-absolute"] : []), + ...args, + ]); processes.add(child); void child.exited.then( () => processes.delete(child), @@ -151,13 +160,20 @@ export function createClient(options: { }; }, }, + async resolveCommand(commandName) { + const result = await json("resolve-command", ["--name", commandName], resolvedCommandSchema); + return result.path; + }, async verify() { const description = await json("describe", [], describeSchema); if ( !description.capabilities.includes("files") || - !description.capabilities.includes("watch") + !description.capabilities.includes("watch") || + !description.capabilities.includes("resolve-command") ) { - throw new Error("Workspace helper does not support files and watching"); + throw new Error( + "Workspace helper does not support files, watching, and command resolution", + ); } }, async close() { diff --git a/packages/server/src/server/workspace-helper/internal/protocol.ts b/packages/server/src/server/workspace-helper/internal/protocol.ts index 63dadf49d4..63e0ab3b91 100644 --- a/packages/server/src/server/workspace-helper/internal/protocol.ts +++ b/packages/server/src/server/workspace-helper/internal/protocol.ts @@ -6,7 +6,11 @@ const fileKind = z.enum(["text", "image", "binary"]); export const describeSchema = z.object({ ...version, version: z.literal(1), - capabilities: z.array(z.enum(["files", "watch"])), + capabilities: z.array(z.enum(["files", "watch", "resolve-command"])), +}); + +export const resolvedCommandSchema = z.object({ + path: z.string().nullable(), }); const readyFile = z.object({ diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index c733bd168b..3824ab88d5 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -80,7 +80,9 @@ export interface WorkspaceTerminal { export interface BoundWorkspaceRuntime { run(input: Omit): Promise; + resolveCommand(command: string): Promise; readonly files: WorkspaceFiles; + readonly homeFiles: WorkspaceFiles; } export interface WorkspaceRuntimeService { @@ -104,6 +106,8 @@ export interface ExternalWorkspaceRuntime { command: readonly [string, ...string[]]; options?: Readonly>; helperCommand?: readonly [string, ...string[]]; + /** Explicit provider base environment for an isolated runtime. Host env is never inherited. */ + providerEnvironment?: Readonly>; } export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore { diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index 1320f957cd..b0cfff3c0f 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -33,6 +33,10 @@ export function createService( string, { runtimeId: string; revision: string; client: WorkspaceFilesOwner } >(); + const homeFileClients = new Map< + string, + { runtimeId: string; revision: string; client: WorkspaceFilesOwner } + >(); const boundFiles = new Map(); const unavailableFiles = new Map(); const boundRuntimes = new Map< @@ -174,7 +178,10 @@ export function createService( } const runtime: BoundWorkspaceRuntime = { run: (input) => runWithDriver(driver, { workspaceId, ...input }), + resolveCommand: async (command) => + (await requireFilesOwner(workspaceId)).resolveCommand(command), files: bindFiles(workspaceId), + homeFiles: bindHomeFiles(workspaceId), }; boundRuntimes.set(workspaceId, { runtimeId: driver.id, @@ -239,7 +246,31 @@ export function createService( }; } + function bindHomeFiles(workspaceId: string): WorkspaceFiles { + return { + async stat(path) { + return (await requireHomeFilesOwner(workspaceId)).files.stat(path); + }, + async list(path) { + return (await requireHomeFilesOwner(workspaceId)).files.list(path); + }, + async read(path) { + return (await requireHomeFilesOwner(workspaceId)).files.read(path); + }, + async write(input) { + return (await requireHomeFilesOwner(workspaceId)).files.write(input); + }, + async subscribe(input, listener) { + return (await requireHomeFilesOwner(workspaceId)).files.subscribe(input, listener); + }, + }; + } + async function requireFiles(workspaceId: string): Promise { + return (await requireFilesOwner(workspaceId)).files; + } + + async function requireFilesOwner(workspaceId: string): Promise { const unavailable = unavailableFiles.get(workspaceId); if (unavailable) throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); const driver = await resolve(workspaceId); @@ -249,7 +280,7 @@ export function createService( } const cached = fileClients.get(workspaceId); if (cached && cached.runtimeId === driver.id && cached.revision === inspection.state.revision) { - return cached.client.files; + return cached.client; } if (cached) await cached.client.close(); const client = bindWorkspaceHelper({ @@ -264,13 +295,42 @@ export function createService( revision: inspection.state.revision, client, }); - return client.files; + return client; + } + + async function requireHomeFilesOwner(workspaceId: string): Promise { + const unavailable = unavailableFiles.get(workspaceId); + if (unavailable) throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const cached = homeFileClients.get(workspaceId); + if (cached && cached.runtimeId === driver.id && cached.revision === inspection.state.revision) { + return cached.client; + } + if (cached) await cached.client.close(); + const client = bindWorkspaceHelper({ + root: "@home", + command: driver.workspaceHelperCommand, + launch: async (argv) => launchHelper(driver, workspaceId, argv), + }); + homeFileClients.set(workspaceId, { + runtimeId: driver.id, + revision: inspection.state.revision, + client, + }); + return client; } async function closeFiles(workspaceId: string): Promise { const cached = fileClients.get(workspaceId); + const cachedHome = homeFileClients.get(workspaceId); fileClients.delete(workspaceId); + homeFileClients.delete(workspaceId); if (cached) await cached.client.close(); + if (cachedHome) await cachedHome.client.close(); } async function launchHelper( 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 986de9aa9d..c4bb382cf6 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 @@ -1,6 +1,15 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,11 +18,23 @@ import { afterAll, beforeAll, expect, test, vi } from "vitest"; import { createWorkspaceRuntimeService } from "./index.js"; import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "../workspace-registry.js"; const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime"); const runtimeExecutable = path.join(dockerRuntimeRoot, "src/index.ts"); const image = "paseo-workspace-runtime-slice1:test"; +const fixtureAgent = fileURLToPath( + new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url), +); const cleanupRoots: string[] = []; beforeAll(() => { @@ -336,6 +357,241 @@ test("the command runtime owns Docker workspace materialization, pipes, and life } }, 120_000); +test("public daemon and client keep Docker provider discovery, launch, files, Git, and snapshots in one runtime", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-daemon-")); + cleanupRoots.push(root); + const repo = await createRepository(root); + await writeFile(path.join(repo, "provider-model.txt"), "fixture-model-v1\n"); + execFileSync("git", ["add", "provider-model.txt"], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "provider model"], { + cwd: repo, + }); + const paseoHomeRoot = path.join(root, "daemon-home"); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + const runtimeConfig = { + type: "command" as const, + command: [process.execPath, "--import", "tsx", runtimeExecutable] as const, + options: { image }, + }; + const configuredRuntimeConfig = { + ...runtimeConfig, + providerEnvironment: { PASEO_DAEMON_ONLY_PROVIDER_ENV: "runtime-configured" }, + }; + const runtimeConfigs = { docker: runtimeConfig, "docker-configured": configuredRuntimeConfig }; + const workspaceIds = [`docker-daemon-a-${Date.now()}`, `docker-daemon-b-${Date.now()}`]; + const hostProbeMarker = path.join(root, "host-provider-probed.txt"); + const hostOnlyProvider = path.join(root, "host-only-provider.mjs"); + await writeFile( + hostOnlyProvider, + `#!/usr/bin/env node\nawait import("node:fs/promises").then((fs) => fs.appendFile(${JSON.stringify(hostProbeMarker)}, \`${"${process.cwd()}"}\\n\`));\n${(await readFile(fixtureAgent, "utf8")).replace(/^#![^\n]*\n/u, "")}`, + ); + await chmod(hostOnlyProvider, 0o755); + const selectedRuntimeIds = new Map([ + [workspaceIds[0]!, "docker"], + [workspaceIds[1]!, "docker-configured"], + ]); + const seedRuntime = createWorkspaceRuntimeService({ + paseoHome, + externalRuntimes: runtimeConfigs, + resolveRuntimeId: async (workspaceId) => selectedRuntimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + selectedRuntimeIds.set(workspaceId, runtimeId); + }, + }); + const projectRegistry = new FileBackedProjectRegistry( + path.join(paseoHome, "projects", "projects.json"), + createTestLogger(), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + await projectRegistry.initialize(); + await workspaceRegistry.initialize(); + const now = new Date().toISOString(); + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "docker-daemon-project", + rootPath: repo, + kind: "git", + displayName: "docker-daemon-project", + createdAt: now, + updatedAt: now, + }), + ); + for (const workspaceId of workspaceIds) { + const runtimeId = selectedRuntimeIds.get(workspaceId)!; + await seedRuntime.create({ + workspaceId, + runtimeId, + project: { id: "docker-daemon-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "docker-daemon-project", + cwd: repo, + kind: "local_checkout", + displayName: workspaceId, + runtime: { runtimeId }, + createdAt: now, + updatedAt: now, + }), + ); + } + + const daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + mcpEnabled: false, + agentClients: {}, + workspaceRuntimes: runtimeConfigs, + providerOverrides: { + "docker-fixture": { + extends: "acp", + label: "Docker Fixture", + command: ["node", "./fixture-agent.mjs"], + params: { supportsMcpServers: false }, + enabled: true, + }, + "host-only-fixture": { + extends: "acp", + label: "Host Only", + command: [hostOnlyProvider], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + }); + const publicClient = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.3.0-beta.2", + reconnect: { enabled: false }, + }); + + try { + await publicClient.connect(); + await publicClient.fetchAgents({ subscribe: { subscriptionId: "docker-runtime-agents" } }); + for (const workspaceId of workspaceIds) { + const snapshot = await publicClient.getProvidersSnapshot({ cwd: repo, workspaceId }); + expect(snapshot.entries).toContainEqual( + expect.objectContaining({ + provider: "docker-fixture", + status: "ready", + models: [expect.objectContaining({ id: "fixture-model-v1" })], + }), + ); + expect(snapshot.entries).toContainEqual( + expect.objectContaining({ provider: "host-only-fixture", status: "unavailable" }), + ); + expect(snapshot.entries).toContainEqual( + expect.objectContaining({ provider: "opencode", status: "unavailable" }), + ); + const hostProbeCwds = (await readFileFromHost(root, "host-provider-probed.txt")) + ?.trim() + .split("\n"); + expect(hostProbeCwds).not.toContain(repo); + } + + const workspaceId = workspaceIds[0]!; + const workspaceGit = publicClient.bindWorkspaceGit({ workspaceId, cwd: repo }); + await expect(workspaceGit.getStatus()).resolves.toMatchObject({ + isGit: true, + isDirty: false, + }); + const agent = await publicClient.createAgent({ + provider: "docker-fixture", + model: "fixture-model-v1", + cwd: repo, + workspaceId, + title: "Docker public fake provider", + }); + await publicClient.sendMessage(agent.id, "public docker edit"); + await expect(publicClient.waitForFinish(agent.id, 30_000)).resolves.toMatchObject({ + status: "idle", + }); + const edited = await publicClient.readFile( + repo, + "stdio-agent-output.txt", + undefined, + workspaceId, + ); + expect(new TextDecoder().decode(edited.bytes)).toBe("public docker edit\n"); + const editedTrackedFile = await publicClient.readFile( + repo, + "committed.txt", + undefined, + workspaceId, + ); + expect(new TextDecoder().decode(editedTrackedFile.bytes)).toBe("public docker edit\n"); + const dirtyDiff = await workspaceGit.getDiff({ mode: "uncommitted" }); + expect(dirtyDiff.files.map((file) => file.path)).toContain("committed.txt"); + const isolatedEnvironment = await publicClient.readFile( + repo, + "stdio-agent-env.txt", + undefined, + workspaceId, + ); + expect(new TextDecoder().decode(isolatedEnvironment.bytes)).toBe("\n"); + + const configuredAgent = await publicClient.createAgent({ + provider: "docker-fixture", + model: "fixture-model-v1", + cwd: repo, + workspaceId: workspaceIds[1], + title: "Configured Docker public fake provider", + }); + await publicClient.sendMessage(configuredAgent.id, "configured public docker edit"); + await expect(publicClient.waitForFinish(configuredAgent.id, 30_000)).resolves.toMatchObject({ + status: "idle", + }); + const configuredEnvironment = await publicClient.readFile( + repo, + "stdio-agent-env.txt", + undefined, + workspaceIds[1], + ); + expect(new TextDecoder().decode(configuredEnvironment.bytes)).toBe("runtime-configured\n"); + + await seedRuntime.destroy(workspaceId); + await writeFile(path.join(repo, "provider-model.txt"), "fixture-model-v2\n"); + execFileSync("git", ["add", "provider-model.txt"], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "provider model v2"], { + cwd: repo, + }); + await seedRuntime.create({ + workspaceId, + runtimeId: "docker", + project: { id: "docker-daemon-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + + const rebuiltA = await publicClient.getProvidersSnapshot({ cwd: repo, workspaceId }); + expect(rebuiltA.entries).toContainEqual( + expect.objectContaining({ + provider: "docker-fixture", + models: [expect.objectContaining({ id: "fixture-model-v2" })], + }), + ); + const unchangedB = await publicClient.getProvidersSnapshot({ + cwd: repo, + workspaceId: workspaceIds[1], + }); + expect(unchangedB.entries).toContainEqual( + expect.objectContaining({ + provider: "docker-fixture", + models: [expect.objectContaining({ id: "fixture-model-v1" })], + }), + ); + } finally { + await publicClient.close().catch(() => undefined); + await daemon.close(); + await Promise.all(workspaceIds.map((workspaceId) => seedRuntime.destroy(workspaceId))); + } +}, 180_000); + test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-rollback-")); cleanupRoots.push(root); @@ -491,6 +747,8 @@ async function createRepository(root: string): Promise { execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); await writeFile(path.join(repo, "committed.txt"), "committed\n"); await writeFile(path.join(repo, "large.bin"), Buffer.alloc(700_000, 0xa5)); + await copyFile(fixtureAgent, path.join(repo, "fixture-agent.mjs")); + await chmod(path.join(repo, "fixture-agent.mjs"), 0o755); await symlink("/etc/passwd", path.join(repo, "outside-link")); execFileSync("git", ["add", "."], { cwd: repo }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 5c60db9275..09ddd1474d 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -23,13 +23,15 @@ afterEach(async () => { }); posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", (runtimeId) => { - test("bound runtime exposes only process and file primitives", async () => { + test("bound runtime exposes only process, file, and command-resolution primitives", async () => { const fixture = await createFixture(runtimeId); await fixture.service.create(fixture.createInput); const runtime = await fixture.service.bind(fixture.workspaceId); - expect(Object.keys(runtime).sort()).toEqual(["files", "run"]); + expect(Object.keys(runtime).sort()).toEqual(["files", "homeFiles", "resolveCommand", "run"]); + await expect(runtime.resolveCommand("git")).resolves.toMatch(/^\//u); + await expect(runtime.resolveCommand("paseo-command-that-does-not-exist")).resolves.toBeNull(); await fixture.service.destroy(fixture.workspaceId); });