feat(workspaces): run providers through workspace runtimes

Bind provider discovery, state, and subprocess launches to workspace identity so selected runtimes cannot fall back to host execution. Preserve explicit legacy behavior and isolate provider snapshots by runtime generation.
This commit is contained in:
Mohamed Boudra
2026-08-11 12:18:42 +02:00
parent 0520481966
commit 36241cbd87
58 changed files with 3590 additions and 460 deletions
@@ -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 });
@@ -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),
@@ -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<AgentProviderDefinition[]>(() => {
if (!slice?.provider) return [];
@@ -28,6 +28,7 @@ type AttachmentUpdater =
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
interface AgentInputDraftComposerOptions {
workspaceId?: string | null;
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialFeatureValues?: Record<string, unknown>;
@@ -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(
() =>
@@ -365,6 +365,7 @@ export function WorkspaceDraftAgentTab({
const draftInput = useAgentInputDraft({
draftKey: draftStoreKey,
composer: {
workspaceId,
initialServerId: serverId,
initialValues: draftInitialValues,
initialFeatureValues: draftSetup?.featureValues,
@@ -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" });
});
});
@@ -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<CachedProviderSnapshot | null>;
read(serverId: string, scope: ProviderSnapshotCacheScope): Promise<CachedProviderSnapshot | null>;
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;
}
+10 -1
View File
@@ -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 } : {}),
};
+12 -2
View File
@@ -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,
@@ -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(
@@ -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,
@@ -21,6 +21,7 @@ import {
providersSnapshotQueryRoot,
providersSnapshotRequestOptions,
} from "@/data/providers-snapshot";
import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache";
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
@@ -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<GetProvidersSnapshotResult> {
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<RefreshProvidersSnapshotResult> {
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 }),
});
}
@@ -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,
]);
+5
View File
@@ -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<GetProvidersSnapshotPayload> {
@@ -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<RefreshProvidersSnapshotPayload> {
@@ -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",
+4
View File
@@ -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(),
@@ -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<T> {
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<T>(): Deferred<T> {
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<FetchCatalogOptions | undefined> = [];
let createCalls = 0;
class RuntimeScopedClient extends TestAgentClient {
override async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
availabilityScopes.push(options);
return false;
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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<boolean> {
hostAvailabilityCalls += 1;
return true;
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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",
+100 -25
View File
@@ -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<AgentLaunchContext["workspaceExecution"]>;
) => Promise<AgentLaunchContext["workspace"] | null>;
}
export interface WaitForAgentOptions {
@@ -646,7 +648,7 @@ export class AgentManager {
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
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<ManagedImportableProviderSession[]> {
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<void> {
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<AgentClient> {
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<void> {
async archiveNativeSessionBestEffort(record: StoredAgentRecord): Promise<void> {
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<void> {
private async unarchiveNativeSession(record: StoredAgentRecord): Promise<void> {
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 {
@@ -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<WorkspaceProcessInput, "workspaceId">): Promise<WorkspaceProcess>;
};
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<boolean>;
isAvailable(options?: FetchCatalogOptions): Promise<boolean>;
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<void>;
archiveNativeSession?(
handle: AgentPersistenceHandle,
launchContext?: AgentLaunchContext,
): Promise<void>;
/**
* Unarchive a durable native session in the provider.
* Called before Paseo clears its archived flag so provider resume can succeed.
*/
unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
unarchiveNativeSession?(
handle: AgentPersistenceHandle,
launchContext?: AgentLaunchContext,
): Promise<void>;
/**
* Release any provider-owned resources held by this client (background
* processes, sockets, cached subprocesses, etc.). Called when the daemon
@@ -18,6 +18,7 @@ function createRealAgentManager(storage: AgentStorage): AgentManager {
return new AgentManager({
clients: createTestAgentClients(),
registry: storage,
resolveProviderWorkspace: async () => null,
logger,
});
}
@@ -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<string, unknown> }> {
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,
@@ -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[] = [];
@@ -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<T>() {
let complete!: (value: T) => void;
let fail!: (error: unknown) => void;
const promise = new Promise<T>((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<void>();
const catalogStarted = deferred<void>();
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<void>();
const snapshotStarted = deferred<void>();
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<string, ProviderWorkspace>([
["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;
},
};
}
@@ -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<Record<AgentProvider, AgentClient>>;
refreshTimeoutMs?: number;
diagnosticTimeoutMs?: number;
resolveProviderWorkspace?: (workspaceId: string) => Promise<ProviderWorkspace | null | undefined>;
}
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<string, unknown> | undefined;
@@ -175,7 +191,14 @@ interface ProviderLoad {
promise: Promise<void>;
}
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<Record<AgentProvider, AgentClient>>;
private readonly resolveProviderWorkspace?: ProviderSnapshotManagerOptions["resolveProviderWorkspace"];
private readonly workspaceSnapshotKeys = new Map<string, string>();
private readonly workspaceTargets = new Map<string, ProviderSnapshotTarget>();
private readonly snapshotDisplayCwds = new Map<string, string>();
private readonly snapshotWorkspaceIds = new Map<string, string>();
private readonly runtimeTokens = new WeakMap<object, number>();
private nextRuntimeToken = 0;
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private providerOverrides: Record<string, ProviderOverride> | undefined;
private baseProviderOverrides: Record<string, ProviderOverride> | 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<AgentProvider, AgentClient>;
}
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<ProviderSnapshotEntry[]> {
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<void> {
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<void> {
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<ProviderSnapshotEntry[]> {
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<ResolvedProviderCreateConfig> {
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<ProviderSnapshotTarget> {
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<AgentProvider, ProviderSnapshotEntry> {
@@ -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 {
@@ -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<string, string>;
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<void> {
process.kill("SIGTERM");
if (await exitsWithin(process.exited, 2_000)) return;
process.kill("SIGKILL");
await process.exited;
}
async function exitsWithin(exit: Promise<unknown>, timeoutMs: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<false>((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<string, string> {
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<AgentSession> {
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<ProviderCatalog> {
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<ImportableProviderSession[]> {
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<boolean> {
async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
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<SpawnedACPProcess> {
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<string, string>): Promise<ACPProcessTransport> {
const { command, args } = await this.resolveLaunchCommand();
protected async spawnTransport(
launchEnv?: Record<string, string>,
workspace?: ProviderWorkspace,
): Promise<ACPProcessTransport> {
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<RequestPermissionResponse> {
return { outcome: { outcome: "cancelled" } };
},
async sessionUpdate(): Promise<void> {},
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<void>;
private readonly agentId?: string;
private readonly launchEnv?: Record<string, string>;
private readonly workspaceExecution?: AgentLaunchContext["workspaceExecution"];
private readonly workspace?: ProviderWorkspace;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
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<Record<string, never>> {
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<SpawnedACPProcess> {
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<string> {
return workspace.readWorkspaceText(toWorkspaceRelativePath(requestedPath));
}
async function writeWorkspaceTextFile(
workspace: ProviderWorkspace,
requestedPath: string,
content: string,
): Promise<void> {
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;
}
@@ -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<string, string>();
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,
);
});
@@ -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<ProviderWorkspaceLaunchInput>((resolve) => {
resolveLaunch = resolve;
});
const child = createChildProcessStub() as ChildProcessWithoutNullStreams;
const workspace = {
cwd: ".",
async resolveExecutable(command: string) {
return command;
},
launchDeferred(input: ProviderWorkspaceLaunchInput | Promise<ProviderWorkspaceLaunchInput>) {
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();
}
});
});
@@ -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> = {}): 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;
@@ -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<ClaudeOptions["settingSources"]> = [
@@ -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<string, AgentDefinition> };
logger: Logger;
@@ -405,6 +423,7 @@ interface ClaudeAgentSessionOptions {
logger: Logger;
queryFactory?: ClaudeQueryFactory;
resolveBinary: () => Promise<string>;
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<ProviderCatalog> {
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
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<ImportableProviderSession[]> {
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<boolean> {
async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
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<string> {
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<string>;
private readonly workspace?: ProviderWorkspace;
private query: Query | null = null;
private childProcess: ChildProcess | null = null;
private input: AsyncMessageInput<SDKUserMessage> | 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<void> = 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<AgentStreamEvent> {
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<void> {
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<string | null> {
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<ClaudeSidechainHistory> {
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;
@@ -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
@@ -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<AgentSessionConfig> = {}): 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<AgentSessionConfig> = {},
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([
{
@@ -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<string | null> {
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<AgentSlashCommand[]> {
async function listCodexCustomPrompts(workspace?: ProviderWorkspace): Promise<AgentSlashCommand[]> {
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<AgentSlashCommand[]> {
const parsedCommands = await Promise.all(
mdEntries.map(async (entry): Promise<AgentSlashCommand | null> => {
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<CodexAppServerUserInput[]> {
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<string>();
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<CodexPromptInput> {
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<AgentSlashCommand[]> {
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<boolean> | null = null;
private autoReviewEnabledPromise: Promise<boolean> | null = null;
private readonly goalsEnabledByRuntime = new WeakMap<object, Promise<boolean>>();
private readonly autoReviewEnabledByRuntime = new WeakMap<object, Promise<boolean>>();
constructor(
private readonly logger: Logger,
@@ -6469,7 +6533,14 @@ export class CodexAppServerAgentClient implements AgentClient {
};
}
private resolveGoalsEnabled(): Promise<boolean> {
private resolveGoalsEnabled(workspace?: ProviderWorkspace): Promise<boolean> {
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<boolean> {
private resolveAutoReviewEnabled(workspace?: ProviderWorkspace): Promise<boolean> {
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<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string },
options?: { goalsEnabled?: boolean; agentId?: string; workspace?: ProviderWorkspace },
): Promise<ChildProcessWithoutNullStreams> {
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<boolean> {
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<ImportableProviderSession[]> {
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<ProviderCatalog> {
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
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<AgentModelDefinition[]> {
private async fetchModelsFromAppServer(
workspace?: ProviderWorkspace,
): Promise<AgentModelDefinition[]> {
// 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<void> {
async archiveNativeSession(
handle: AgentPersistenceHandle,
launchContext?: AgentLaunchContext,
): Promise<void> {
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<void> {
async unarchiveNativeSession(
handle: AgentPersistenceHandle,
launchContext?: AgentLaunchContext,
): Promise<void> {
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<boolean> {
async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
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;
}
@@ -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<boolean> {
override async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
if (options?.scope === "workspace" && options.workspace) {
return super.isAvailable(options);
}
const launch = await this.resolveConfiguredLaunch();
const availability = await checkProviderLaunchAvailable(launch);
return availability.available;
@@ -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<AgentTimelineItem, { type: "todo" }> | 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<AgentSession> {
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<ProviderCatalog> {
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<ImportableProviderSession[]> {
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<boolean> {
async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
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 {
@@ -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);
@@ -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([
{
@@ -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<string>;
workspace?: ProviderWorkspace;
}): AsyncGenerator<AgentStreamEvent> {
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<string>,
workspace?: ProviderWorkspace,
): AsyncGenerator<AgentStreamEvent> {
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<OmpSessionEntry[]> {
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") {
@@ -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<string, string>;
protocolMode?: "rpc" | "rpc-ui";
@@ -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<ProviderCatalog> {
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<ImportableProviderSession[]> {
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<boolean> {
async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
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",
@@ -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<void>;
extensionTimeoutMs?: number;
workspace?: ProviderWorkspace;
}
interface PiResumeConfig {
@@ -250,7 +253,7 @@ interface PiMcpConfigFile {
interface PiTempFile {
path: string;
cleanup: () => void;
cleanup: () => void | Promise<void>;
}
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<PiTempFile> {
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<void>) | undefined>,
): (() => Promise<void>) | undefined {
const activeCleanups = cleanups.filter((cleanup): cleanup is () => void | Promise<void> =>
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<void> {
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<ProviderCatalog> {
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<ImportableProviderSession[]> {
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<boolean> {
async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
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<string, McpServerConfig> | undefined,
env: Record<string, string> | undefined,
workspace?: ProviderWorkspace,
): Promise<PiMcpConfigFile | null> {
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;
}
@@ -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);
@@ -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<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
@@ -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<string>;
}
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<void>;
}
/** Provider-owned placement capability. Adapters never receive a workspace runtime or its root. */
export interface ProviderWorkspace {
readonly cwd: string;
resolveExecutable(command: string): Promise<string>;
launch(input: ProviderWorkspaceLaunchInput): Promise<ChildProcessWithoutNullStreams>;
launchDeferred(
input: ProviderWorkspaceLaunchInput | Promise<ProviderWorkspaceLaunchInput>,
): ChildProcessWithoutNullStreams;
runProbe(input: {
argv: readonly [string, ...string[]];
environment?: readonly (ProcessEnvRecord | undefined)[];
provider: string;
}): Promise<{ stdout: string; stderr: string }>;
readWorkspaceText(path: string): Promise<string>;
writeWorkspaceText(path: string, content: string): Promise<void>;
listState(path: string): ReturnType<BoundWorkspaceRuntime["homeFiles"]["list"]>;
readStateText(path: string): Promise<string>;
findStateFile(root: string, fileName: string): Promise<string | null>;
materializeStateFile(input: {
name: string;
content: string | Uint8Array | AsyncIterable<Uint8Array>;
encoding?: "utf8" | "base64";
}): Promise<MaterializedProviderStateFile>;
removeStateFile(path: string): Promise<void>;
allowsHostService(provider: string): boolean;
}
export function bindProviderWorkspace(input: {
runtime: BoundWorkspaceRuntime;
cwd: string;
policy: ProviderPlacementPolicy;
}): ProviderWorkspace {
const environment = (
overlays: readonly (ProcessEnvRecord | undefined)[] = [],
): Record<string, string> => {
const baseEnv =
input.policy.environment.type === "inherit-sanitized-host"
? input.policy.environment.hostEnvironment
: (input.policy.environment.baseEnvironment ?? {});
return createProviderEnv({ baseEnv, overlays: [...overlays] }) as Record<string, string>;
};
const run = (launch: ProviderWorkspaceLaunchInput): Promise<WorkspaceProcess> =>
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<string> {
return workspace.resolveExecutable(command);
}
export function spawnWorkspaceProviderProcess(input: {
workspace: ProviderWorkspace;
argv: readonly [string, ...string[]];
env?: ProcessEnvRecord;
purpose: WorkspaceProcessPurpose;
}): Promise<ChildProcessWithoutNullStreams> {
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<void> {
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<boolean> {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
process.exited.then(() => true),
new Promise<false>((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<Uint8Array>;
encoding?: "utf8" | "base64";
}): Uint8Array | AsyncIterable<Uint8Array> {
if (typeof input.content !== "string") return input.content;
return Buffer.from(input.content, input.encoding ?? "utf8");
}
async function writeProcessInput(
process: WorkspaceProcess,
contents: Uint8Array | AsyncIterable<Uint8Array>,
): Promise<void> {
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<void> {
return new Promise((resolve, reject) => {
stream.once("drain", resolve);
stream.once("error", reject);
});
}
async function collect(stream: NodeJS.ReadableStream | AsyncIterable<Uint8Array>): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
@@ -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<string, string>();
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<readonly string[]> = [];
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<string, string>();
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<string, string>();
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<string, string>();
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 });
}
});
});
+23 -9
View File
@@ -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<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
providerEnvironment?: Readonly<Record<string, string>>;
}
>
>;
@@ -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,
});
@@ -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<void> {
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);
@@ -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)
@@ -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<ProviderSnapshotManager>({
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<FetchCatalogOptions | undefined> = [];
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<ProviderUsageService>({}),
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"]),
@@ -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<void> {
// 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 {
@@ -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 ?? "<absent>"}\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`);
}
@@ -31,6 +31,7 @@ interface TestPaseoDaemonOptions {
relayConfigCapability?: boolean;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
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",
@@ -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 <command> --root <path>");
@@ -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 {
@@ -13,6 +13,7 @@ export interface WorkspaceHelperProcess {
export interface WorkspaceFilesOwner {
files: WorkspaceFiles;
resolveCommand(command: string): Promise<string | null>;
verify(): Promise<void>;
close(): Promise<void>;
}
@@ -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<WorkspaceHelperProcess>;
}): WorkspaceFilesOwner {
@@ -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<WorkspaceHelperProcess>;
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() {
@@ -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({
@@ -80,7 +80,9 @@ export interface WorkspaceTerminal {
export interface BoundWorkspaceRuntime {
run(input: Omit<WorkspaceProcessInput, "workspaceId">): Promise<WorkspaceProcess>;
resolveCommand(command: string): Promise<string | null>;
readonly files: WorkspaceFiles;
readonly homeFiles: WorkspaceFiles;
}
export interface WorkspaceRuntimeService {
@@ -104,6 +106,8 @@ export interface ExternalWorkspaceRuntime {
command: readonly [string, ...string[]];
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
/** Explicit provider base environment for an isolated runtime. Host env is never inherited. */
providerEnvironment?: Readonly<Record<string, string>>;
}
export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore {
@@ -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<string, WorkspaceFiles>();
const unavailableFiles = new Map<string, "paused" | "destroyed">();
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<WorkspaceFiles> {
return (await requireFilesOwner(workspaceId)).files;
}
async function requireFilesOwner(workspaceId: string): Promise<WorkspaceFilesOwner> {
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<WorkspaceFilesOwner> {
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<void> {
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(
@@ -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("<absent>\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<string> {
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"], {
@@ -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);
});