feat(agent-launch): create a folder workspace and start its agent in one host operation (#26083)

* 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.

* 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.

* 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`.

* 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.

* 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.

* 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.

* test(agent-launch): drop the per-connection view-mode expectation main removed
This commit is contained in:
Brennan Benson
2026-10-09 01:23:06 -07:00
committed by GitHub
parent 6ba86414bf
commit 39a01f9c0b
27 changed files with 614 additions and 44 deletions
@@ -201,6 +201,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-instant-tab.test.ts',
'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts',
'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts',
@@ -28,6 +28,8 @@ function harness(options: {
/** Whether the surface reports that its typed line took the offered prompt. */
lineCarriesPrompt?: boolean
onSurfacePublished?: AgentLaunchExecution['onSurfacePublished']
/** A worktree-only factory, as orchestration workers and `worktree.create` pass. */
worktreeOnlyFactory?: boolean
}) {
const calls: string[] = []
const carried = (startupPrompt: string | undefined) =>
@@ -47,6 +49,10 @@ function harness(options: {
}
}
)
const createFolderWorkspace = vi.fn(async (_args: { create: Record<string, unknown> }) => {
calls.push('createFolderWorkspace')
return { worktreeId: 'folder:fw-new', connectionId: null }
})
const getStructuredAgentSessionCreateSupport = vi.fn(async () => {
calls.push('createSupport')
if (options.createSupportThrows) {
@@ -81,6 +87,7 @@ function harness(options: {
return {
calls,
createWorktree,
createFolderWorkspace,
createStructuredSession,
createTerminalAgent,
deliverStructuredPrompt,
@@ -96,7 +103,9 @@ function harness(options: {
deliverStructuredPrompt,
deliverTerminalPrompt
},
workspaces: { createWorktree },
workspaces: options.worktreeOnlyFactory
? { createWorktree }
: { createWorktree, createFolderWorkspace },
...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {})
})
}
@@ -220,6 +229,41 @@ 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('refuses a folder create from a factory that cannot make one, before anything is created', async () => {
const h = harness({ worktreeOnlyFactory: true })
await expect(
h.run({
agent: 'claude',
target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } }
})
).rejects.toThrow('agent_launch_workspace_factory_required')
expect(h.calls).toEqual([])
})
it('strips a stale startupAgent out of a migrated create payload', async () => {
const h = harness({})
await h.run({
+9 -14
View File
@@ -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 { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal'
import {
decideAgentLaunchMode,
@@ -86,7 +83,7 @@ export async function executeAgentLaunch(
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,13 @@ async function resolveWorkspace(
throw new Error('agent_launch_workspace_factory_required')
}
execution.onStage?.('worktree_create')
if (intent.target.kind === 'create-folder-workspace') {
if (!workspaces.createFolderWorkspace) {
throw new Error('agent_launch_workspace_factory_required')
}
const created = await workspaces.createFolderWorkspace({ create: intent.target.create })
return { ...created, startupTerminalHandle: undefined }
}
const created = await workspaces.createWorktree({
// A caller migrating from `worktree.create` passes its existing params; a stale `startupAgent`
// in there would re-create the agent-first path this executor exists to replace. The launch
@@ -383,12 +387,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'
}
@@ -35,7 +35,12 @@ function harness() {
},
deliverTerminalPrompt: deliverPrompt
},
workspaces: { createWorktree }
workspaces: {
createWorktree,
createFolderWorkspace: async () => {
throw new Error('folder_create_not_expected')
}
}
})
}
}
@@ -134,4 +134,9 @@ 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.
* Only `agent.launch` creates folders; callers that only create worktrees omit it. */
createFolderWorkspace?(args: {
create: Readonly<Record<string, unknown>>
}): Promise<{ worktreeId: string; connectionId: string | null }>
}
@@ -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 {}
@@ -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}`)
}
+2
View File
@@ -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<string> = 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',
@@ -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)
@@ -2,10 +2,12 @@
* Which view a launch moves: the requesting connection's, never the host's or another client's.
*
* 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 their
* launch that creates its workspace keeps `worktree.create`'s navigation. A paired client's create
* never activates the host window: its setup and default tabs are provisioned in the background.
* 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 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.
*/
import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
@@ -19,7 +21,7 @@ export function agentLaunchCallerNavigationId(
target: AgentLaunchTarget,
context: Pick<RpcContext, 'caller'>
): 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
}
@@ -13,9 +13,12 @@ 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
/** 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
@@ -33,10 +36,24 @@ 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 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 +63,9 @@ export function launchFailureWithoutEffectsCode(
if (error instanceof WorktreeCreateCollisionError) {
return WORKTREE_CREATE_COLLISION_CODE
}
if (error instanceof FolderWorkspaceCreateRefusedError) {
return folderCreateRefusalCode(error)
}
if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') {
return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE
}
@@ -0,0 +1,255 @@
/**
* `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 { 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'
const createStructuredSession = vi.fn(async (_args: Record<string, unknown>) => ({
ok: true as const,
value: { sessionId: 'sess-1' }
}))
vi.mock('./structured-agent-session-create', () => ({
createStructuredAgentSessionForWorktree: (args: Record<string, unknown>) =>
createStructuredSession(args)
}))
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',
target: {
kind: 'create-folder-workspace',
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<RpcContext> = { ...CAPABLE_CLIENT, clientKind: 'runtime' }
function folderRuntime(options: Parameters<typeof runtimeStub>[0] = {}) {
return Object.assign(runtimeStub(options), {
selectCreatedMobileSessionTabForClient: vi.fn(() => true)
})
}
function launch(
params: unknown,
runtime: AgentLaunchRuntimeStub,
context: Partial<RpcContext> = PAIRED_DESKTOP
) {
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 = folderRuntime({ settings: {} })
const result = await launch(FOLDER_LAUNCH, runtime)
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: { kind: 'paired-device', deviceId: 'device-1' }
})
)
expect(runtime.showTerminalWorkspaceLaunchScope).toHaveBeenCalledWith('id:folder:fw-new')
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
// 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' }
})
})
it('opens a structured chat in the new workspace when that is the default', async () => {
const runtime = folderRuntime()
const result = await launch(FOLDER_LAUNCH, runtime)
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.each([
['an SSH', 'ssh-1'],
['a local', null]
] as const)(
'keeps %s folder workspace connection through to the agent it starts',
async (_where, connectionId) => {
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.anything())
}
)
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)
})
})
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()
})
// 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.
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()
})
})
@@ -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 }
}
}
@@ -24,8 +24,8 @@ async function agentLaunchTarget(
params: AgentLaunchParams,
runtime: Pick<OrcaRuntimeService, 'showTerminalWorkspaceLaunchScope'>
): Promise<AgentLaunchTarget> {
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 {
@@ -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<Exclude<AgentLaunchTarget['kind'], 'existing'>, string>
export function assertAgentLaunchTargetAuthorized(
target: Pick<AgentLaunchTarget, 'kind'>,
context: Pick<RpcContext, 'clientKind'>
): 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()
}
}
@@ -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'
import { agentLaunchMovesHostWindow } from './agent-launch-tab-publication'
type WorktreeCreateParams = Extract<
@@ -37,6 +38,7 @@ export function agentLaunchWorkspaceFactory(
agent: TuiAgent
): AgentLaunchWorkspaceFactory {
return {
createFolderWorkspace: agentLaunchFolderWorkspaceCreator(context, agent),
createWorktree: async ({
create,
startupAgent,
@@ -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
@@ -98,6 +99,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) {
}
),
showRepo: vi.fn(async () => ({ id: 'repo-1' })),
createFolderWorkspace: vi.fn(async (_input: Record<string, unknown>) => ({ id: 'fw-new' })),
createManagedWorktree: vi.fn(async (args: Record<string, unknown>) => {
reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt)
if (args.startupAgent && options.startupTerminalPaneKey) {
@@ -138,13 +140,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<TerminalWorkspaceLaunchScope> => ({
id: selector.replace(/^id:/, ''),
path: '/tmp/wt-7',
connectionId: null,
repo: null,
folderWorkspace: null
})
),
ensureStructuredAgentSessionHost: vi.fn(async () => {}),
openAgentSessionRecordStore: vi.fn(async (): Promise<AgentSessionRecordStore> => {
if (!launchRecordStore) {
@@ -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 {
@@ -311,6 +312,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) {
@@ -341,6 +343,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)
}
@@ -0,0 +1,75 @@
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<NonNullable<RuntimeStore['createFolderWorkspace']>>()
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: () => store,
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.toMatchObject({ message: code })
expect(deps.createFolderWorkspace).not.toHaveBeenCalled()
expect(deps.notifyReposChanged).not.toHaveBeenCalled()
})
})
@@ -8,9 +8,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'
@@ -134,9 +136,10 @@ export class RuntimeProjectGroupController {
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}): Promise<FolderWorkspace> {
// 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)
@@ -145,7 +148,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(
{
@@ -157,7 +160,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' }
+2
View File
@@ -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<Record<string, unknown>> }
/** A folder workspace this launch creates. `create` is the `folderWorkspace.create` request. */
| { kind: 'create-folder-workspace'; create: Readonly<Record<string, unknown>> }
/** 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. */
+1
View File
@@ -31,6 +31,7 @@ export type AgentLaunchFingerprintInput = {
target:
| { kind: 'existing'; worktree: string }
| { kind: 'create-worktree'; create: Readonly<Record<string, unknown>> }
| { kind: 'create-folder-workspace'; create: Readonly<Record<string, unknown>> }
prompt?: { text: string; delivery: string }
sessionOptions?: Readonly<Record<string, string>>
reuseTerminal?: { handle: string }
@@ -38,6 +38,13 @@ 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. 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
// 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 +61,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
@@ -0,0 +1,10 @@
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)
this.name = 'AgentLaunchTargetForbiddenError'
}
}
@@ -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({
@@ -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
+13
View File
@@ -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'
}