From 2b171c6c590489acf79ad9528edf809c1c704a91 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:35:03 -0700 Subject: [PATCH 1/7] feat(agent-launch): accept a create-folder-workspace target agent.launch can now create a folder workspace and start its agent there, the same operation it already runs for a new git worktree. The host creates the folder workspace (metadata only, no startup terminal), resolves it the way an existing target is resolved, then settles the mode and builds the surface as for any existing folder workspace. Hosts advertise agent.launch.create-folder-workspace.v1 so a client offers the target only to a host that accepts it. --- .../agent-launch-executor.test.ts | 31 ++++++- .../agent-launch/agent-launch-executor.ts | 20 ++--- .../agent-launch-opencode-model.test.ts | 7 +- .../agent-launch-surface-factories.ts | 4 + ...ent-launch-folder-workspace-create.test.ts | 88 +++++++++++++++++++ .../agent-launch-folder-workspace-creation.ts | 38 ++++++++ .../methods/agent-launch-intent-resolution.ts | 4 +- .../methods/agent-launch-worktree-creation.ts | 2 + .../rpc/methods/agent-launch.test-fixture.ts | 1 + src/shared/agent-launch-intent.ts | 2 + src/shared/agent-launch-operation.ts | 1 + src/shared/agent-launch-runtime-capability.ts | 8 +- .../rpc-contract/agent-launch-params.ts | 6 ++ src/shared/workspace-launch-kind.ts | 13 +++ 14 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 30e652a6cb4c..23fb149a9a16 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -49,6 +49,10 @@ function harness(options: { } } ) + const createFolderWorkspace = vi.fn(async (_args: { create: Record }) => { + calls.push('createFolderWorkspace') + return { worktreeId: 'folder:fw-new', connectionId: null } + }) const getStructuredAgentSessionCreateSupport = vi.fn(async () => { calls.push('createSupport') if (options.createSupportThrows) { @@ -83,6 +87,7 @@ function harness(options: { return { calls, createWorktree, + createFolderWorkspace, createStructuredSession, createTerminalAgent, deliverStructuredPrompt, @@ -98,7 +103,7 @@ function harness(options: { deliverStructuredPrompt, deliverTerminalPrompt }, - workspaces: { createWorktree }, + workspaces: { createWorktree, createFolderWorkspace }, ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) }) } @@ -197,6 +202,30 @@ describe('a structured launch that creates its own worktree', () => { ]) }) + it('creates a folder workspace, then opens its structured session there', async () => { + const h = harness({}) + const result = await h.run({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }) + + expect(h.calls).toEqual(['createFolderWorkspace', 'createSupport', 'createStructuredSession']) + expect(h.createFolderWorkspace).toHaveBeenCalledWith({ create: { projectGroupId: 'group-1' } }) + expect(result.worktreeId).toBe('folder:fw-new') + expect(result.outcome.kind).toBe('structured') + }) + + it('starts a terminal agent in a new folder workspace, not agent-first', async () => { + const h = harness({ settings: null }) + const result = await h.run({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }) + + expect(h.calls).toEqual(['createFolderWorkspace', 'createTerminalAgent']) + expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal' } }) + }) + it('strips a stale startupAgent out of a migrated create payload', async () => { const h = harness({}) await h.run({ diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 3b5794a0ba74..95d72ea114d7 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -42,10 +42,7 @@ import { settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' -import { - workspaceKindForWorktreeId, - type WorkspaceLaunchKind -} from '../../shared/workspace-launch-kind' +import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { deriveAgentLaunchTerminalViewMode } from './agent-launch-view-mode' import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal' @@ -98,7 +95,7 @@ export async function executeAgentLaunch( const preflight = decideAgentLaunchMode({ placement: { agent: intent.agent, - workspaceKind: launchWorkspaceKind(intent.target), + workspaceKind: workspaceKindForLaunchTarget(intent.target), ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}), ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.target.kind === 'existing' && intent.target.workspacePath @@ -251,6 +248,10 @@ async function resolveWorkspace( throw new Error('agent_launch_workspace_factory_required') } execution.onStage?.('worktree_create') + if (intent.target.kind === 'create-folder-workspace') { + const created = await workspaces.createFolderWorkspace({ create: intent.target.create }) + return { ...created, startupTerminalHandle: undefined } + } const startupPrompt = launchCommandPrompt(intent, preflight.mode) const created = await workspaces.createWorktree({ // A caller migrating from `worktree.create` passes its existing params; a stale `startupAgent` @@ -389,12 +390,3 @@ function combineLaunchWarnings( function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } - -/** - * Read from the id rather than carried alongside it, so the kind cannot disagree with the workspace - * it describes. `worktree` here is never a caller's selector — the method resolved it to an id - * before building the intent — and a create always produces a git worktree. - */ -function launchWorkspaceKind(target: AgentLaunchTarget): WorkspaceLaunchKind { - return target.kind === 'existing' ? workspaceKindForWorktreeId(target.worktree) : 'git-worktree' -} diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts index 78446330fddd..879f27ab1b52 100644 --- a/src/main/agent-launch/agent-launch-opencode-model.test.ts +++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts @@ -35,7 +35,12 @@ function harness() { }, deliverTerminalPrompt: deliverPrompt }, - workspaces: { createWorktree } + workspaces: { + createWorktree, + createFolderWorkspace: async () => { + throw new Error('folder_create_not_expected') + } + } }) } } diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index b9cf2e8c6b5c..6b1f00557df1 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -130,4 +130,8 @@ export type AgentLaunchWorkspaceFactory = { /** Reported by the create that built the startup command's typed line. */ promptRodeLaunchCommand?: boolean }> + /** A folder workspace has no startup terminal: the launch starts its agent there afterwards. */ + createFolderWorkspace(args: { + create: Readonly> + }): Promise<{ worktreeId: string; connectionId: string | null }> } diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts new file mode 100644 index 000000000000..1f4402c79e6b --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts @@ -0,0 +1,88 @@ +/** + * `agent.launch` with a `create-folder-workspace` target: the host creates the folder workspace, + * then starts the agent in it exactly as it would in a folder workspace that already existed. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.fn(async (_args: Record) => ({ + ok: true as const, + value: { sessionId: 'sess-1' } +})) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (args: Record) => + createStructuredSession(args) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +const FOLDER_LAUNCH = { + agent: 'claude', + target: { + kind: 'create-folder-workspace', + create: { projectGroupId: 'group-1', name: 'notes', createdWithAgent: 'codex' } + } +} + +function launch(params: unknown, runtime: AgentLaunchRuntimeStub, context: Partial) { + return AGENT_LAUNCH.handler(AGENT_LAUNCH.params.parse(params), rpcContext(runtime, context)) +} + +beforeEach(() => { + createStructuredSession.mockClear() +}) + +describe('agent.launch creating a folder workspace', () => { + it('creates the workspace, then starts a terminal agent in it', async () => { + const runtime = runtimeStub({ settings: {} }) + + const result = await launch(FOLDER_LAUNCH, runtime, CAPABLE_CLIENT) + + expect(runtime.createFolderWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectGroupId: 'group-1', + name: 'notes', + // The launch's agent, not the one the create payload named. + createdWithAgent: 'claude', + creatorProvenance: expect.anything() + }) + ) + expect(runtime.showTerminalWorkspaceLaunchScope).toHaveBeenCalledWith('id:folder:fw-new') + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(runtime.createTerminal).toHaveBeenCalledWith('id:folder:fw-new', expect.anything()) + expect(result).toMatchObject({ + worktreeId: 'folder:fw-new', + outcome: { kind: 'terminal', handle: 'term_1' } + }) + }) + + it('opens a structured chat in the new workspace when that is the default', async () => { + const runtime = runtimeStub() + + const result = await launch(FOLDER_LAUNCH, runtime, CAPABLE_CLIENT) + + expect(createStructuredSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: 'id:folder:fw-new' }) + ) + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'structured' } }) + }) + + it('rejects a folder create with no project group, the same as folderWorkspace.create', () => { + const parsed = AGENT_LAUNCH.params.safeParse({ + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { name: 'notes' } } + }) + expect(parsed.success).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts new file mode 100644 index 000000000000..560a94c07e31 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-creation.ts @@ -0,0 +1,38 @@ +/** + * Creating the folder workspace an `agent.launch` asks for. A folder workspace is metadata only — + * no git, no setup, no startup terminal — so the launch then starts its agent there exactly as it + * would in a folder workspace that already existed. + */ + +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentLaunchWorkspaceFactory } from '../../../agent-launch/agent-launch-surface-factories' +import type { RpcContext } from '../core' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' +import type { AgentLaunchParams } from './agent-launch-schemas' + +type FolderWorkspaceCreateParams = Extract< + AgentLaunchParams['target'], + { kind: 'create-folder-workspace' } +>['create'] + +export function agentLaunchFolderWorkspaceCreator( + context: RpcContext, + agent: TuiAgent +): AgentLaunchWorkspaceFactory['createFolderWorkspace'] { + return async ({ create }) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`, and the intent copies the parsed payload unchanged. + const params = create as FolderWorkspaceCreateParams + const workspace = await context.runtime.createFolderWorkspace({ + ...params, + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context), + // The launch owns the agent, as it does for a worktree it creates. + createdWithAgent: agent + }) + // The same resolution an `existing` target gets, so the id and connection match it exactly. + const scope = await context.runtime.showTerminalWorkspaceLaunchScope( + `id:${folderWorkspaceKey(workspace.id)}` + ) + return { worktreeId: scope.id, connectionId: scope.connectionId } + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts index b172a1b62b8f..8fad5220e4c3 100644 --- a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts +++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts @@ -24,8 +24,8 @@ async function agentLaunchTarget( params: AgentLaunchParams, runtime: Pick ): Promise { - if (params.target.kind === 'create-worktree') { - return { kind: 'create-worktree', create: { ...params.target.create } } + if (params.target.kind !== 'existing') { + return { kind: params.target.kind, create: { ...params.target.create } } } const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) return { diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 72cc48c3bc3e..f2a038e7a436 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -23,6 +23,7 @@ import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-conte import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import type { AgentLaunchParams } from './agent-launch-schemas' +import { agentLaunchFolderWorkspaceCreator } from './agent-launch-folder-workspace-creation' type WorktreeCreateParams = Extract< AgentLaunchParams['target'], @@ -36,6 +37,7 @@ export function agentLaunchWorkspaceFactory( agent: TuiAgent ): AgentLaunchWorkspaceFactory { return { + createFolderWorkspace: agentLaunchFolderWorkspaceCreator(context, agent), createWorktree: async ({ create, startupAgent, diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index d41f250ad969..d4fe858492bf 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -100,6 +100,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } ), showRepo: vi.fn(async () => ({ id: 'repo-1' })), + createFolderWorkspace: vi.fn(async (_input: Record) => ({ id: 'fw-new' })), createManagedWorktree: vi.fn(async (args: Record) => { reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt) if (args.startupAgent && options.startupTerminalPaneKey) { diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index b16287660f7d..0083293cdbd1 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -52,6 +52,8 @@ export type AgentLaunchTarget = /** A worktree this launch creates. `create` is the `worktree.create` request minus its agent * fields — the launch owns those, so a caller cannot set a startup agent behind the router. */ | { kind: 'create-worktree'; create: Readonly> } + /** A folder workspace this launch creates. `create` is the `folderWorkspace.create` request. */ + | { kind: 'create-folder-workspace'; create: Readonly> } /** An existing terminal the caller wants reused rather than a fresh surface. Always resolves to a * terminal agent: a running PTY keeps its execution transport. */ diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index fcd4b191172b..920841d568c6 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -31,6 +31,7 @@ export type AgentLaunchFingerprintInput = { target: | { kind: 'existing'; worktree: string } | { kind: 'create-worktree'; create: Readonly> } + | { kind: 'create-folder-workspace'; create: Readonly> } prompt?: { text: string; delivery: string } sessionOptions?: Readonly> reuseTerminal?: { handle: string } diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 060766828e27..d63e3895548e 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -38,6 +38,11 @@ export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY = // may publish the launch's tab before it admits the launch. export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement.v1' as const +// Host-advertised: accepts `target.kind: 'create-folder-workspace'`. An older host refuses the +// unknown target kind, so a client offers the new-folder-workspace launch only on this. +export const AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY = + 'agent.launch.create-folder-workspace.v1' as const + // Client-advertised only: the client reads a listed launch tab with no terminal yet as "not // started", never as proof its agent runs. The host publishes a paired caller's tab before the // spawn only for a client that says so. @@ -54,5 +59,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY + AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY, + AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index d95f113f4c98..641f2eb4eb1d 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -18,6 +18,7 @@ import { isValidHostTerminalTabId } from '../terminal-tab-id' import { isTuiAgent } from '../tui-agent-config' import type { TuiAgent } from '../tui-agent' import { WorktreeCreate } from './worktree-create-params' +import { FolderWorkspaceCreate } from './folder-workspace-params' import { LaunchSourceParam } from './launch-source-param' import { TerminalTabIdParam } from './agent-session-params' import { SessionId } from './structured-agent-session-params' @@ -69,6 +70,11 @@ export const AgentLaunchFields = z.object({ /** The `worktree.create` request verbatim, so a caller migrating to this method keeps its * existing payload; the agent fields in it are stripped rather than honoured. */ create: WorktreeCreate + }), + z.object({ + kind: z.literal('create-folder-workspace'), + /** The `folderWorkspace.create` request verbatim; its `createdWithAgent` is the launch's own. */ + create: FolderWorkspaceCreate }) ]), prompt: z diff --git a/src/shared/workspace-launch-kind.ts b/src/shared/workspace-launch-kind.ts index a81621bc28b7..d4b6a9996637 100644 --- a/src/shared/workspace-launch-kind.ts +++ b/src/shared/workspace-launch-kind.ts @@ -10,6 +10,7 @@ * never take the answer from a caller, so it derives it here from the id it resolved itself. */ +import type { AgentLaunchTarget } from './agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { parseWorkspaceKey } from './workspace-scope' @@ -21,3 +22,15 @@ export function workspaceKindForWorktreeId(worktreeId: string): WorkspaceLaunchK } return parseWorkspaceKey(worktreeId)?.type === 'folder' ? 'folder' : 'git-worktree' } + +/** + * Read from the id rather than carried alongside it, so the kind cannot disagree with the workspace + * it describes. An existing target's `worktree` is already resolved to an id, never a caller's + * selector; a create's kind is what it creates. + */ +export function workspaceKindForLaunchTarget(target: AgentLaunchTarget): WorkspaceLaunchKind { + if (target.kind === 'existing') { + return workspaceKindForWorktreeId(target.worktree) + } + return target.kind === 'create-folder-workspace' ? 'folder' : 'git-worktree' +} From bc64f9801ccc83bf78125635e31dfdb6dce882c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:56 -0700 Subject: [PATCH 2/7] fix(agent-launch): authorize, classify and navigate folder creates like their create method A create-folder-workspace launch could do three things its two-call equivalent never did: - A phone could create folder workspaces. The mobile allowlist admits agent.launch but not folderWorkspace.create, and is checked by method name only. A create target now needs the authorization of the create method it stands for, read from the same allowlist, and a phone is refused with agent_launch_target_forbidden before admission, so no ledger row and no create. create-worktree maps to worktree.create, which phones already may call, so nothing changes for it. - A create refused before anything was stored (group gone, folder missing, SSH host unreachable) replayed as agent_session_operation_unknown, and a retry under the same operation id was refused until the row expired. runtime.createFolderWorkspace now raises those refusals as FolderWorkspaceCreateRefusedError, with the same message text, and the launch records it as failed for any target. A failure after the create still replays unknown. - A paired caller's folder create activated the new chat on the host window and did not select the caller's own tab. Only create-worktree, whose host activation runs setup, keeps the host navigation now; a folder create navigates like an existing workspace. Tests cover the phone refusal, replay of a success, a refused create settling failed and replaying that failure, an after-create failure staying unknown, an SSH connection reaching the agent's view mode, paired navigation, and an older host refusing the target at parse. --- .../folder-workspace-create-refusal.ts | 3 + .../folder-workspace-path-status.ts | 20 ++- src/main/runtime/rpc/errors.ts | 2 + .../agent-launch-caller-selection.test.ts | 23 ++- .../methods/agent-launch-caller-selection.ts | 9 +- .../rpc/methods/agent-launch-failure-code.ts | 9 +- ...ent-launch-folder-workspace-create.test.ts | 163 +++++++++++++++++- .../agent-launch-target-authorization.ts | 29 ++++ .../rpc/methods/agent-launch.test-fixture.ts | 17 +- src/main/runtime/rpc/methods/agent-launch.ts | 3 + ...ect-group-controller-folder-create.test.ts | 46 +++++ .../runtime-project-group-controller.ts | 12 +- src/shared/agent-launch-target-forbidden.ts | 9 + .../rpc-contract/agent-launch-params.test.ts | 14 ++ 14 files changed, 328 insertions(+), 31 deletions(-) create mode 100644 src/main/project-groups/folder-workspace-create-refusal.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-target-authorization.ts create mode 100644 src/main/runtime/runtime-project-group-controller-folder-create.test.ts create mode 100644 src/shared/agent-launch-target-forbidden.ts diff --git a/src/main/project-groups/folder-workspace-create-refusal.ts b/src/main/project-groups/folder-workspace-create-refusal.ts new file mode 100644 index 000000000000..ae03f55aed5a --- /dev/null +++ b/src/main/project-groups/folder-workspace-create-refusal.ts @@ -0,0 +1,3 @@ +// Marks a folder workspace create refused before anything was stored. Its message is the code the +// create has always thrown, so every caller reads the same refusal it did before. +export class FolderWorkspaceCreateRefusedError extends Error {} diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index dbbd34a70343..ef146482f32f 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -212,18 +212,26 @@ export async function getFolderWorkspacePathStatus( ) } -export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void { +/** The code a folder workspace refuses this path with, or null when the path is usable. */ +export function folderWorkspacePathRefusal(status: FolderWorkspacePathStatus): string | null { if (status.exists) { - return + return null } if (status.reason === 'missing') { - throw new Error(`folder_workspace_path_missing:${status.path}`) + return `folder_workspace_path_missing:${status.path}` } if (status.reason === 'not-directory') { - throw new Error(`folder_workspace_path_not_directory:${status.path}`) + return `folder_workspace_path_not_directory:${status.path}` } if (status.reason === 'ambiguous-connection') { - throw new Error(`folder_workspace_connection_ambiguous:${status.path}`) + return `folder_workspace_connection_ambiguous:${status.path}` + } + return `folder_workspace_path_unavailable:${status.path}` +} + +export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void { + const refusal = folderWorkspacePathRefusal(status) + if (refusal) { + throw new Error(refusal) } - throw new Error(`folder_workspace_path_unavailable:${status.path}`) } diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 6be04e9feba9..3c8dd53f5c22 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -34,6 +34,7 @@ import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/wo import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launch-pane-already-live' import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists' import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' +import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../shared/agent-launch-target-forbidden' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -68,6 +69,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE, AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE, AGENT_LAUNCH_TAB_CLOSED_CODE, + AGENT_LAUNCH_TARGET_FORBIDDEN_CODE, 'agent_launch_replay_unsupported', 'runtime_unavailable', 'selector_not_found', diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 76afbc093349..50cbb63c1ef9 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -1,6 +1,6 @@ /** * A launch from a paired client moves that client's view to the new tab and nobody else's: the view - * intent belongs to the connection that asked. In-process callers and workspace-creating launches + * intent belongs to the connection that asked. In-process callers and worktree-creating launches * keep today's behaviour. */ @@ -45,6 +45,10 @@ const CREATE_LAUNCH = { agent: 'claude', target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } } +const FOLDER_LAUNCH = { + agent: 'claude', + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } +} const CALLER = 'device-1' const REVEAL_WARNING = 'Terminal term_1 is running, but Orca could not make it discoverable. Run `orca terminal focus --terminal term_1` to reveal and focus it.' @@ -150,6 +154,21 @@ describe('a paired client launching into an existing workspace', () => { expect(result.warning).toBeUndefined() }) + // A folder workspace has no setup for a host activation to run, so its create moves only the caller. + it('selects the chat in a folder workspace it creates without activating it on the host', async () => { + const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) + + // A desktop client of a remote server; a phone may not create folder workspaces at all. + await launch(FOLDER_LAUNCH, runtime, { ...CAPABLE_CLIENT, clientKind: 'runtime' }) + + expect(chatActivation()).toBe(false) + expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledExactlyOnceWith( + 'folder:fw-new', + { sessionId: 'sess-1' }, + CALLER + ) + }) + it('selects nothing when the runtime reported no pane for the terminal', async () => { const runtime = selectionRuntime({ settings: {} }) @@ -190,7 +209,7 @@ describe('launches that keep the host-wide behaviour', () => { expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() }) - it('a workspace-creating launch from a paired client keeps the create navigation', async () => { + it('a worktree-creating launch from a paired client keeps the create navigation', async () => { const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) await launch(CREATE_LAUNCH, runtime) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 2260c1af2f2c..b1f90bcf43f6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -3,9 +3,10 @@ * * A paired client (a phone, or a desktop client of a remote server) that launches into an existing * workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal` - * selects the tab it creates for its caller. In-process callers keep today's behaviour, and a - * launch that creates its workspace keeps `worktree.create`'s navigation, whose host activation is - * what runs the new workspace's setup. + * selects the tab it creates for its caller. In-process callers keep today's behaviour. A launch + * that creates a git worktree keeps `worktree.create`'s navigation, whose host activation is what + * runs the new workspace's setup. A folder workspace has no setup, so its create navigates like an + * existing workspace and leaves the host where it was. */ import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' @@ -19,7 +20,7 @@ export function agentLaunchCallerNavigationId( target: AgentLaunchTarget, context: Pick ): string | null { - return target.kind === 'existing' && context.caller?.kind === 'paired-device' + return target.kind !== 'create-worktree' && context.caller?.kind === 'paired-device' ? context.caller.deviceId : null } diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts index bf3b4a2ceaaf..1cadc069742f 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -13,6 +13,7 @@ import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../../shared/agent-launch-session-already-exists' import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folder-workspace-create-refusal' /** Long enough for every code this path raises, with room for one a later guard adds. */ const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 @@ -35,8 +36,9 @@ export function agentLaunchFailureCode(error: unknown): string { /** * Only a typed refusal raised before anything was created proves the claimed launch had no effects. - * A live reserved pane or an existing reserved session proves it only for an existing workspace; on - * create-worktree the workspace already exists by the time the surface is refused. + * A refused create proves it for any target, since the create is the launch's first effect. A live + * reserved pane or an existing reserved session proves it only for an existing workspace; on a + * create target the workspace already exists by the time the surface is refused. */ export function launchFailureWithoutEffectsCode( error: unknown, @@ -46,6 +48,9 @@ export function launchFailureWithoutEffectsCode( if (error instanceof WorktreeCreateCollisionError) { return WORKTREE_CREATE_COLLISION_CODE } + if (error instanceof FolderWorkspaceCreateRefusedError) { + return agentLaunchFailureCode(error) + } if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts index 1f4402c79e6b..539a889ee143 100644 --- a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts @@ -3,13 +3,21 @@ * then starts the agent in it exactly as it would in a folder workspace that already existed. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../../shared/agent-launch-target-forbidden' +import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folder-workspace-create-refusal' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { RpcContext } from '../core' import { CAPABLE_CLIENT, methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' @@ -25,6 +33,7 @@ vi.mock('./structured-agent-session-create', () => ({ const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') const FOLDER_LAUNCH = { agent: 'claude', @@ -33,8 +42,20 @@ const FOLDER_LAUNCH = { create: { projectGroupId: 'group-1', name: 'notes', createdWithAgent: 'codex' } } } +// A desktop client of a remote server: paired, but not a phone, so it may create folder workspaces. +const PAIRED_DESKTOP: Partial = { ...CAPABLE_CLIENT, clientKind: 'runtime' } -function launch(params: unknown, runtime: AgentLaunchRuntimeStub, context: Partial) { +function folderRuntime(options: Parameters[0] = {}) { + return Object.assign(runtimeStub(options), { + selectCreatedMobileSessionTabForClient: vi.fn(() => true) + }) +} + +function launch( + params: unknown, + runtime: AgentLaunchRuntimeStub, + context: Partial = PAIRED_DESKTOP +) { return AGENT_LAUNCH.handler(AGENT_LAUNCH.params.parse(params), rpcContext(runtime, context)) } @@ -44,9 +65,9 @@ beforeEach(() => { describe('agent.launch creating a folder workspace', () => { it('creates the workspace, then starts a terminal agent in it', async () => { - const runtime = runtimeStub({ settings: {} }) + const runtime = folderRuntime({ settings: {} }) - const result = await launch(FOLDER_LAUNCH, runtime, CAPABLE_CLIENT) + const result = await launch(FOLDER_LAUNCH, runtime) expect(runtime.createFolderWorkspace).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,7 +75,7 @@ describe('agent.launch creating a folder workspace', () => { name: 'notes', // The launch's agent, not the one the create payload named. createdWithAgent: 'claude', - creatorProvenance: expect.anything() + creatorProvenance: { kind: 'paired-device', deviceId: 'device-1' } }) ) expect(runtime.showTerminalWorkspaceLaunchScope).toHaveBeenCalledWith('id:folder:fw-new') @@ -67,9 +88,9 @@ describe('agent.launch creating a folder workspace', () => { }) it('opens a structured chat in the new workspace when that is the default', async () => { - const runtime = runtimeStub() + const runtime = folderRuntime() - const result = await launch(FOLDER_LAUNCH, runtime, CAPABLE_CLIENT) + const result = await launch(FOLDER_LAUNCH, runtime) expect(createStructuredSession).toHaveBeenCalledWith( expect.objectContaining({ worktree: 'id:folder:fw-new' }) @@ -78,6 +99,38 @@ describe('agent.launch creating a folder workspace', () => { expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'structured' } }) }) + // OpenCode's chat view reads its transcript locally, so on an SSH host its tab opens as the terminal. + it.each([ + ['an SSH', 'ssh-1', 'terminal'], + ['a local', null, 'chat'] + ] as const)( + 'keeps %s folder workspace connection through to the agent it starts', + async (_where, connectionId, viewMode) => { + const runtime = folderRuntime({ createSupport: { supported: false, reason: 'remote' } }) + runtime.showTerminalWorkspaceLaunchScope.mockImplementationOnce(async (selector: string) => ({ + id: selector.replace(/^id:/, ''), + path: '/srv/notes', + connectionId, + repo: null, + folderWorkspace: null + })) + const create = { ...FOLDER_LAUNCH.target.create, connectionId } + + await launch( + { ...FOLDER_LAUNCH, agent: 'opencode', target: { ...FOLDER_LAUNCH.target, create } }, + runtime + ) + + expect(runtime.createFolderWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ connectionId }) + ) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:folder:fw-new', + expect.objectContaining({ viewMode }) + ) + } + ) + it('rejects a folder create with no project group, the same as folderWorkspace.create', () => { const parsed = AGENT_LAUNCH.params.safeParse({ agent: 'claude', @@ -86,3 +139,99 @@ describe('agent.launch creating a folder workspace', () => { expect(parsed.success).toBe(false) }) }) + +describe('a phone asking for a folder workspace', () => { + // `folderWorkspace.create` is not on the mobile allowlist, so the launch may not do it either. + const PHONE_LAUNCH = { + ...FOLDER_LAUNCH, + operationId: `${Date.now()}-000000000000000000000000000000ee` + } + + it.each([ + [ + 'agent.launch', + (runtime: AgentLaunchRuntimeStub) => launch(PHONE_LAUNCH, runtime, CAPABLE_CLIENT) + ], + [ + 'agent.launchReplay', + (runtime: AgentLaunchRuntimeStub) => + AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse(PHONE_LAUNCH), + rpcContext(runtime, CAPABLE_CLIENT) + ) + ] + ])('%s refuses it before admitting or creating anything', async (_method, send) => { + const runtime = folderRuntime() + + await expect(send(runtime)).rejects.toThrow(AGENT_LAUNCH_TARGET_FORBIDDEN_CODE) + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.createFolderWorkspace).not.toHaveBeenCalled() + }) +}) + +describe('a replayed folder launch', () => { + // The ledger admits against `Date.now()`, so the id must be dated now. + const OPERATION_ID = `${Date.now()}-000000000000000000000000000000ff` + const PATH_UNAVAILABLE = 'folder_workspace_path_unavailable:/srv/notes' + let directory: string + let store: AgentSessionRecordStore + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-folder-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) + }) + + afterEach(async () => { + setAgentLaunchRecordStore(null) + await rm(directory, { recursive: true, force: true }) + }) + + function replay(runtime: AgentLaunchRuntimeStub) { + const params = AGENT_LAUNCH_REPLAY.params.parse({ ...FOLDER_LAUNCH, operationId: OPERATION_ID }) + return AGENT_LAUNCH_REPLAY.handler(params, rpcContext(runtime, PAIRED_DESKTOP)) + } + + function outcome() { + return store.listOperationRows().find((row) => row.operationId === OPERATION_ID)?.outcome + } + + it('answers a retry from the record without creating a second workspace', async () => { + await replay(folderRuntime({ settings: {} })) + + const retry = folderRuntime({ settings: {} }) + const result = await replay(retry) + + expect(retry.createFolderWorkspace).not.toHaveBeenCalled() + expect(retry.createTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal' } }) + }) + + it('records a create refused before anything was stored as failed, and replays that', async () => { + const first = folderRuntime({ settings: {} }) + first.createFolderWorkspace.mockRejectedValueOnce( + new FolderWorkspaceCreateRefusedError(PATH_UNAVAILABLE) + ) + + await expect(replay(first)).rejects.toThrow(PATH_UNAVAILABLE) + expect(outcome()).toMatchObject({ status: 'failed', code: PATH_UNAVAILABLE }) + + const retry = folderRuntime({ settings: {} }) + await expect(replay(retry)).rejects.toThrow(PATH_UNAVAILABLE) + expect(retry.createFolderWorkspace).not.toHaveBeenCalled() + }) + + it('stays unknown when the failure came after the workspace was created', async () => { + const first = folderRuntime({ settings: {} }) + // The same words as a refused create, but untyped: only the create's own refusal proves nothing. + first.showTerminalWorkspaceLaunchScope.mockRejectedValueOnce(new Error(PATH_UNAVAILABLE)) + + await expect(replay(first)).rejects.toThrow('agent_session_operation_unknown') + expect(first.createFolderWorkspace).toHaveBeenCalledOnce() + expect(outcome()?.status).not.toBe('failed') + + const retry = folderRuntime({ settings: {} }) + await expect(replay(retry)).rejects.toThrow('agent_session_operation_unknown') + expect(retry.createFolderWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-target-authorization.ts b/src/main/runtime/rpc/methods/agent-launch-target-authorization.ts new file mode 100644 index 000000000000..3f10768a162d --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-target-authorization.ts @@ -0,0 +1,29 @@ +/** + * A launch that creates its workspace performs that create, so it needs the create method's own + * authorization. The mobile allowlist is enforced per method name before dispatch, and `agent.launch` + * being on it must not let a phone make a workspace its create method would be refused. Reading the + * same list keeps the two from drifting. + */ + +import { AgentLaunchTargetForbiddenError } from '../../../../shared/agent-launch-target-forbidden' +import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist' +import type { RpcContext } from '../core' + +const CREATE_METHOD_BY_TARGET_KIND = { + 'create-worktree': 'worktree.create', + 'create-folder-workspace': 'folderWorkspace.create' +} as const satisfies Record, string> + +export function assertAgentLaunchTargetAuthorized( + target: Pick, + context: Pick +): void { + // `clientKind` is the paired device's scope, stamped from its validated token. + if (target.kind === 'existing' || context.clientKind !== 'mobile') { + return + } + if (!MOBILE_RPC_METHOD_ALLOWLIST.has(CREATE_METHOD_BY_TARGET_KIND[target.kind])) { + throw new AgentLaunchTargetForbiddenError() + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index d4fe858492bf..a99b13b71c6d 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -11,6 +11,7 @@ import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch import type { RpcContext } from '../core' import { resolveRpcCallerIdentity } from '../rpc-caller-identity' import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { AgentLaunchTabPublished, AgentLaunchTabPublishRequest @@ -141,13 +142,15 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { })), // The scope resolves for every workspace kind, so unlike the worktree record above it never // refuses the floating sentinel — which is the whole reason the launch asks for this one. - showTerminalWorkspaceLaunchScope: vi.fn(async (selector: string) => ({ - id: selector.replace(/^id:/, ''), - path: '/tmp/wt-7', - connectionId: null, - repo: null, - folderWorkspace: null - })), + showTerminalWorkspaceLaunchScope: vi.fn( + async (selector: string): Promise => ({ + id: selector.replace(/^id:/, ''), + path: '/tmp/wt-7', + connectionId: null, + repo: null, + folderWorkspace: null + }) + ), ensureStructuredAgentSessionHost: vi.fn(async () => {}), openAgentSessionRecordStore: vi.fn(async (): Promise => { if (!launchRecordStore) { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 15aeb28bd125..bb9753da1f97 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -54,6 +54,7 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { assertAgentLaunchTargetAuthorized } from './agent-launch-target-authorization' import { clientRendersStructuredAgent } from './structured-agent-session-policy' import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' import { @@ -310,6 +311,7 @@ export const AGENT_LAUNCH_METHODS = [ if (!supportsAgentLaunch(context)) { throw new Error('agent_launch_replay_unsupported') } + assertAgentLaunchTargetAuthorized(params.target, context) try { return await runReplaySafeAgentLaunch(params, context) } catch (error) { @@ -339,6 +341,7 @@ export const AGENT_LAUNCH_METHODS = [ if (!supportsAgentLaunch(context)) { throw new Error('agent_launch_unsupported') } + assertAgentLaunchTargetAuthorized(params.target, context) if (!params.operationId) { return runLegacyAgentLaunch(params, context) } diff --git a/src/main/runtime/runtime-project-group-controller-folder-create.test.ts b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts new file mode 100644 index 000000000000..2c43019ec100 --- /dev/null +++ b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectGroupController } from './runtime-project-group-controller' +import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' + +const MISSING_PATH = join(tmpdir(), 'orca-folder-create-missing-9c1f') + +function createController() { + const createFolderWorkspace = vi.fn() + const notifyReposChanged = vi.fn() + const controller = new RuntimeProjectGroupController({ + getStore: () => + ({ + getProjectGroups: () => [{ id: 'group-1', parentPath: MISSING_PATH, connectionId: null }], + getRepos: () => [], + createFolderWorkspace + }) as never, + resolveRepo: async () => { + throw new Error('unused') + }, + notifyReposChanged, + resolveFolderConnectionId: () => null, + teardownFolderWorkspacePtys: async () => undefined, + cleanupRemovedFolderWorkspaceState: () => undefined + }) + return { controller, createFolderWorkspace, notifyReposChanged } +} + +// A launch reads these as "nothing was created", so each must come before the store write and keep +// the code `folderWorkspace.create` has always answered with. +describe('RuntimeProjectGroupController.createFolderWorkspace refusals', () => { + it.each([ + ['a group that is gone', 'group-2', 'folder_workspace_project_group_not_found'], + ['a folder that is missing', 'group-1', `folder_workspace_path_missing:${MISSING_PATH}`] + ])('refuses %s as a typed refusal without storing anything', async (_case, groupId, code) => { + const deps = createController() + + const refused = deps.controller.createFolderWorkspace({ projectGroupId: groupId }) + + await expect(refused).rejects.toBeInstanceOf(FolderWorkspaceCreateRefusedError) + await expect(refused).rejects.toThrow(code) + expect(deps.createFolderWorkspace).not.toHaveBeenCalled() + expect(deps.notifyReposChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 2530ef9ad158..52b3f7bdfb6d 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -7,9 +7,11 @@ import type { } from '../../shared/folder-workspace-path-status' import { assertFolderWorkspacePathUsable, + folderWorkspacePathRefusal, getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath } from '../project-groups/folder-workspace-path-status' +import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { RuntimeStore } from './runtime-store-contract' import { folderWorkspaceKey } from '../../shared/workspace-scope' @@ -130,9 +132,10 @@ export class RuntimeProjectGroupController { createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): Promise { + // Every refusal below comes before the store write, so it proves nothing was created. const store = this.deps.getStore() if (!store?.createFolderWorkspace) { - throw new Error('runtime_unavailable') + throw new FolderWorkspaceCreateRefusedError('runtime_unavailable') } const projectGroups = store.getProjectGroups?.() ?? [] const group = projectGroups.find((entry) => entry.id === input.projectGroupId) @@ -141,7 +144,7 @@ export class RuntimeProjectGroupController { ? input.folderPath : group?.parentPath if (!group || !folderPath) { - throw new Error('folder_workspace_project_group_not_found') + throw new FolderWorkspaceCreateRefusedError('folder_workspace_project_group_not_found') } const status = await getFolderWorkspacePathStatusForPath( { @@ -153,7 +156,10 @@ export class RuntimeProjectGroupController { }, { getSshFilesystemProvider } ) - assertFolderWorkspacePathUsable(status) + const pathRefusal = folderWorkspacePathRefusal(status) + if (pathRefusal) { + throw new FolderWorkspaceCreateRefusedError(pathRefusal) + } const workspace = store.createFolderWorkspace({ ...input, creatorProvenance: input.creatorProvenance ?? { kind: 'host' } diff --git a/src/shared/agent-launch-target-forbidden.ts b/src/shared/agent-launch-target-forbidden.ts new file mode 100644 index 000000000000..a3ab01657646 --- /dev/null +++ b/src/shared/agent-launch-target-forbidden.ts @@ -0,0 +1,9 @@ +export const AGENT_LAUNCH_TARGET_FORBIDDEN_CODE = 'agent_launch_target_forbidden' as const + +// The caller may not call the create method this target stands for; refused before admission. +export class AgentLaunchTargetForbiddenError extends Error { + constructor() { + super(AGENT_LAUNCH_TARGET_FORBIDDEN_CODE) + this.name = 'AgentLaunchTargetForbiddenError' + } +} diff --git a/src/shared/rpc-contract/agent-launch-params.test.ts b/src/shared/rpc-contract/agent-launch-params.test.ts index f425920d498c..cd38efeb276b 100644 --- a/src/shared/rpc-contract/agent-launch-params.test.ts +++ b/src/shared/rpc-contract/agent-launch-params.test.ts @@ -7,6 +7,7 @@ */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { AgentLaunch, AgentLaunchFields } from './agent-launch-params' const BASE = { agent: 'claude', target: { kind: 'existing', worktree: 'wt-1' } } @@ -76,6 +77,19 @@ describe('agent.launch params', () => { }) }) + it('has a host from before folder creates refuse that target rather than misread it', () => { + const [existing, createWorktree] = AgentLaunchFields.shape.target.options + const olderHost = AgentLaunchFields.extend({ + target: z.discriminatedUnion('kind', [existing, createWorktree]) + }) + expect( + olderHost.safeParse({ + ...BASE, + target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } } + }).success + ).toBe(false) + }) + it('lets a host from before placement drop it rather than refuse the launch', () => { const olderHost = AgentLaunchFields.omit({ placement: true, presentation: true }) const parsed = olderHost.safeParse({ From ddf6231af599d2addc6b9eb3025b89d746088368 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:45:15 -0700 Subject: [PATCH 3/7] fix(agent-launch): replay a refused folder create with its whole path A folder create refused before anything was stored is recorded as the launch's failure, and a retry under the same operation id answers from that record. The record went through the 128-character cap every launch failure code gets, and folder refusals carry the folder's absolute path, so a retry could show the user a path cut short: a different folder. The refusal is now recorded whole when it fits a path as long as Linux's PATH_MAX after its code. Past that, which no folder a stat accepts reaches, only the code before the path is kept, so the replay degrades to the bare code instead of a partial path. Also: the controller test asserts the exact refusal message, the create-folder-workspace capability comment says a phone needs its own gate, and the forbidden-target code says why it is not `forbidden`. --- .../rpc/methods/agent-launch-failure-code.ts | 17 ++++++++++++++++- ...gent-launch-folder-workspace-create.test.ts | 18 ++++++++++++++++++ ...ject-group-controller-folder-create.test.ts | 2 +- src/shared/agent-launch-runtime-capability.ts | 4 +++- src/shared/agent-launch-target-forbidden.ts | 1 + 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts index 1cadc069742f..55e242e7919f 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -17,6 +17,8 @@ import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folde /** Long enough for every code this path raises, with room for one a later guard adds. */ const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 +/** A path as long as Linux's PATH_MAX, after its code. */ +const FOLDER_CREATE_REFUSAL_MAX_LENGTH = 4096 + LAUNCH_FAILURE_CODE_MAX_LENGTH /** * This path raises its refusals as the thrown code, the way the method's own guards do — and the @@ -34,6 +36,19 @@ export function agentLaunchFailureCode(error: unknown): string { return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' } +/** + * A refused folder create names its folder, and the user is shown that path, so a replay carries all + * of it or none: a path cut short names a different folder. Any path a filesystem stat accepts + * fits; past that, only the code before the path is kept. + */ +function folderCreateRefusalCode(error: FolderWorkspaceCreateRefusedError): string { + const { message } = error + const pathStart = message.indexOf(':') + return message.length <= FOLDER_CREATE_REFUSAL_MAX_LENGTH || pathStart === -1 + ? message + : message.slice(0, pathStart) +} + /** * Only a typed refusal raised before anything was created proves the claimed launch had no effects. * A refused create proves it for any target, since the create is the launch's first effect. A live @@ -49,7 +64,7 @@ export function launchFailureWithoutEffectsCode( return WORKTREE_CREATE_COLLISION_CODE } if (error instanceof FolderWorkspaceCreateRefusedError) { - return agentLaunchFailureCode(error) + return folderCreateRefusalCode(error) } if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts index 539a889ee143..e9d305374140 100644 --- a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts @@ -221,6 +221,24 @@ describe('a replayed folder launch', () => { expect(retry.createFolderWorkspace).not.toHaveBeenCalled() }) + // The path is shown to the user, so a replay names the whole folder or none of it. + it.each([ + ['repeats a long path in full', `/srv/${'nested/'.repeat(30)}notes`, true], + ['drops a path no filesystem accepts', `/srv/${'n'.repeat(5000)}`, false] + ])('%s when it replays a refused create', async (_case, path, keepsPath) => { + const refusal = `folder_workspace_path_missing:${path}` + const first = folderRuntime({ settings: {} }) + first.createFolderWorkspace.mockRejectedValueOnce( + new FolderWorkspaceCreateRefusedError(refusal) + ) + await expect(replay(first)).rejects.toMatchObject({ message: refusal }) + + const replayed = keepsPath ? refusal : 'folder_workspace_path_missing' + await expect(replay(folderRuntime({ settings: {} }))).rejects.toMatchObject({ + message: replayed + }) + }) + it('stays unknown when the failure came after the workspace was created', async () => { const first = folderRuntime({ settings: {} }) // The same words as a refused create, but untyped: only the create's own refusal proves nothing. diff --git a/src/main/runtime/runtime-project-group-controller-folder-create.test.ts b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts index 2c43019ec100..821e9dd7b5f7 100644 --- a/src/main/runtime/runtime-project-group-controller-folder-create.test.ts +++ b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts @@ -39,7 +39,7 @@ describe('RuntimeProjectGroupController.createFolderWorkspace refusals', () => { const refused = deps.controller.createFolderWorkspace({ projectGroupId: groupId }) await expect(refused).rejects.toBeInstanceOf(FolderWorkspaceCreateRefusedError) - await expect(refused).rejects.toThrow(code) + await expect(refused).rejects.toMatchObject({ message: code }) expect(deps.createFolderWorkspace).not.toHaveBeenCalled() expect(deps.notifyReposChanged).not.toHaveBeenCalled() }) diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index d63e3895548e..b751c13b9c48 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -39,7 +39,9 @@ export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY = export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement.v1' as const // Host-advertised: accepts `target.kind: 'create-folder-workspace'`. An older host refuses the -// unknown target kind, so a client offers the new-folder-workspace launch only on this. +// unknown target kind, so a client offers the new-folder-workspace launch only on this. It is not +// enough on its own: the target also needs `folderWorkspace.create` authorization, which a +// mobile-scope device lacks, so a phone folder launch needs its own gate. export const AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY = 'agent.launch.create-folder-workspace.v1' as const diff --git a/src/shared/agent-launch-target-forbidden.ts b/src/shared/agent-launch-target-forbidden.ts index a3ab01657646..b433c5b5b5a3 100644 --- a/src/shared/agent-launch-target-forbidden.ts +++ b/src/shared/agent-launch-target-forbidden.ts @@ -1,6 +1,7 @@ export const AGENT_LAUNCH_TARGET_FORBIDDEN_CODE = 'agent_launch_target_forbidden' as const // The caller may not call the create method this target stands for; refused before admission. +// Not `forbidden`: the replay classifier reads that as an older host and falls back to its old path. export class AgentLaunchTargetForbiddenError extends Error { constructor() { super(AGENT_LAUNCH_TARGET_FORBIDDEN_CODE) From a0568463e51f4e5b0fbf41d2ce3fd040607639bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:56:37 -0700 Subject: [PATCH 4/7] test(folder-workspace): type the controller test's store stub without an assertion The changed-code quality gate refuses the `as never` cast on the stub store. It is now a RuntimeStore whose unreached members throw. --- ...ect-group-controller-folder-create.test.ts | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/runtime-project-group-controller-folder-create.test.ts b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts index 821e9dd7b5f7..70022e8c6254 100644 --- a/src/main/runtime/runtime-project-group-controller-folder-create.test.ts +++ b/src/main/runtime/runtime-project-group-controller-folder-create.test.ts @@ -2,20 +2,49 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { RuntimeProjectGroupController } from './runtime-project-group-controller' +import type { RuntimeStore } from './runtime-store-contract' import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' const MISSING_PATH = join(tmpdir(), 'orca-folder-create-missing-9c1f') +// The store members a folder create never reaches. +const unused = (): never => { + throw new Error('unused') +} + function createController() { - const createFolderWorkspace = vi.fn() + const createFolderWorkspace = vi.fn>() const notifyReposChanged = vi.fn() + const store: RuntimeStore = { + getProjectGroups: () => [ + { + id: 'group-1', + name: 'Notes', + parentPath: MISSING_PATH, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + getRepos: () => [], + createFolderWorkspace, + getRepo: unused, + addRepo: unused, + updateRepo: unused, + getAllWorktreeMeta: unused, + getWorktreeMeta: unused, + setWorktreeMeta: unused, + removeWorktreeMeta: unused, + getGitHubCache: unused, + getSettings: unused + } const controller = new RuntimeProjectGroupController({ - getStore: () => - ({ - getProjectGroups: () => [{ id: 'group-1', parentPath: MISSING_PATH, connectionId: null }], - getRepos: () => [], - createFolderWorkspace - }) as never, + getStore: () => store, resolveRepo: async () => { throw new Error('unused') }, From 1efc5a19218dcc01acbccd5fab2956cb0b3ad82f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:24:11 -0700 Subject: [PATCH 5/7] test(agent-launch): run the folder-create launch tests in the Node runtime project The operationId replay tests open the real SQLite record store, so the file belongs with the other ledger-backed agent.launch tests. --- config/scripts/vitest-sqlite-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 69df486ddc82..9e650a442e2e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -161,6 +161,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/orchestration/orchestration-party-location.test.ts', 'src/main/runtime/orchestration/structured-worker-journal-page.test.ts', 'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts', 'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts', 'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts', 'src/main/runtime/rpc/methods/agent-launch-replay.test.ts', From f0b30d5307bd70058c40857dbcf833aa834ae527 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:32:02 -0700 Subject: [PATCH 6/7] test(agent-launch): pin that a paired folder create's terminal stays off the host window The folder-create test now asserts surfaceOwner: false on the terminal a paired caller's launch creates, rather than matching any options. The caller-selection header now says only an in-process worktree create keeps worktree.create's navigation. --- .../runtime/rpc/methods/agent-launch-caller-selection.ts | 2 +- .../methods/agent-launch-folder-workspace-create.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 9ec5d4295940..40a2300b5a94 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -4,7 +4,7 @@ * A paired client (a phone, or a desktop client of a remote server) that launches into an existing * workspace, or into a folder workspace it creates, gets the new tab as its own selection, recorded * the way `session.tabs.createTerminal` selects the tab it creates for its caller. In-process - * callers keep today's behaviour, and their launch that creates its workspace keeps + * callers keep today's behaviour, and their launch that creates a worktree keeps * `worktree.create`'s navigation. A paired client's worktree create never activates the host * window: its setup and default tabs are provisioned in the background. A folder workspace has * neither, and its create activates nothing, so it is selected for its caller like an existing one. diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts index e9d305374140..698028c69872 100644 --- a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts @@ -80,7 +80,11 @@ describe('agent.launch creating a folder workspace', () => { ) expect(runtime.showTerminalWorkspaceLaunchScope).toHaveBeenCalledWith('id:folder:fw-new') expect(runtime.createManagedWorktree).not.toHaveBeenCalled() - expect(runtime.createTerminal).toHaveBeenCalledWith('id:folder:fw-new', expect.anything()) + // A paired caller's terminal is not surfaced on the host window. + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:folder:fw-new', + expect.objectContaining({ surfaceOwner: false }) + ) expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal', handle: 'term_1' } From 7283f36ed9d084ebfd6ad6e2b6f223103f046927 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:58:38 -0700 Subject: [PATCH 7/7] test(agent-launch): drop the per-connection view-mode expectation main removed --- .../agent-launch-folder-workspace-create.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts index 698028c69872..a9cdf37db554 100644 --- a/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts @@ -103,13 +103,12 @@ describe('agent.launch creating a folder workspace', () => { expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'structured' } }) }) - // OpenCode's chat view reads its transcript locally, so on an SSH host its tab opens as the terminal. it.each([ - ['an SSH', 'ssh-1', 'terminal'], - ['a local', null, 'chat'] + ['an SSH', 'ssh-1'], + ['a local', null] ] as const)( 'keeps %s folder workspace connection through to the agent it starts', - async (_where, connectionId, viewMode) => { + async (_where, connectionId) => { const runtime = folderRuntime({ createSupport: { supported: false, reason: 'remote' } }) runtime.showTerminalWorkspaceLaunchScope.mockImplementationOnce(async (selector: string) => ({ id: selector.replace(/^id:/, ''), @@ -128,10 +127,7 @@ describe('agent.launch creating a folder workspace', () => { expect(runtime.createFolderWorkspace).toHaveBeenCalledWith( expect.objectContaining({ connectionId }) ) - expect(runtime.createTerminal).toHaveBeenCalledWith( - 'id:folder:fw-new', - expect.objectContaining({ viewMode }) - ) + expect(runtime.createTerminal).toHaveBeenCalledWith('id:folder:fw-new', expect.anything()) } )