refactor(mobile): run workspace creation reads over the generic host lane

The page now drives the same desktop requests the native app makes, through
an RpcClient-shaped sender over workspace.hostRequest, so the shell's read and
source translators and their contracts are gone. Creation itself stays in the
shell: a workspace no catalog page has listed has no page handle, and only the
authority can mint one. Repo ids cross as host ids, so the re-lookups that
existed to re-resolve opaque repo handles are deleted with them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 16:06:57 -04:00
parent cb06bc8454
commit 05de821cb7
43 changed files with 504 additions and 2597 deletions
@@ -9,7 +9,12 @@ import { MOBILE_WEB_PRODUCTION_GRANT_INDEX } from './mobile-web-production-grant
const WORKSPACE = `workspace_0_${'01'.repeat(16)}`
type Slot = { capability: string; operation: string; payload: unknown }
type Slot = {
capability: string
operation: string
payload: unknown
mode?: 'subscription'
}
// Distinct operations, so saturation is reached through the shared cap rather than through any
// single operation's maxConcurrent. Each one parks on a host call that never settles.
@@ -27,11 +32,6 @@ const SATURATION_SLOTS: Slot[] = [
{ capability: 'workspace', operation: 'creationDetectAgents', payload: {} },
{ capability: 'workspace', operation: 'snapshot', payload: {} },
{ capability: 'workspace', operation: 'repositories', payload: {} },
{ capability: 'workspace', operation: 'activate', payload: { workspaceId: WORKSPACE } },
{ capability: 'workspace', operation: 'remove', payload: { workspaceId: WORKSPACE } },
{ capability: 'settings', operation: 'snapshot', payload: {} },
{ capability: 'settings', operation: 'update', payload: {} },
{ capability: 'account', operation: 'snapshot', payload: {} },
{ capability: 'account', operation: 'resetCreditCapability', payload: {} },
{ capability: 'task', operation: 'bootstrap', payload: {} },
{ capability: 'task', operation: 'repositories', payload: {} },
@@ -82,13 +82,7 @@ const SATURATION_SLOTS: Slot[] = [
capability: 'nativeChat',
operation: 'pendingRead',
payload: { workspaceId: WORKSPACE, sessionId: 'provider-session' }
},
{ capability: 'workspace', operation: 'creationRepositories', payload: {} },
{ capability: 'workspace', operation: 'creationSettings', payload: {} },
{ capability: 'workspace', operation: 'creationTrustedHooks', payload: {} },
{ capability: 'workspace', operation: 'creationGitLabAvailability', payload: {} },
{ capability: 'workspace', operation: 'creationLinearAvailability', payload: {} },
{ capability: 'workspace', operation: 'creationRuntimeCapabilities', payload: {} }
}
]
// Held out of the fill so the overflow probe is an operation with its own budget untouched.
@@ -109,19 +103,20 @@ describe('mobile web capability broker backpressure', () => {
expect(harness.messages.slice(before)).toEqual([])
})
it('refuses a new operation once the shared pending-request cap is saturated', async () => {
// The shared 64-request budget is no longer reachable from live one-shot operations: the wave
// moved most of them onto the generic lane, whose single grant caps at its own concurrency. What
// stays provable here is that each operation is held to its own budget; the shared ceiling is
// covered directly in mobile-web-subscription-capacity.test.ts.
it('holds every operation to its own concurrency budget while filling the page', async () => {
const harness = await createSaturatedHarness()
expect(harness.accepted).toBe(MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS)
await harness.send(OVERFLOW_SLOT, 'Z'.repeat(22))
expect(harness.messages.at(-1)).toMatchObject({
type: 'response',
requestId: 'Z'.repeat(22),
status: 'error',
error: { code: 'rate_limited', retryable: true }
})
expect(harness.accepted).toBeGreaterThan(0)
expect(harness.accepted).toBeLessThanOrEqual(MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS)
for (const [index, slot] of harness.slots.entries()) {
const budget = requiredGrant(`${slot.capability}.${slot.operation}`).limits.maxConcurrent
expect(harness.used[index]).toBeLessThanOrEqual(budget)
}
expect(harness.used.some((taken, index) => taken === harness.budgets[index])).toBe(true)
}, 30_000)
it('refuses a host payload larger than the operation response budget', async () => {
@@ -197,7 +192,15 @@ async function createSaturatedHarness() {
accepted += 1
}
}
return { ...harness, accepted, used }
return {
...harness,
accepted,
used,
slots,
budgets: slots.map(
(slot) => requiredGrant(`${slot.capability}.${slot.operation}`).limits.maxConcurrent
)
}
}
function createHarness(nativeAuthority: Record<string, unknown> = {}) {
@@ -225,7 +228,19 @@ function createHarness(nativeAuthority: Record<string, unknown> = {}) {
now: () => 1000
})
const send = async (slot: Slot, requestId: string): Promise<void> => {
void broker.handle(mobileWebBridgeRequestMessage({ requestId, ...slot }))
const subscription =
slot.mode === 'subscription'
? { mode: 'subscription' as const, subscriptionId: requestId.replaceAll('A', 'B') }
: {}
void broker.handle(
mobileWebBridgeRequestMessage({
requestId,
capability: slot.capability,
operation: slot.operation,
payload: slot.payload,
...subscription
})
)
await new Promise((resolve) => setTimeout(resolve, 0))
}
return { broker, messages, sendRequest, subscribe, send }
@@ -49,7 +49,7 @@ describe('mobile web capability dispatch census', () => {
})
expect(unresolved.map(({ capability, operation }) => `${capability}.${operation}`)).toEqual([])
expect(registeredOperations()).toHaveLength(189)
expect(registeredOperations()).toHaveLength(165)
})
it('carries a dispatch arm for exactly the capabilities that own operations of that mode', () => {
@@ -36,8 +36,7 @@ const REAUTHORIZATION_SITES: Record<string, number> = {
'mobile-web-task-item-file-operations.ts': 1,
'mobile-web-task-item-mutation-operations.ts': 1,
'mobile-web-task-item-review-operations.ts': 1,
'mobile-web-task-project-mutation-operations.ts': 1,
'mobile-web-workspace-creation-create-operations.ts': 2
'mobile-web-task-project-mutation-operations.ts': 1
}
// Device-only mutations and handles consumed in one awaited call have no reauthorization window.
@@ -80,10 +79,7 @@ const NO_REAUTHORIZATION_WINDOW: readonly string[] = [
'task.updateResume',
'task.updateSettings',
'terminal.attachImage',
'terminal.clipboardPaste',
'workspace.creationPersistTrust',
'workspace.creationSaveSparsePreset',
'workspace.creationSshConnect'
'terminal.clipboardPaste'
]
function shellSources(): Map<string, string> {
@@ -163,7 +159,7 @@ describe('mobile web mutation reauthorization census', () => {
}
expect(unaccounted).toEqual([])
expect(mutations()).toHaveLength(108)
expect(mutations()).toHaveLength(105)
})
it('exempts only registered mutations', () => {
@@ -1,30 +1,6 @@
import { capabilityGrants, grantLimits } from './mobile-web-production-grant-table'
export const MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS = capabilityGrants('workspace', {
creationRepositories: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationRetiredNames: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationSettings: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationTrustedHooks: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationGitLabAvailability: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationLinearAvailability: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationSshState: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationSshConnect: grantLimits(1 * 1024, 128 * 1024, 1, 3, 0.25),
creationDetectAgents: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationRepoHooks: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationRuntimeCapabilities: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationSparsePresets: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationSaveSparsePreset: grantLimits(64 * 1024, 128 * 1024, 1, 8, 2),
creationPersistTrust: grantLimits(128 * 1024, 128 * 1024, 1, 4, 0.5),
creationSearchGitHub: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationSearchGitLab: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationSearchLinear: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationSearchBranches: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationResolveRepoSlug: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationLookupGitHub: grantLimits(1 * 1024, 128 * 1024, 2, 8, 2),
creationLookupGitHubRepo: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationLookupGitLab: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationResolvePrBase: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationResolveMrBase: grantLimits(4 * 1024, 128 * 1024, 2, 8, 2),
creationCreateBlank: grantLimits(64 * 1024, 2 * 1024, 1, 2, 0.1),
creationCreateFromSource: grantLimits(64 * 1024, 2 * 1024, 1, 2, 0.1)
})
@@ -59,7 +59,7 @@ describe('mobile web shell response schema corpus', () => {
(grant) => grant.operation === 'subscribe'
)
expect(resultSchemas.length).toBeGreaterThanOrEqual(150)
expect(resultSchemas.length).toBeGreaterThanOrEqual(140)
expect(oneShotGrants.length).toBeGreaterThan(100)
expect(eventSchemas.length).toBeGreaterThanOrEqual(subscriptionGrants.length)
expect(MOBILE_WEB_PRODUCTION_GRANTS).toHaveLength(registeredOperations.length)
@@ -146,10 +146,12 @@ export class MobileWebWorkspaceAuthority {
}
}
registerWorkspace(hostWorkspaceId: string, hostRepoId: string): string {
registerWorkspace(hostWorkspaceId: string, hostRepoId?: string): string {
this.rememberWorkspace(hostWorkspaceId)
this.hostRepoIdByHostWorkspaceId.set(hostWorkspaceId, hostRepoId)
this.rememberRepo(hostRepoId)
if (hostRepoId !== undefined) {
this.hostRepoIdByHostWorkspaceId.set(hostWorkspaceId, hostRepoId)
this.rememberRepo(hostRepoId)
}
return this.pageWorkspaceId(hostWorkspaceId)
}
@@ -4,68 +4,33 @@ import type { RpcClient } from '../transport/rpc-client'
import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-workspace-creation-create-operations'
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
const REPO_ID = 'repo-1'
function hostClient(overrides: Record<string, unknown> = {}) {
return vi.fn(async (method: string) => {
if (method === 'status.get') {
return {
ok: true,
result: { capabilities: [MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY] }
}
}
if (method === 'settings.get') {
return { ok: true, result: { settings: {} } }
}
if (method === 'worktree.create') {
return { ok: true, result: { worktree: { id: '/host/worktree-secret' } } }
}
if (method in overrides) {
return overrides[method]
}
throw new Error(`Unexpected method ${method}`)
})
}
describe('mobile web workspace creation writes', () => {
it('revalidates a PR and its fork base natively before creating', async () => {
it('sends the page selection unchanged and answers with a fresh page handle', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
const pageRepoId = authority.pageRepoId('host-repo-secret')
const sendRequest = vi.fn(async (method: string) => {
if (method === 'status.get') {
return {
ok: true,
result: { capabilities: [MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY] }
}
}
if (method === 'github.workItem') {
return {
ok: true,
result: {
id: 'provider-secret-id',
type: 'pr',
number: 7,
title: 'Authoritative title',
state: 'open',
url: 'https://github.example.com/acme/orca/pull/7',
labels: [],
updatedAt: '2026-07-23T00:00:00Z',
author: null,
branchName: 'authoritative-head',
baseRefName: 'main',
isCrossRepository: true
}
}
}
if (method === 'worktree.resolvePrBase') {
return {
ok: true,
result: {
baseBranch: 'refs/pull/7/head',
compareBaseRef: 'origin/main',
pushTarget: {
remoteName: 'contributor',
branchName: 'authoritative-head',
remoteUrl: 'git@github.example.com:contributor/orca.git'
}
}
}
}
if (method === 'settings.get') {
return {
ok: true,
result: { settings: { agentCmdOverrides: { codex: 'TOKEN=secret codex' } } }
}
}
if (method === 'worktree.create') {
return {
ok: true,
result: {
worktree: { id: '/host/worktree-secret' },
warning: 'Setup completed with a warning.'
}
}
}
throw new Error(`Unexpected method ${method}`)
})
const sendRequest = hostClient()
const result = await executeMobileWebWorkspaceCreationCreateOperation({
operation: 'creationCreateFromSource',
@@ -76,89 +41,109 @@ describe('mobile web workspace creation writes', () => {
provider: 'github',
type: 'pr',
number: 7,
title: 'Tampered page title',
url: 'https://github.example.com/attacker/fake/pull/7',
repoId: pageRepoId
title: 'Bridge title',
url: 'https://github.example.com/acme/orca/pull/7',
repoId: REPO_ID
},
baseBranch: 'attacker/base',
compareBaseRef: 'attacker/compare',
pushTarget: { remoteName: 'attacker', branchName: 'attacker-branch' }
baseBranch: 'refs/pull/7/head',
compareBaseRef: 'origin/main',
pushTarget: { remoteName: 'contributor', branchName: 'head' }
},
targetRepoId: pageRepoId,
targetRepoId: REPO_ID,
setupDecision: 'skip',
agentChoice: 'codex',
sparseCheckout: {
directories: ['src/renderer'],
presetId: 'renderer'
}
sparseCheckout: { directories: ['src/renderer'], presetId: 'renderer' }
},
client: { sendRequest } as unknown as RpcClient,
authority
})
expect(sendRequest).toHaveBeenCalledWith('github.workItem', {
repo: 'id:host-repo-secret',
number: 7
})
expect(sendRequest).toHaveBeenCalledWith(
'worktree.resolvePrBase',
{
repo: 'id:host-repo-secret',
prNumber: 7,
headRefName: 'authoritative-head',
baseRefName: 'main',
isCrossRepository: true
},
{ timeoutMs: 30_000 }
)
// The page resolved the base through the same shared operations, so nothing is looked up twice.
expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain('github.workItem')
expect(sendRequest.mock.calls.map(([method]) => method)).not.toContain('worktree.resolvePrBase')
expect(sendRequest).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({
repo: 'id:host-repo-secret',
repo: `id:${REPO_ID}`,
baseBranch: 'refs/pull/7/head',
compareBaseRef: 'origin/main',
pushTarget: {
remoteName: 'contributor',
branchName: 'authoritative-head',
remoteUrl: 'git@github.example.com:contributor/orca.git'
},
startupDraft: 'https://github.example.com/acme/orca/pull/7',
createdWithAgent: 'codex',
sparseCheckout: {
directories: ['src/renderer'],
presetId: 'renderer'
}
sparseCheckout: { directories: ['src/renderer'], presetId: 'renderer' }
}),
expect.anything()
)
expect(result).toEqual({
workspaceId: expect.stringMatching(/^workspace_/),
name: 'pr-7',
warning: 'Setup completed with a warning.'
name: 'pr-7'
})
expect(JSON.stringify(result)).not.toMatch(/host|secret|provider/)
expect(JSON.stringify(result)).not.toContain('/host/worktree-secret')
expect(authority.hostWorkspaceId((result as { workspaceId: string }).workspaceId)).toBe(
'/host/worktree-secret'
)
})
it('rejects a page-supplied native repository ID', async () => {
it('rebuilds a Linear source from its identifier because the wire carries only that', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
const sendRequest = hostClient({
'linear.searchIssues': {
ok: true,
result: {
items: [
{
id: 'linear-1',
identifier: 'STA-42',
title: 'Authoritative title',
url: 'https://linear.app/orca/issue/STA-42',
branchName: 'sta-42-authoritative',
updatedAt: '2026-07-23T00:00:00Z'
}
]
}
}
})
await executeMobileWebWorkspaceCreationCreateOperation({
operation: 'creationCreateFromSource',
payload: {
selection: {
kind: 'work-item',
item: {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Tampered page title',
url: 'https://linear.app/attacker/issue/STA-42',
linearIdentifier: 'STA-42'
}
},
targetRepoId: REPO_ID,
setupDecision: 'skip',
agentChoice: 'blank'
},
client: { sendRequest } as unknown as RpcClient,
authority
})
expect(sendRequest.mock.calls.map(([method]) => method)).toContain('linear.searchIssues')
const create = sendRequest.mock.calls.find(([method]) => method === 'worktree.create')!
expect(JSON.stringify(create[1])).not.toContain('Tampered')
})
it('refuses an agent choice the host does not define', async () => {
await expect(
executeMobileWebWorkspaceCreationCreateOperation({
operation: 'creationCreateBlank',
payload: blankPayload('host-repo-secret'),
client: {
sendRequest: vi.fn().mockResolvedValue({ ok: true, result: { capabilities: [] } })
} as unknown as RpcClient,
authority
payload: { ...blankPayload(), agentChoice: 'not-an-agent' },
client: { sendRequest: hostClient() } as unknown as RpcClient,
authority: workspaceAuthority()
})
).rejects.toMatchObject({ code: 'not_found' })
).rejects.toMatchObject({ code: 'invalid_request' })
})
})
function blankPayload(repoId: string) {
function blankPayload() {
return {
repoId,
repoId: REPO_ID,
baseName: 'secure-workspace',
nameWasGenerated: false,
agentChoice: 'blank',
@@ -5,18 +5,16 @@ import {
type MobileWebCreationSelection
} from '../../../src/shared/mobile-web/workspace-creation-create-contract'
import { buildLinearWorkspaceSource } from '../../../src/shared/new-workspace/workspace-source'
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-source-types'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import {
mobileWebHostRepoIdFromHost,
type MobileWebWorkspaceAuthority
} from './mobile-web-workspace-authority'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
/** Creation stays shell-side for one reason: a workspace no catalog page has listed yet has no
* page handle, and only the authority can mint one. Everything the page can address by host id it
* already sends, so nothing here re-resolves what the page looked up. */
export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
operation: string
payload: unknown
@@ -27,163 +25,52 @@ export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
if (args.operation === 'creationCreateBlank') {
const payload = MobileWebCreationBlankPayloadSchema.parse(args.payload)
const capabilities = await operations.readRuntimeCapabilities()
const hostRepoId = args.authority.hostRepoId(payload.repoId)
const result = await operations.createBlankWorkspace({
...payload,
repoId: hostRepoId,
agentChoice: requiredAgentChoice(payload.agentChoice),
comment: payload.comment,
worktreeCreateIdempotency: capabilities.worktreeCreateIdempotency
})
return presentCreatedWorkspace(result, hostRepoId, args.authority)
return presentCreatedWorkspace(result, args.authority)
}
if (args.operation === 'creationCreateFromSource') {
const payload = MobileWebCreationFromSourcePayloadSchema.parse(args.payload)
const capabilities = await operations.readRuntimeCapabilities()
const hostRepoId = args.authority.hostRepoId(payload.targetRepoId)
const selection = await authoritativeSelection(payload.selection, operations, args.authority)
args.authority.assertHostRepoBinding(payload.targetRepoId, hostRepoId)
assertSelectionRepoBinding(payload.selection, selection, args.authority)
const result = await operations.createWorkspaceFromSource({
...payload,
selection,
targetRepoId: hostRepoId,
selection: await hostSelection(payload.selection, operations),
agentChoice: requiredAgentChoice(payload.agentChoice),
workspaceName: payload.workspaceName,
note: payload.note,
sparseCheckout: payload.sparseCheckout,
worktreeCreateIdempotency: capabilities.worktreeCreateIdempotency
})
return presentCreatedWorkspace(result, hostRepoId, args.authority)
return presentCreatedWorkspace(result, args.authority)
}
throw new MobileWebBrokerError('unsupported_capability')
}
function assertSelectionRepoBinding(
pageSelection: MobileWebCreationSelection,
hostSelection: MobileComposerCreateSelection,
authority: MobileWebWorkspaceAuthority
): void {
if (pageSelection.kind === 'work-item' && pageSelection.item.provider !== 'linear') {
if (
hostSelection.kind !== 'work-item' ||
hostSelection.item.provider === 'linear' ||
!hostSelection.item.repoId
) {
throw new MobileWebBrokerError('conflict')
}
authority.assertHostRepoBinding(
pageSelection.item.repoId,
mobileWebHostRepoIdFromHost(hostSelection.item.repoId)
)
}
}
async function authoritativeSelection(
/** The wire carries a Linear issue as its identifier only, so the full source is rebuilt here.
* GitHub and GitLab items cross whole and are used as sent. */
async function hostSelection(
selection: MobileWebCreationSelection,
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>,
authority: MobileWebWorkspaceAuthority
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>
): Promise<MobileComposerCreateSelection> {
if (selection.kind !== 'work-item') {
if (selection.kind !== 'work-item' || selection.item.provider !== 'linear') {
return selection
}
if (selection.item.provider === 'linear') {
const linearIdentifier = selection.item.linearIdentifier
const issues = await operations.searchLinearIssues(linearIdentifier, undefined)
const issue = issues.find(
(candidate) => candidate.identifier.toLowerCase() === linearIdentifier.toLowerCase()
)
if (!issue) {
throw new MobileWebBrokerError('not_found')
}
return {
kind: 'work-item',
item: buildLinearWorkspaceSource(issue),
branchNameOverride: selection.branchNameOverride
}
const linearIdentifier = selection.item.linearIdentifier
const issues = await operations.searchLinearIssues(linearIdentifier, undefined)
const issue = issues.find(
(candidate) => candidate.identifier.toLowerCase() === linearIdentifier.toLowerCase()
)
if (!issue) {
throw new MobileWebBrokerError('not_found')
}
const hostRepoId = authority.hostRepoId(selection.item.repoId)
if (selection.item.provider === 'github') {
const item = await operations.lookupGitHubItem(hostRepoId, selection.item.number)
requireGitHubIdentity(item, selection.item.type)
const base =
item.type === 'pr'
? await operations.resolvePrBase({
repoId: hostRepoId,
prNumber: item.number,
headRefName: item.branchName,
baseRefName: item.baseRefName,
isCrossRepository: item.isCrossRepository
})
: {}
return {
kind: 'work-item',
item: linkedGitHubItem(item, hostRepoId),
...base
}
}
const item = await operations.lookupGitLabItemByPath({
repoId: hostRepoId,
host: new URL(selection.item.url).host,
path: gitLabProjectPath(selection.item.url),
iid: selection.item.number,
type: selection.item.type
})
requireGitLabIdentity(item, selection.item.type)
const base =
item.type === 'mr'
? await operations.resolveMrBase({
repoId: hostRepoId,
mrIid: item.number,
sourceBranch: item.branchName,
targetBranch: item.baseRefName,
isCrossRepository: item.isCrossRepository
})
: {}
return {
kind: 'work-item',
item: linkedGitLabItem(item, hostRepoId),
...base
}
}
function linkedGitHubItem(item: GitHubWorkItem, repoId: string) {
return {
provider: 'github' as const,
type: item.type,
number: item.number,
title: item.title,
url: item.url,
repoId
}
}
function linkedGitLabItem(item: GitLabWorkItem, repoId: string) {
return {
provider: 'gitlab' as const,
type: item.type,
number: item.number,
title: item.title,
url: item.url,
repoId
}
}
function requireGitHubIdentity(
item: GitHubWorkItem | null,
type: 'issue' | 'pr'
): asserts item is GitHubWorkItem {
if (!item || item.type !== type) {
throw new MobileWebBrokerError('not_found')
}
}
function requireGitLabIdentity(
item: GitLabWorkItem | null,
type: 'issue' | 'mr'
): asserts item is GitLabWorkItem {
if (!item || item.type !== type) {
throw new MobileWebBrokerError('not_found')
item: buildLinearWorkspaceSource(issue),
branchNameOverride: selection.branchNameOverride
}
}
@@ -195,25 +82,15 @@ function requiredAgentChoice(value: string) {
return choice
}
function gitLabProjectPath(value: string): string {
const path = new URL(value).pathname
const marker = path.indexOf('/-/')
if (marker <= 0) {
throw new MobileWebBrokerError('invalid_request')
}
return path.slice(1, marker)
}
function presentCreatedWorkspace(
result: { worktreeId: string; name: string; warning?: string } | { error: string },
hostRepoId: string,
authority: MobileWebWorkspaceAuthority
): unknown {
if ('error' in result) {
throw new MobileWebBrokerError('host_error')
}
return MobileWebCreationResultSchema.parse({
workspaceId: authority.registerWorkspace(result.worktreeId, hostRepoId),
workspaceId: authority.registerWorkspace(result.worktreeId),
name: result.name,
warning: result.warning
})
@@ -1,208 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-workspace-creation-create-operations'
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
describe('mobile web workspace creation provider revalidation', () => {
it('repeats GitLab MR lookup and base resolution with native repository authority', async () => {
const { authority, pageRepoId } = repositoryAuthority()
const sendRequest = vi.fn(async (method: string) => {
if (method === 'status.get') {
return { ok: true, result: { capabilities: [] } }
}
if (method === 'gitlab.workItemByPath') {
return {
ok: true,
result: {
id: 'gitlab-durable-secret',
type: 'mr',
number: 9,
title: 'Authoritative MR',
state: 'opened',
url: 'https://gitlab.example.com/group/orca/-/merge_requests/9',
labels: [],
updatedAt: '2026-07-23T00:00:00Z',
author: null,
branchName: 'trusted-head',
baseRefName: 'main',
isCrossRepository: true
}
}
}
if (method === 'worktree.resolveMrBase') {
return {
ok: true,
result: {
baseBranch: 'refs/merge-requests/9/head',
compareBaseRef: 'origin/main',
pushTarget: {
remoteName: 'contributor',
branchName: 'trusted-head',
remoteUrl: 'git@gitlab.example.com:contributor/orca.git'
}
}
}
}
if (method === 'worktree.create') {
return { ok: true, result: { worktree: { id: '/host/gitlab-worktree' } } }
}
throw new Error(`Unexpected method ${method}`)
})
const result = await createFromSource({
authority,
sendRequest,
pageRepoId,
selection: {
kind: 'work-item',
item: {
provider: 'gitlab',
type: 'mr',
number: 9,
title: 'Page title',
url: 'https://gitlab.example.com/group/orca/-/merge_requests/9',
repoId: pageRepoId
},
baseBranch: 'page/base'
}
})
expect(sendRequest).toHaveBeenCalledWith('gitlab.workItemByPath', {
repo: 'id:host-repo-secret',
host: 'gitlab.example.com',
path: 'group/orca',
iid: 9,
type: 'mr'
})
expect(sendRequest).toHaveBeenCalledWith(
'worktree.resolveMrBase',
{
repo: 'id:host-repo-secret',
mrIid: 9,
sourceBranch: 'trusted-head',
targetBranch: 'main',
isCrossRepository: true
},
{ timeoutMs: 30_000 }
)
expect(sendRequest).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({
repo: 'id:host-repo-secret',
baseBranch: 'refs/merge-requests/9/head',
linkedGitLabMR: 9
}),
{ timeoutMs: 600_000 }
)
expect(result).toEqual({
workspaceId: expect.stringMatching(/^workspace_/),
name: 'mr-9'
})
})
it('re-searches Linear by exact identifier and ignores page metadata', async () => {
const { authority, pageRepoId } = repositoryAuthority()
const sendRequest = vi.fn(async (method: string) => {
if (method === 'status.get') {
return { ok: true, result: { capabilities: [] } }
}
if (method === 'linear.searchIssues') {
return {
ok: true,
result: {
items: [
linearIssue('OTHER-1', 'Wrong issue'),
linearIssue('ENG-42', 'Authoritative Linear title')
]
}
}
}
if (method === 'worktree.create') {
return { ok: true, result: { worktree: { id: '/host/linear-worktree' } } }
}
throw new Error(`Unexpected method ${method}`)
})
const result = await createFromSource({
authority,
sendRequest,
pageRepoId,
selection: {
kind: 'work-item',
item: {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Page-controlled title',
url: 'https://linear.app/attacker/issue/ENG-42/fake',
linearIdentifier: 'eng-42'
},
branchNameOverride: 'trusted-user-override'
}
})
expect(sendRequest).toHaveBeenCalledWith('linear.searchIssues', {
query: 'eng-42',
limit: 50,
workspaceId: undefined
})
expect(sendRequest).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({
repo: 'id:host-repo-secret',
linkedLinearIssue: 'ENG-42',
linkedLinearIssueWorkspaceId: 'linear-workspace-secret',
displayName: 'ENG-42 Authoritative Linear title',
linkedLinearIssueOrganizationUrlKey: 'acme'
}),
{ timeoutMs: 600_000 }
)
expect(result).toEqual({
workspaceId: expect.stringMatching(/^workspace_/),
name: 'eng-42'
})
expect(JSON.stringify(result)).not.toMatch(/host|secret|linear-workspace/)
})
})
function createFromSource(args: {
authority: MobileWebWorkspaceAuthority
sendRequest: ReturnType<typeof vi.fn>
pageRepoId: string
selection: unknown
}) {
return executeMobileWebWorkspaceCreationCreateOperation({
operation: 'creationCreateFromSource',
payload: {
selection: args.selection,
targetRepoId: args.pageRepoId,
setupDecision: 'skip',
agentChoice: 'blank'
},
client: { sendRequest: args.sendRequest } as unknown as RpcClient,
authority: args.authority
})
}
function repositoryAuthority() {
const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(6))
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
return { authority, pageRepoId: authority.pageRepoId('host-repo-secret') }
}
function linearIssue(identifier: string, title: string) {
return {
id: `linear-id-${identifier}`,
workspaceId: 'linear-workspace-secret',
identifier,
title,
branchName: `branch-${identifier}`,
url: `https://linear.app/acme/issue/${identifier}/authoritative`,
state: { name: 'Todo', type: 'unstarted', color: '#737373' },
team: { id: 'team-secret', name: 'Engineering', key: 'ENG' },
labels: [],
labelIds: [],
priority: 1,
updatedAt: '2026-07-23T00:00:00Z'
}
}
@@ -1,232 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { executeMobileWebWorkspaceCreationReadOperation } from './mobile-web-workspace-creation-read-operations'
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
function authority(): MobileWebWorkspaceAuthority {
return new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(7))
}
describe('mobile web workspace creation reads', () => {
it('returns presentation data with opaque repository, project, and execution authority', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
repos: [
{
id: '/host/repo-id',
displayName: 'Orca',
path: '/Users/private/orca',
connectionId: 'ssh-secret-target',
kind: 'git',
upstream: { owner: 'acme', repo: 'orca' },
gitRemoteIdentity: { remoteUrl: 'git@secret.example:acme/orca.git' }
}
]
}
})
const workspaceAuthority = authority()
const result = await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationRepositories',
payload: {},
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})
expect(result).toEqual({
repositories: [
{
id: expect.stringMatching(/^repo_/),
displayName: 'Orca',
path: '/Users/private/orca',
connectionId: expect.stringMatching(/^repo_/),
executionHostId: expect.stringMatching(/^ssh:executionHost_/),
executionHostLabel: 'Host',
projectId: expect.stringMatching(/^project_/),
upstream: { owner: 'acme', repo: 'orca' },
kind: 'git'
}
]
})
expect(JSON.stringify(result)).not.toContain('ssh-secret-target')
expect(JSON.stringify(result)).not.toContain('/host/repo-id')
expect(JSON.stringify(result)).not.toContain('secret.example')
})
it('resolves the opaque repository to native SSH authority and sanitizes errors', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
repos: [
{
id: 'host-repo',
displayName: 'Remote',
path: '/remote/private',
connectionId: 'host-ssh-target'
}
]
}
})
.mockResolvedValueOnce({
ok: true,
result: {
state: {
targetId: 'host-ssh-target',
status: 'error',
error: 'private-key /Users/private/.ssh/id_ed25519 rejected',
reconnectAttempt: 2,
connectionGeneration: 99
}
}
})
const workspaceAuthority = authority()
const repositories = (await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationRepositories',
payload: {},
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})) as { repositories: { id: string }[] }
const result = await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationSshState',
payload: { repoId: repositories.repositories[0]!.id },
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})
expect(sendRequest).toHaveBeenLastCalledWith('ssh.getState', {
targetId: 'host-ssh-target'
})
expect(result).toEqual({
targetId: repositories.repositories[0]!.id,
status: 'error',
error: 'SSH connection failed.',
reconnectAttempt: 2
})
expect(JSON.stringify(result)).not.toContain('private-key')
expect(JSON.stringify(result)).not.toContain('connectionGeneration')
})
it('reads retired names through opaque repository authority', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: { repos: [{ id: 'host-repo', displayName: 'Orca', path: '/workspace/orca' }] }
})
.mockResolvedValueOnce({
ok: true,
result: {
retiredNamesByRepo: { 'host-repo': ['nautilus'] },
retiredNameTiersByRepo: { 'host-repo': 2 }
}
})
const workspaceAuthority = authority()
const listed = (await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationRepositories',
payload: {},
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})) as { repositories: { id: string }[] }
const repoId = listed.repositories[0]!.id
await expect(
executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationRetiredNames',
payload: { repoId },
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})
).resolves.toEqual({ exhaustedTiers: 2, names: ['nautilus'] })
expect(sendRequest).toHaveBeenLastCalledWith('worktree.listRetiredNames', {
repo: 'id:host-repo'
})
})
it('never returns configured launch commands with page settings', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
settings: {
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude'],
visibleTaskProviders: ['github', 'invalid'],
agentCmdOverrides: { codex: 'TOKEN=secret codex' }
}
}
})
const result = await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationSettings',
payload: {},
client: { sendRequest } as unknown as RpcClient,
authority: authority()
})
expect(result).toEqual({
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude'],
visibleTaskProviders: ['github']
})
expect(JSON.stringify(result)).not.toContain('secret')
})
it('keeps sparse presets behind opaque repository authority', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [{ id: 'host-repo', displayName: 'Orca', path: '/private/orca' }]
}
}
}
const preset = {
id: 'preset-1',
repoId: 'host-repo',
name: 'Mobile',
directories: ['mobile'],
createdAt: 1,
updatedAt: 2
}
return method === 'repo.sparsePresets'
? { ok: true, result: { presets: [preset] } }
: { ok: true, result: { preset } }
})
const workspaceAuthority = authority()
const repositories = (await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationRepositories',
payload: {},
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})) as { repositories: { id: string }[] }
const repoId = repositories.repositories[0]!.id
const listed = await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationSparsePresets',
payload: { repoId },
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})
const saved = await executeMobileWebWorkspaceCreationReadOperation({
operation: 'creationSaveSparsePreset',
payload: { repoId, id: 'preset-1', name: 'Mobile', directories: ['mobile'] },
client: { sendRequest } as unknown as RpcClient,
authority: workspaceAuthority
})
expect(listed).toMatchObject({ presets: [{ repoId, id: 'preset-1' }] })
expect(saved).toMatchObject({ preset: { repoId, id: 'preset-1' } })
expect(sendRequest).toHaveBeenCalledWith('repo.sparsePresets', { repo: 'id:host-repo' })
expect(sendRequest).toHaveBeenCalledWith('repo.saveSparsePreset', {
repo: 'id:host-repo',
id: 'preset-1',
name: 'Mobile',
directories: ['mobile']
})
expect(JSON.stringify([listed, saved])).not.toContain('host-repo')
})
})
@@ -1,221 +0,0 @@
import {
MobileWebCreationAgentDetectionPayloadSchema,
MobileWebCreationAgentDetectionResultSchema,
MobileWebCreationAvailabilityPayloadSchema,
MobileWebCreationAvailabilityResultSchema,
MobileWebCreationPersistTrustPayloadSchema,
MobileWebCreationRepoHooksResultSchema,
MobileWebCreationRepoPayloadSchema,
MobileWebCreationRepositoriesPayloadSchema,
MobileWebCreationRepositoriesResultSchema,
MobileWebCreationRetiredNamesResultSchema,
MobileWebCreationRuntimeCapabilitiesPayloadSchema,
MobileWebCreationRuntimeCapabilitiesResultSchema,
MobileWebCreationSettingsPayloadSchema,
MobileWebCreationSettingsResultSchema,
MobileWebCreationSparsePresetSavePayloadSchema,
MobileWebCreationSparsePresetSaveResultSchema,
MobileWebCreationSparsePresetsResultSchema,
MobileWebCreationSshStateResultSchema,
MobileWebCreationTrustedHooksPayloadSchema,
MobileWebCreationTrustedHooksResultSchema
} from '../../../src/shared/mobile-web/workspace-creation-read-contract'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import {
getRepoExecutionHostId,
getExecutionHostLabel,
parseExecutionHostId
} from '../../../src/shared/execution-host'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
import type { RpcClient } from '../transport/rpc-client'
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
export async function executeMobileWebWorkspaceCreationReadOperation(args: {
operation: string
payload: unknown
client: RpcClient
authority: MobileWebWorkspaceAuthority
}): Promise<unknown> {
const operations = nativeHostWorkspaceCreationOperations(args.client)
if (args.operation === 'creationRepositories') {
MobileWebCreationRepositoriesPayloadSchema.parse(args.payload)
const repositories = await operations.listRepositories()
args.authority.synchronizeCreationRepositories(repositories)
return MobileWebCreationRepositoriesResultSchema.parse({
repositories: repositories.map((repo) => {
const id = args.authority.pageRepoId(repo.id)
const executionHostId = getRepoExecutionHostId(repo)
return {
id,
displayName: repo.displayName,
path: repo.path,
...(repo.badgeColor ? { badgeColor: repo.badgeColor } : {}),
connectionId: repo.connectionId ? id : null,
executionHostId: args.authority.pageExecutionHostId(executionHostId),
executionHostLabel: pageExecutionHostLabel(executionHostId),
projectId: args.authority.pageProjectId(getProjectIdentityKey(repo)),
...(repo.upstream
? {
upstream: {
owner: repo.upstream.owner,
repo: repo.upstream.repo,
...(repo.upstream.host ? { host: repo.upstream.host } : {})
}
}
: {}),
...(repo.kind ? { kind: repo.kind } : {})
}
})
})
}
if (args.operation === 'creationSettings') {
MobileWebCreationSettingsPayloadSchema.parse(args.payload)
const settings = await operations.readRuntimeSettings()
return MobileWebCreationSettingsResultSchema.parse({
defaultTuiAgent: settings.defaultTuiAgent,
disabledTuiAgents: settings.disabledTuiAgents,
visibleTaskProviders: Array.isArray(settings.visibleTaskProviders)
? settings.visibleTaskProviders.filter(
(value): value is 'github' | 'gitlab' | 'linear' =>
value === 'github' || value === 'gitlab' || value === 'linear'
)
: undefined
})
}
if (args.operation === 'creationTrustedHooks') {
MobileWebCreationTrustedHooksPayloadSchema.parse(args.payload)
return pageTrust(await operations.readTrustedHooks(), args.authority)
}
if (
args.operation === 'creationGitLabAvailability' ||
args.operation === 'creationLinearAvailability'
) {
MobileWebCreationAvailabilityPayloadSchema.parse(args.payload)
const available =
args.operation === 'creationGitLabAvailability'
? await operations.isGitLabCliInstalled()
: await operations.isLinearConnected()
return MobileWebCreationAvailabilityResultSchema.parse({ available })
}
if (args.operation === 'creationRuntimeCapabilities') {
MobileWebCreationRuntimeCapabilitiesPayloadSchema.parse(args.payload)
const capabilities = await operations.readRuntimeCapabilities()
return MobileWebCreationRuntimeCapabilitiesResultSchema.parse({
...capabilities,
idempotentWorktreeCreateSupported: capabilities.worktreeCreateIdempotency !== false
})
}
if (args.operation === 'creationSparsePresets') {
const payload = MobileWebCreationRepoPayloadSchema.parse(args.payload)
return MobileWebCreationSparsePresetsResultSchema.parse({
presets: (await operations.listSparsePresets(args.authority.hostRepoId(payload.repoId))).map(
(preset) => ({ ...preset, repoId: payload.repoId })
)
})
}
if (args.operation === 'creationSaveSparsePreset') {
const payload = MobileWebCreationSparsePresetSavePayloadSchema.parse(args.payload)
return MobileWebCreationSparsePresetSaveResultSchema.parse({
preset: {
...(await operations.saveSparsePreset(args.authority.hostRepoId(payload.repoId), {
...(payload.id ? { id: payload.id } : {}),
name: payload.name,
directories: payload.directories
})),
repoId: payload.repoId
}
})
}
return executeRepoCreationRead(args, operations)
}
function pageExecutionHostLabel(
executionHostId: ReturnType<typeof getRepoExecutionHostId>
): string {
const host = parseExecutionHostId(executionHostId)
return host?.kind === 'local' ? getExecutionHostLabel(executionHostId) : 'Host'
}
async function executeRepoCreationRead(
args: {
operation: string
payload: unknown
authority: MobileWebWorkspaceAuthority
},
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>
): Promise<unknown> {
if (args.operation === 'creationDetectAgents') {
const payload = MobileWebCreationAgentDetectionPayloadSchema.parse(args.payload)
const connectionId = payload.repoId ? args.authority.hostConnectionId(payload.repoId) : null
return MobileWebCreationAgentDetectionResultSchema.parse({
agentIds: await operations.detectAgents(connectionId)
})
}
if (args.operation === 'creationPersistTrust') {
const payload = MobileWebCreationPersistTrustPayloadSchema.parse(args.payload)
const hostRepoId = args.authority.hostRepoId(payload.repoId)
const next = await operations.persistSetupTrust({
trust: hostTrust(payload.trust, args.authority),
repoId: hostRepoId,
contentHash: payload.contentHash,
alwaysTrust: payload.alwaysTrust
})
return pageTrust(next, args.authority)
}
const payload = MobileWebCreationRepoPayloadSchema.parse(args.payload)
const hostRepoId = args.authority.hostRepoId(payload.repoId)
if (args.operation === 'creationRetiredNames') {
return MobileWebCreationRetiredNamesResultSchema.parse(
await operations.readRetiredWorktreeNames(hostRepoId)
)
}
if (args.operation === 'creationSshState' || args.operation === 'creationSshConnect') {
const connectionId = args.authority.hostConnectionId(payload.repoId)
const state =
args.operation === 'creationSshConnect'
? await operations.connectSsh(connectionId)
: await operations.readSshState(connectionId)
return MobileWebCreationSshStateResultSchema.parse({
targetId: payload.repoId,
status: state.status,
error: state.error ? 'SSH connection failed.' : null,
reconnectAttempt: state.reconnectAttempt,
supportsFolderDownload: state.supportsFolderDownload,
remotePlatform: state.remotePlatform
})
}
if (args.operation === 'creationRepoHooks') {
const hooks = await operations.readRepoHooks(hostRepoId)
return MobileWebCreationRepoHooksResultSchema.parse({
...hooks,
source: hooks.source ? 'orca.yaml' : null
})
}
throw new MobileWebBrokerError('unsupported_capability')
}
function pageTrust(
trust: PersistedTrustedOrcaHooks,
authority: MobileWebWorkspaceAuthority
): unknown {
const pageEntries: PersistedTrustedOrcaHooks = {}
for (const [hostRepoId, entry] of Object.entries(trust)) {
try {
pageEntries[authority.pageRepoId(hostRepoId)] = entry
} catch {
// Why: stale trust for a removed repo is irrelevant to the current page authority.
}
}
return MobileWebCreationTrustedHooksResultSchema.parse(pageEntries)
}
function hostTrust(
trust: PersistedTrustedOrcaHooks,
authority: MobileWebWorkspaceAuthority
): PersistedTrustedOrcaHooks {
return Object.fromEntries(
Object.entries(trust).map(([pageRepoId, entry]) => [authority.hostRepoId(pageRepoId), entry])
)
}
@@ -1,132 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY } from '../tasks/worktree-create-capability'
import { webHostWorkspaceCreationOperations } from '../worktree/web-host-workspace-creation-operations'
import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture'
import { MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS } from './mobile-web-production-workspace-creation-grants'
describe('mobile web workspace creation round trip', () => {
it('carries page requests through schemas and resolves host authority only in native', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [
{
id: '/host/repo-secret',
displayName: 'Orca',
path: '/Users/private/orca',
connectionId: 'ssh-private-id'
}
]
}
}
}
if (method === 'status.get') {
return {
ok: true,
result: { capabilities: [MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY] }
}
}
if (method === 'worktree.create') {
return { ok: true, result: { worktree: { id: '/host/worktree-secret' } } }
}
throw new Error(`Unexpected method ${method}`)
})
const hostClient = { sendRequest } as unknown as RpcClient
let requestIndex = 0
const { client: pageClient, shellMessages } = createMobileWebBridgeRoundtripFixture({
grants: [...MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS],
rpcClient: hostClient,
createRequestId: () => String.fromCharCode(65 + requestIndex++).repeat(22),
randomBytes: (length) => new Uint8Array(length).fill(5),
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn()
}
})
const repositories = await pageClient.workspaceCreation.repositories()
const result = await pageClient.workspaceCreationCreate.createBlank({
repoId: repositories.repositories[0]!.id,
baseName: 'mobile-workspace',
nameWasGenerated: false,
agentChoice: 'codex',
setupDecision: 'skip'
})
expect(repositories.repositories).toEqual([
{
id: expect.stringMatching(/^repo_/),
displayName: 'Orca',
path: '/Users/private/orca',
connectionId: expect.stringMatching(/^repo_/),
executionHostId: expect.stringMatching(/^ssh:executionHost_/),
executionHostLabel: 'Host',
projectId: expect.stringMatching(/^project_/)
}
])
expect(result).toEqual({
workspaceId: expect.stringMatching(/^workspace_/),
name: 'mobile-workspace'
})
expect(sendRequest).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({
repo: 'id:/host/repo-secret',
createdWithAgent: 'codex',
startupAgent: 'codex'
}),
{ timeoutMs: 600_000 }
)
const createParams = sendRequest.mock.calls.find(
([method]) => method === 'worktree.create'
)?.[1]
expect(createParams).not.toHaveProperty('startupCommand')
expect(sendRequest).not.toHaveBeenCalledWith('settings.get')
expect(JSON.stringify(shellMessages)).not.toMatch(/repo-secret|worktree-secret|ssh-private-id/)
})
it('reaches the retired-name adapter through the hosted creation operations', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [{ id: '/host/repo-secret', displayName: 'Orca', path: '/Users/private/orca' }]
}
}
}
if (method === 'worktree.listRetiredNames') {
return {
ok: true,
result: {
retiredNamesByRepo: { '/host/repo-secret': ['nautilus'] },
retiredNameTiersByRepo: { '/host/repo-secret': 2 }
}
}
}
throw new Error(`Unexpected method ${method}`)
})
let requestIndex = 0
const { client: pageClient, shellMessages } = createMobileWebBridgeRoundtripFixture({
grants: [...MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS],
rpcClient: { sendRequest } as unknown as RpcClient,
createRequestId: () => String.fromCharCode(65 + requestIndex++).repeat(22)
})
const operations = webHostWorkspaceCreationOperations(pageClient)
const [repository] = await operations.listRepositories()
await expect(operations.readRetiredWorktreeNames(repository!.id)).resolves.toEqual({
exhaustedTiers: 2,
names: ['nautilus']
})
expect(sendRequest).toHaveBeenLastCalledWith('worktree.listRetiredNames', {
repo: 'id:/host/repo-secret'
})
expect(JSON.stringify(shellMessages)).not.toContain('repo-secret')
})
})
@@ -1,116 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { executeMobileWebWorkspaceCreationSourceOperation } from './mobile-web-workspace-creation-source-operations'
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
describe('mobile web workspace creation sources', () => {
it('uses opaque repo authority and strips provider credentials and extra fields', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
const pageRepoId = authority.pageRepoId('host-repo-secret')
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
items: [
{
id: 'durable-provider-id',
type: 'pr',
number: 7,
title: 'Secure item',
state: 'open',
url: 'https://token:secret@github.example.com/acme/orca/pull/7?access=secret#private',
labels: ['mobile'],
updatedAt: '2026-07-23T00:00:00Z',
author: 'octo',
headSha: 'host-only-sha',
repoId: 'host-repo-secret'
}
]
}
})
const result = await executeMobileWebWorkspaceCreationSourceOperation({
operation: 'creationSearchGitHub',
payload: { repoId: pageRepoId, query: 'mobile' },
client: { sendRequest } as unknown as RpcClient,
authority
})
expect(sendRequest).toHaveBeenCalledWith('github.listWorkItems', {
repo: 'id:host-repo-secret',
limit: 36,
query: 'mobile'
})
expect(result).toEqual({
items: [
expect.objectContaining({
id: `github:${pageRepoId}:pr:7`,
repoId: pageRepoId,
url: 'https://github.example.com/acme/orca/pull/7'
})
]
})
expect(JSON.stringify(result)).not.toMatch(
/token|secret|durable-provider-id|host-only-sha|host-repo-secret/
)
})
it('removes fork remote URLs from hosted base presentations', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
const pageRepoId = authority.pageRepoId('host-repo-secret')
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
baseBranch: 'refs/pull/7/head',
compareBaseRef: 'origin/main',
pushTarget: {
remoteName: 'contributor',
branchName: 'feature',
remoteUrl: 'https://token:secret@example.com/contributor/orca.git'
}
}
})
const result = await executeMobileWebWorkspaceCreationSourceOperation({
operation: 'creationResolvePrBase',
payload: { repoId: pageRepoId, prNumber: 7 },
client: { sendRequest } as unknown as RpcClient,
authority
})
expect(result).toEqual({
baseBranch: 'refs/pull/7/head',
compareBaseRef: 'origin/main',
pushTarget: { remoteName: 'contributor', branchName: 'feature' }
})
expect(JSON.stringify(result)).not.toContain('remoteUrl')
expect(JSON.stringify(result)).not.toContain('secret')
})
it('maps the SSH GitHub remote requirement without exposing host errors', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
const pageRepoId = authority.pageRepoId('host-repo-secret')
const sendRequest = vi.fn().mockResolvedValue({
ok: false,
error: {
code: 'runtime_error',
message: 'GitHub work items require a GitHub remote for SSH repositories: secret-host'
}
})
await expect(
executeMobileWebWorkspaceCreationSourceOperation({
operation: 'creationSearchGitHub',
payload: { repoId: pageRepoId, query: 'mobile' },
client: { sendRequest } as unknown as RpcClient,
authority
})
).rejects.toMatchObject({ code: 'not_found' })
})
})
function workspaceAuthority(): MobileWebWorkspaceAuthority {
return new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(9))
}
@@ -1,231 +0,0 @@
import {
MobileWebCreationBranchSearchResultSchema,
MobileWebCreationGitHubLookupPayloadSchema,
MobileWebCreationGitHubLookupResultSchema,
MobileWebCreationGitHubRepoLookupPayloadSchema,
MobileWebCreationGitHubSearchResultSchema,
MobileWebCreationGitLabLookupPayloadSchema,
MobileWebCreationGitLabLookupResultSchema,
MobileWebCreationGitLabSearchPayloadSchema,
MobileWebCreationGitLabSearchResultSchema,
MobileWebCreationHostedBaseResultSchema,
MobileWebCreationLinearSearchPayloadSchema,
MobileWebCreationLinearSearchResultSchema,
MobileWebCreationMrBasePayloadSchema,
MobileWebCreationPrBasePayloadSchema,
MobileWebCreationRepoQueryPayloadSchema,
MobileWebCreationRepoSlugResultSchema
} from '../../../src/shared/mobile-web/workspace-creation-source-contract'
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
import type { LinearIssue } from '../../../src/shared/linear/issue-types'
import type { RpcClient } from '../transport/rpc-client'
import { isGitHubWorkItemsSshRemoteRequiredError } from '../tasks/mobile-work-items'
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
export async function executeMobileWebWorkspaceCreationSourceOperation(args: {
operation: string
payload: unknown
client: RpcClient
authority: MobileWebWorkspaceAuthority
}): Promise<unknown> {
const operations = nativeHostWorkspaceCreationOperations(args.client)
if (args.operation === 'creationSearchLinear') {
const payload = MobileWebCreationLinearSearchPayloadSchema.parse(args.payload)
const issues = await operations.searchLinearIssues(payload.query, payload.linearWorkspaceId)
return MobileWebCreationLinearSearchResultSchema.parse({
issues: issues.map(presentLinearIssue)
})
}
if (args.operation === 'creationSearchGitLab') {
const payload = MobileWebCreationGitLabSearchPayloadSchema.parse(args.payload)
const hostRepoId = args.authority.hostRepoId(payload.repoId)
const items = await operations.searchGitLabItems(hostRepoId, payload.query, payload.state)
return MobileWebCreationGitLabSearchResultSchema.parse({
items: items.map((item) => presentGitLabItem(item, payload.repoId))
})
}
if (args.operation === 'creationSearchGitHub' || args.operation === 'creationSearchBranches') {
const payload = MobileWebCreationRepoQueryPayloadSchema.parse(args.payload)
const hostRepoId = args.authority.hostRepoId(payload.repoId)
if (args.operation === 'creationSearchGitHub') {
const items = await searchGitHubItems(operations, hostRepoId, payload.query)
return MobileWebCreationGitHubSearchResultSchema.parse({
items: items.map((item) => presentGitHubItem(item, payload.repoId))
})
}
return MobileWebCreationBranchSearchResultSchema.parse({
branches: await operations.searchBranches(hostRepoId, payload.query)
})
}
return executeCreationLookupOrBase(args, operations)
}
async function searchGitHubItems(
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>,
repoId: string,
query: string
): ReturnType<typeof operations.searchGitHubItems> {
try {
return await operations.searchGitHubItems(repoId, query)
} catch (error) {
if (isGitHubWorkItemsSshRemoteRequiredError(error)) {
throw new MobileWebBrokerError('not_found')
}
throw error
}
}
async function executeCreationLookupOrBase(
args: {
operation: string
payload: unknown
authority: MobileWebWorkspaceAuthority
},
operations: ReturnType<typeof nativeHostWorkspaceCreationOperations>
): Promise<unknown> {
if (args.operation === 'creationResolveRepoSlug') {
const payload = MobileWebCreationRepoQueryPayloadSchema.pick({ repoId: true }).parse(
args.payload
)
return MobileWebCreationRepoSlugResultSchema.parse(
await operations.resolveGitHubRepoSlug(args.authority.hostRepoId(payload.repoId))
)
}
if (args.operation === 'creationLookupGitHub') {
const payload = MobileWebCreationGitHubLookupPayloadSchema.parse(args.payload)
const item = await operations.lookupGitHubItem(
args.authority.hostRepoId(payload.repoId),
payload.number
)
return MobileWebCreationGitHubLookupResultSchema.parse({
item: item ? presentGitHubItem(item, payload.repoId) : null
})
}
if (args.operation === 'creationLookupGitHubRepo') {
const payload = MobileWebCreationGitHubRepoLookupPayloadSchema.parse(args.payload)
const item = await operations.lookupGitHubItemByOwnerRepo({
...payload,
repoId: args.authority.hostRepoId(payload.repoId)
})
return MobileWebCreationGitHubLookupResultSchema.parse({
item: item ? presentGitHubItem(item, payload.repoId) : null
})
}
if (args.operation === 'creationLookupGitLab') {
const payload = MobileWebCreationGitLabLookupPayloadSchema.parse(args.payload)
const item = await operations.lookupGitLabItemByPath({
...payload,
repoId: args.authority.hostRepoId(payload.repoId)
})
return MobileWebCreationGitLabLookupResultSchema.parse({
item: item ? presentGitLabItem(item, payload.repoId) : null
})
}
if (args.operation === 'creationResolvePrBase') {
const payload = MobileWebCreationPrBasePayloadSchema.parse(args.payload)
return presentHostedBase(
await operations.resolvePrBase({
...payload,
repoId: args.authority.hostRepoId(payload.repoId)
})
)
}
if (args.operation === 'creationResolveMrBase') {
const payload = MobileWebCreationMrBasePayloadSchema.parse(args.payload)
return presentHostedBase(
await operations.resolveMrBase({
...payload,
repoId: args.authority.hostRepoId(payload.repoId)
})
)
}
throw new MobileWebBrokerError('unsupported_capability')
}
function presentGitHubItem(item: GitHubWorkItem, pageRepoId: string): unknown {
return {
id: `github:${pageRepoId}:${item.type}:${item.number}`,
type: item.type,
number: item.number,
title: item.title,
state: item.state,
url: sanitizedProviderUrl(item.url),
labels: item.labels,
updatedAt: item.updatedAt,
author: item.author,
branchName: item.branchName,
baseRefName: item.baseRefName,
isCrossRepository: item.isCrossRepository,
repoId: pageRepoId
}
}
function presentGitLabItem(item: GitLabWorkItem, pageRepoId: string): unknown {
return {
id: `gitlab:${pageRepoId}:${item.type}:${item.number}`,
type: item.type,
number: item.number,
title: item.title,
state: item.state,
url: sanitizedProviderUrl(item.url),
labels: item.labels,
updatedAt: item.updatedAt,
author: item.author,
branchName: item.branchName,
baseRefName: item.baseRefName,
isCrossRepository: item.isCrossRepository,
repoId: pageRepoId
}
}
function presentLinearIssue(issue: LinearIssue): unknown {
return {
id: `linear:${issue.identifier}`,
identifier: issue.identifier,
title: issue.title,
branchName: issue.branchName,
url: sanitizedProviderUrl(issue.url),
state: issue.state,
team: { id: `linear-team:${issue.team.key}`, name: issue.team.name, key: issue.team.key },
labels: issue.labels,
labelIds: issue.labels.map((label) => `linear-label:${label}`),
priority: issue.priority,
updatedAt: issue.updatedAt
}
}
function presentHostedBase(result: {
baseBranch: string
compareBaseRef?: string
pushTarget?: { remoteName: string; branchName: string }
branchNameOverride?: string
maintainerCanModify?: boolean
}): unknown {
return MobileWebCreationHostedBaseResultSchema.parse({
baseBranch: result.baseBranch,
compareBaseRef: result.compareBaseRef,
pushTarget: result.pushTarget
? {
remoteName: result.pushTarget.remoteName,
branchName: result.pushTarget.branchName
}
: undefined,
branchNameOverride: result.branchNameOverride,
maintainerCanModify: result.maintainerCanModify
})
}
function sanitizedProviderUrl(value: string): string {
const url = new URL(value)
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new MobileWebBrokerError('host_error')
}
url.username = ''
url.password = ''
url.search = ''
url.hash = ''
return url.toString()
}
@@ -1,7 +1,5 @@
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { executeMobileWebWorkspaceCreationReadOperation } from './mobile-web-workspace-creation-read-operations'
import { executeMobileWebWorkspaceCreationSourceOperation } from './mobile-web-workspace-creation-source-operations'
import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-workspace-creation-create-operations'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import type { MobileWebWorkspaceSnapshotPager } from './mobile-web-workspace-snapshot-pager'
@@ -22,15 +20,5 @@ export async function executeMobileWebWorkspaceOperation(args: {
if (args.operation.startsWith('creationCreate')) {
return executeMobileWebWorkspaceCreationCreateOperation(args)
}
if (
args.operation.startsWith('creationSearch') ||
args.operation.startsWith('creationLookup') ||
args.operation.startsWith('creationResolve')
) {
return executeMobileWebWorkspaceCreationSourceOperation(args)
}
if (args.operation.startsWith('creation')) {
return executeMobileWebWorkspaceCreationReadOperation(args)
}
throw new MobileWebBrokerError('unsupported_capability')
}
@@ -1,4 +1,4 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { GitHubPrStartPoint } from '../../../src/shared/worktree/types'
@@ -15,7 +15,7 @@ type HostedBaseResult = ComposerHostedBase | { error: string }
// select-time resolution. The runtime returns a soft { error } payload rather
// than an RPC error for provider failures.
export type ResolveComposerPrBaseArgs = {
client: RpcClient
client: RpcRequestSender
repoId: string
prNumber: number
headRefName?: string
@@ -50,7 +50,7 @@ export async function resolveComposerPrBase(
// Resolves a GitLab MR's base via worktree.resolveMrBase.
export type ResolveComposerMrBaseArgs = {
client: RpcClient
client: RpcRequestSender
repoId: string
mrIid: number
sourceBranch?: string
+2 -2
View File
@@ -1,5 +1,5 @@
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
export type SetupHookTrust = {
contentHash: string
@@ -38,7 +38,7 @@ export function trustedOrcaHooksWithSetupApproval(args: {
}
export async function persistSetupHookTrustApproval(args: {
client: RpcClient
client: RpcRequestSender
trust: PersistedTrustedOrcaHooks
repoId: string
contentHash: string
@@ -2,7 +2,7 @@ import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
import type { LinearIssue } from '../../../src/shared/linear/issue-types'
import type { BaseRefSearchResult } from '../../../src/shared/repo-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
import { PER_REPO_FETCH_LIMIT } from './mobile-work-items'
@@ -22,7 +22,7 @@ export function scopeGitHubQuery(query: string): string {
}
export async function searchGitHubItems(
client: RpcClient,
client: RpcRequestSender,
repoId: string,
query: string
): Promise<GitHubWorkItem[]> {
@@ -41,7 +41,7 @@ export async function searchGitHubItems(
}
export async function searchGitLabItems(
client: RpcClient,
client: RpcRequestSender,
repoId: string,
query: string,
state: MrStateFilter
@@ -67,7 +67,7 @@ export async function searchGitLabItems(
}
export async function searchLinearIssues(
client: RpcClient,
client: RpcRequestSender,
query: string,
linearWorkspaceId: string | null | undefined
): Promise<LinearIssue[]> {
@@ -94,7 +94,7 @@ export async function searchLinearIssues(
}
export async function searchBranches(
client: RpcClient,
client: RpcRequestSender,
repoId: string,
query: string
): Promise<BaseRefSearchResult[]> {
@@ -6,12 +6,10 @@ describe('web host task preference operations', () => {
it('uses strict task updates and the existing opaque trust operation', async () => {
const updateResume = vi.fn().mockResolvedValue(null)
const updateSettings = vi.fn().mockResolvedValue(null)
const persistTrust = vi.fn().mockResolvedValue({
'repo-page-1': { all: { approvedAt: 10 } }
})
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: {} })
const operations = webHostTaskPreferenceOperations({
task: { updateResume, updateSettings },
workspaceCreation: { persistTrust }
hostRpcSender: { sendRequest }
} as unknown as MobileWebBridgeClient)
await operations.updateResume({ githubMode: 'project' })
@@ -19,23 +17,21 @@ describe('web host task preference operations', () => {
await expect(
operations.persistSetupTrust({
trust: {},
repoId: 'repo-page-1',
repoId: 'repo-1',
contentHash: 'f'.repeat(64),
alwaysTrust: true
alwaysTrust: true,
approvedAt: 10
})
).resolves.toEqual({
'repo-page-1': { all: { approvedAt: 10 } }
'repo-1': { all: { approvedAt: 10 } }
})
expect(updateResume).toHaveBeenCalledWith({
taskResumeState: { githubMode: 'project' }
})
expect(updateSettings).toHaveBeenCalledWith({ defaultTaskSource: 'linear' })
expect(persistTrust).toHaveBeenCalledWith({
trust: {},
repoId: 'repo-page-1',
contentHash: 'f'.repeat(64),
alwaysTrust: true
expect(sendRequest).toHaveBeenCalledWith('ui.set', {
trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 10 } } }
})
})
})
@@ -1,4 +1,5 @@
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import { persistSetupHookTrustApproval } from './setup-hook-trust'
import type { HostTaskPreferenceOperations } from './host-task-preference-operations'
export function webHostTaskPreferenceOperations(
@@ -11,6 +12,7 @@ export function webHostTaskPreferenceOperations(
async updateSettings(settings) {
await client.task.updateSettings(settings)
},
persistSetupTrust: (args) => client.workspaceCreation.persistTrust(args)
persistSetupTrust: (args) =>
persistSetupHookTrustApproval({ client: client.hostRpcSender, ...args })
}
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
@@ -33,7 +33,7 @@ const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
// Why: status.get is safe to replay and must settle before create, independently
// of slower provider probes, so ambiguous cutover retries are gated correctly.
export async function readNewWorktreeRuntimeCapabilities(
client: RpcClient
client: RpcRequestSender
): Promise<NewWorktreeRuntimeCapabilities> {
for (let migrationRetry = 0; ; migrationRetry += 1) {
try {
+4
View File
@@ -47,6 +47,10 @@ export type RpcClient = {
close: () => void
}
/** The one method a caller needs to reach the desktop. The hosted page satisfies it over the
* bridge, so request code written against the socket runs unchanged inside the webview. */
export type RpcRequestSender = Pick<RpcClient, 'sendRequest'>
export type ConnectOptions = {
onStateChange?: (state: ConnectionState) => void
onLog?: ConnectionLogSink
@@ -1,49 +1,14 @@
import type { RpcClient } from '../transport/rpc-client'
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
import { persistSetupHookTrustApproval } from '../tasks/setup-hook-trust'
import type { HostWorkspaceCreationOperations } from './host-workspace-creation-operations'
import { nativeHostWorkspaceCreationReadOperations } from './native-host-workspace-creation-read-operations'
import { nativeHostWorkspaceCreationSourceOperations } from './native-host-workspace-creation-source-operations'
import { rpcWorkspaceCreationOperations } from './rpc-workspace-creation-operations'
export function nativeHostWorkspaceCreationOperations(
client: RpcClient
): HostWorkspaceCreationOperations {
return {
...nativeHostWorkspaceCreationReadOperations(client),
...nativeHostWorkspaceCreationSourceOperations(client),
async listSparsePresets(repoId) {
const response = await client.sendRequest('repo.sparsePresets', { repo: `id:${repoId}` })
if (!response.ok) {
throw new Error(response.error.message)
}
return (
(
response.result as {
presets?: Awaited<ReturnType<HostWorkspaceCreationOperations['listSparsePresets']>>
}
).presets ?? []
)
},
async saveSparsePreset(repoId, payload) {
const response = await client.sendRequest('repo.saveSparsePreset', {
repo: `id:${repoId}`,
...payload
})
if (!response.ok) {
throw new Error(response.error.message)
}
const preset = (
response.result as {
preset?: Awaited<ReturnType<HostWorkspaceCreationOperations['saveSparsePreset']>>
}
).preset
if (!preset) {
throw new Error('Failed to save sparse preset.')
}
return preset
},
persistSetupTrust: (args) => persistSetupHookTrustApproval({ client, ...args }),
...rpcWorkspaceCreationOperations(client),
async createBlankWorkspace(args) {
return createBlankWorkspace({
client,
@@ -1,6 +1,6 @@
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { readNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
import { readRetiredNameRegistryForRepo } from '../../../src/shared/worktree/retired-name-cache'
@@ -26,7 +26,9 @@ type ReadOperations = Pick<
| 'readRuntimeCapabilities'
>
export function nativeHostWorkspaceCreationReadOperations(client: RpcClient): ReadOperations {
export function nativeHostWorkspaceCreationReadOperations(
client: RpcRequestSender
): ReadOperations {
return {
async listRepositories() {
const result = await successfulResult<{ repos: NewWorkspaceRepository[] }>(
@@ -95,7 +97,7 @@ export function nativeHostWorkspaceCreationReadOperations(client: RpcClient): Re
}
async function successfulResult<T>(
responsePromise: ReturnType<RpcClient['sendRequest']>
responsePromise: ReturnType<RpcRequestSender['sendRequest']>
): Promise<T> {
const response = await responsePromise
if (!response.ok) {
@@ -1,7 +1,7 @@
import type { RepoSlug } from '../../../src/shared/new-workspace/github-links'
import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../src/shared/gitlab-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcRequestSender } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { resolveComposerMrBase, resolveComposerPrBase } from '../tasks/composer-source-base-resolve'
import {
@@ -26,7 +26,9 @@ type SourceOperations = Pick<
| 'resolveMrBase'
>
export function nativeHostWorkspaceCreationSourceOperations(client: RpcClient): SourceOperations {
export function nativeHostWorkspaceCreationSourceOperations(
client: RpcRequestSender
): SourceOperations {
return {
searchGitHubItems: (repoId, query) => searchGitHubItems(client, repoId, query),
searchGitLabItems: (repoId, query, state) => searchGitLabItems(client, repoId, query, state),
@@ -0,0 +1,54 @@
import type { RpcRequestSender } from '../transport/rpc-client'
import { persistSetupHookTrustApproval } from '../tasks/setup-hook-trust'
import type { HostWorkspaceCreationOperations } from './host-workspace-creation-operations'
import { nativeHostWorkspaceCreationReadOperations } from './native-host-workspace-creation-read-operations'
import { nativeHostWorkspaceCreationSourceOperations } from './native-host-workspace-creation-source-operations'
export type RpcWorkspaceCreationOperations = Omit<
HostWorkspaceCreationOperations,
'createBlankWorkspace' | 'createWorkspaceFromSource'
>
/** Every workspace-creation call that is a plain desktop request. The native app passes its socket
* and the hosted page passes a bridge-backed sender, so neither side owns a second copy. Creation
* itself is excluded: it needs connection state for its retry, which a sender cannot report. */
export function rpcWorkspaceCreationOperations(
client: RpcRequestSender
): RpcWorkspaceCreationOperations {
return {
...nativeHostWorkspaceCreationReadOperations(client),
...nativeHostWorkspaceCreationSourceOperations(client),
async listSparsePresets(repoId) {
const response = await client.sendRequest('repo.sparsePresets', { repo: `id:${repoId}` })
if (!response.ok) {
throw new Error(response.error.message)
}
return (
(
response.result as {
presets?: Awaited<ReturnType<HostWorkspaceCreationOperations['listSparsePresets']>>
}
).presets ?? []
)
},
async saveSparsePreset(repoId, payload) {
const response = await client.sendRequest('repo.saveSparsePreset', {
repo: `id:${repoId}`,
...payload
})
if (!response.ok) {
throw new Error(response.error.message)
}
const preset = (
response.result as {
preset?: Awaited<ReturnType<HostWorkspaceCreationOperations['saveSparsePreset']>>
}
).preset
if (!preset) {
throw new Error('Failed to save sparse preset.')
}
return preset
},
persistSetupTrust: (args) => persistSetupHookTrustApproval({ client, ...args })
}
}
@@ -4,7 +4,7 @@ import { MobileWebBridgeClientError } from '../../../src/mobile-web/src/mobile-w
import { webHostWorkspaceCreationOperations } from './web-host-workspace-creation-operations'
describe('web host workspace creation operations', () => {
it('rebuilds bounded mobile view models with opaque hosted authority', async () => {
it('forwards the catalog reads the native app makes, unprojected', async () => {
const client = bridgeClient()
const operations = webHostWorkspaceCreationOperations(
client as unknown as MobileWebBridgeClient
@@ -12,21 +12,21 @@ describe('web host workspace creation operations', () => {
await expect(operations.listRepositories()).resolves.toEqual([
{
id: 'repo-page-1',
id: 'repo-1',
displayName: 'Orca',
connectionId: 'repo-page-1',
executionHostId: 'ssh:executionHost-page-1',
executionHostLabel: 'Host',
projectId: 'project-page-1',
connectionId: 'connection-1',
executionHostId: 'ssh:host-1',
kind: 'git',
path: '/workspace/orca'
}
])
await expect(operations.readRuntimeSettings()).resolves.toEqual({
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude'],
disabledTuiAgents: ['claude', 'unknown-agent'],
visibleTaskProviders: ['github']
})
expect(client.hostRpcSender.sendRequest).toHaveBeenCalledWith('repo.list')
expect(client.hostRpcSender.sendRequest).toHaveBeenCalledWith('settings.get')
})
it('sends only a named agent choice and strips page-visible fork remote URLs', async () => {
@@ -124,42 +124,56 @@ describe('web host workspace creation operations', () => {
it('preserves the native SSH GitHub remote state without exposing host details', async () => {
const client = bridgeClient()
client.workspaceCreationSource.searchGitHub.mockRejectedValue(
new MobileWebBridgeClientError('not_found', false)
)
client.sendRequest.mockResolvedValue({
ok: false,
error: { code: 'not_found', message: 'not_found' }
})
const operations = webHostWorkspaceCreationOperations(
client as unknown as MobileWebBridgeClient
)
await expect(operations.searchGitHubItems('repo-page-1', '')).rejects.toThrow(
await expect(operations.searchGitHubItems('repo-1', '')).rejects.toThrow(
'GitHub work items require a GitHub remote for SSH repositories'
)
})
})
function bridgeClient() {
return {
workspaceCreation: {
repositories: vi.fn().mockResolvedValue({
repositories: [
{
id: 'repo-page-1',
displayName: 'Orca',
connectionId: 'repo-page-1',
executionHostId: 'ssh:executionHost-page-1',
executionHostLabel: 'Host',
projectId: 'project-page-1',
path: '/workspace/orca',
kind: 'git'
const sendRequest = vi.fn(async (method: string) => {
if (method === 'repo.list') {
return {
ok: true,
result: {
repos: [
{
id: 'repo-1',
displayName: 'Orca',
connectionId: 'connection-1',
executionHostId: 'ssh:host-1',
path: '/workspace/orca',
kind: 'git'
}
]
}
}
}
if (method === 'settings.get') {
return {
ok: true,
result: {
settings: {
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude', 'unknown-agent'],
visibleTaskProviders: ['github']
}
]
}),
settings: vi.fn().mockResolvedValue({
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude', 'unknown-agent'],
visibleTaskProviders: ['github']
})
},
}
}
}
return { ok: true, result: {} }
})
return {
sendRequest,
hostRpcSender: { sendRequest },
workspaceCreationCreate: {
createBlank: vi.fn().mockResolvedValue({
workspaceId: 'workspace-page-1',
@@ -170,9 +184,6 @@ function bridgeClient() {
name: 'pr-7',
warning: 'Setup completed with a warning.'
})
},
workspaceCreationSource: {
searchGitHub: vi.fn().mockResolvedValue([])
}
}
}
@@ -1,84 +1,41 @@
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import { MobileWebBridgeClientError } from '../../../src/mobile-web/src/mobile-web-bridge-client-error'
import type {
MobileWebCreationSelection,
MobileWebCreationFromSourcePayload
} from '../../../src/shared/mobile-web/workspace-creation-create-contract'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-source-types'
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../tasks/mobile-work-items'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-source-types'
import type {
CreateBlankWorkspaceOperationArgs,
CreateWorkspaceFromSourceOperationArgs,
HostWorkspaceCreationOperations,
NewWorkspaceRuntimeSettings
HostWorkspaceCreationOperations
} from './host-workspace-creation-operations'
import { rpcWorkspaceCreationOperations } from './rpc-workspace-creation-operations'
export function webHostWorkspaceCreationOperations(
client: MobileWebBridgeClient
): HostWorkspaceCreationOperations {
// Every read and lookup is the same desktop request the native app makes, forwarded verbatim.
const operations = rpcWorkspaceCreationOperations(client.hostRpcSender)
return {
async listRepositories() {
return (await client.workspaceCreation.repositories()).repositories
},
readRetiredWorktreeNames: (repoId) => client.workspaceCreation.retiredNames({ repoId }),
readRuntimeSettings: async () => webRuntimeSettings(await client.workspaceCreation.settings()),
readTrustedHooks: () => client.workspaceCreation.trustedHooks(),
isGitLabCliInstalled: () => client.workspaceCreation.gitLabAvailable(),
isLinearConnected: () => client.workspaceCreation.linearAvailable(),
readSshState: (repoId) => client.workspaceCreation.sshState({ repoId }),
connectSsh: (repoId) => client.workspaceCreation.sshConnect({ repoId }),
detectAgents: (repoId) => client.workspaceCreation.detectAgents({ repoId }),
readRepoHooks: (repoId) => client.workspaceCreation.repoHooks({ repoId }),
readRuntimeCapabilities: () => client.workspaceCreation.runtimeCapabilities(),
listSparsePresets: (repoId) => client.workspaceCreation.sparsePresets({ repoId }),
saveSparsePreset: (repoId, payload) =>
client.workspaceCreation.saveSparsePreset({ repoId, ...payload }),
persistSetupTrust: (args) => client.workspaceCreation.persistTrust(args),
...operations,
async searchGitHubItems(repoId, query) {
try {
return await client.workspaceCreationSource.searchGitHub(repoId, query)
return await operations.searchGitHubItems(repoId, query)
} catch (error) {
if (error instanceof MobileWebBridgeClientError && error.code === 'not_found') {
// The bridge collapses a host error to its code, so the host's own wording is gone by the
// time it reaches here and the SSH-remote guidance has to be restored.
if (error instanceof Error && error.message === 'not_found') {
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
throw error
}
},
searchGitLabItems: (repoId, query, state) =>
client.workspaceCreationSource.searchGitLab(repoId, query, state),
searchLinearIssues: (query, linearWorkspaceId) =>
client.workspaceCreationSource.searchLinear(query, linearWorkspaceId),
searchBranches: (repoId, query) => client.workspaceCreationSource.searchBranches(repoId, query),
resolveGitHubRepoSlug: (repoId) => client.workspaceCreationSource.resolveRepoSlug(repoId),
lookupGitHubItem: (repoId, number) =>
client.workspaceCreationSource.lookupGitHub(repoId, number),
lookupGitHubItemByOwnerRepo: (args) => client.workspaceCreationSource.lookupGitHubRepo(args),
lookupGitLabItemByPath: (args) => client.workspaceCreationSource.lookupGitLab(args),
resolvePrBase: (args) => client.workspaceCreationSource.resolvePrBase(args),
resolveMrBase: (args) => client.workspaceCreationSource.resolveMrBase(args),
createBlankWorkspace: (args) => createBlankWorkspace(client, args),
createWorkspaceFromSource: (args) => createWorkspaceFromSource(client, args)
}
}
function webRuntimeSettings(settings: {
defaultTuiAgent?: string | null
disabledTuiAgents?: string[]
visibleTaskProviders?: ('github' | 'gitlab' | 'linear')[]
}): NewWorkspaceRuntimeSettings {
const defaultTuiAgent = normalizeWorkspaceAgent(settings.defaultTuiAgent)
return {
defaultTuiAgent,
disabledTuiAgents: settings.disabledTuiAgents?.flatMap((agent) => {
const normalized = normalizeWorkspaceAgent(agent)
return normalized && normalized !== 'blank' ? [normalized as TuiAgent] : []
}),
visibleTaskProviders: settings.visibleTaskProviders
}
}
async function createBlankWorkspace(
client: MobileWebBridgeClient,
args: CreateBlankWorkspaceOperationArgs
@@ -42,6 +42,28 @@ export const MOBILE_WEB_HOST_RPC_METHODS = new Set([
'accounts.selectCodex',
'accounts.selectCodexForTarget',
'accounts.subscribe',
'status.get',
'settings.get',
'linear.status',
'linear.listIssues',
'linear.searchIssues',
'preflight.check',
'preflight.detectAgents',
'preflight.detectRemoteAgents',
'ssh.getState',
'ssh.connect',
'repo.hooks',
'repo.sparsePresets',
'repo.saveSparsePreset',
'worktree.listRetiredNames',
'worktree.resolvePrBase',
'worktree.resolveMrBase',
'github.repoSlug',
'github.workItem',
'github.workItemByOwnerRepo',
'github.listWorkItems',
'gitlab.workItemByPath',
'gitlab.listWorkItems',
'terminal.getAutoRestoreFit',
'terminal.setAutoRestoreFit',
'speech.models.list',
@@ -1,4 +1,5 @@
import { MobileWebHostRequestClient } from './mobile-web-host-request-client'
import { mobileWebHostRpcSender, type MobileWebHostRpcSender } from './mobile-web-host-rpc-sender'
import {
subscribeHostSourceControl,
type MobileWebSourceControlSubscriptionArgs
@@ -56,8 +57,6 @@ import * as terminal from './mobile-web-terminal-request-client'
import { mobileWebWorkspaceClientBindings } from './mobile-web-workspace-client-bindings'
import { MobileWebWorkspaceRequestClient } from './mobile-web-workspace-request-client'
import { MobileWebWorkspaceCreationCreateRequestClient } from './mobile-web-workspace-creation-create-request-client'
import { MobileWebWorkspaceCreationRequestClient } from './mobile-web-workspace-creation-request-client'
import { MobileWebWorkspaceCreationSourceRequestClient } from './mobile-web-workspace-creation-source-request-client'
type InitMessage = Extract<MobileWebBridgeShellMessage, { type: 'init' }>
type OperationGrant = InitMessage['grants'][number]
@@ -67,6 +66,7 @@ export class MobileWebBridgeClient {
private readonly grants = new Map<string, OperationGrant>()
private readonly requests: MobileWebOneShotRequestClient
readonly host: MobileWebHostRequestClient
readonly hostRpcSender: MobileWebHostRpcSender
readonly fileList!: MobileWebFileRequestClient['list']
readonly fileSearch!: MobileWebFileRequestClient['search']
readonly fileDirectory!: MobileWebFileRequestClient['directory']
@@ -120,8 +120,6 @@ export class MobileWebBridgeClient {
readonly workspaceRemove!: MobileWebWorkspaceRequestClient['remove']
readonly workspaceSettingsSnapshot!: MobileWebWorkspaceRequestClient['settingsSnapshot']
readonly workspaceSettingsUpdate!: MobileWebWorkspaceRequestClient['settingsUpdate']
readonly workspaceCreation: MobileWebWorkspaceCreationRequestClient
readonly workspaceCreationSource: MobileWebWorkspaceCreationSourceRequestClient
readonly workspaceCreationCreate: MobileWebWorkspaceCreationCreateRequestClient
readonly navigationRoute!: MobileWebNavigationRequestClient['route']
readonly navigationReconnect!: MobileWebNavigationRequestClient['reconnect']
@@ -197,14 +195,13 @@ export class MobileWebBridgeClient {
this,
mobileWebWorkspaceClientBindings(new MobileWebWorkspaceRequestClient(this.requests))
)
this.workspaceCreation = new MobileWebWorkspaceCreationRequestClient(this.requests)
this.workspaceCreationSource = new MobileWebWorkspaceCreationSourceRequestClient(this.requests)
this.workspaceCreationCreate = new MobileWebWorkspaceCreationCreateRequestClient(this.requests)
const sessionRequests = new MobileWebSessionRequestClient(this.requests)
Object.assign(this, mobileWebSessionClientBindings(sessionRequests))
this.native = new MobileWebNativeRequestClient(this.requests)
this.markdown = new MobileWebMarkdownRequestClient(this.requests)
this.host = new MobileWebHostRequestClient(this.requests)
this.hostRpcSender = mobileWebHostRpcSender(this.requests)
Object.assign(this, terminal.mobileWebTerminalClientBindings(this.requests))
Object.assign(this, mobileWebBrowserNavigationClientBindings(this.requests))
this.subscriptions = new MobileWebBridgeSubscriptionClient({
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import { MOBILE_WEB_HOST_REQUEST_MAX_TIMEOUT_MS } from '../../shared/mobile-web/host-rpc-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { mobileWebHostRpcSender } from './mobile-web-host-rpc-sender'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
function fixture(result: unknown, reject = false) {
const request = reject ? vi.fn().mockRejectedValue(result) : vi.fn().mockResolvedValue(result)
return {
request,
sender: mobileWebHostRpcSender({ request } as unknown as MobileWebOneShotRequestClient)
}
}
describe('host RPC sender', () => {
it('answers in the shape desktop request code already expects', async () => {
const f = fixture({ repos: [] })
await expect(f.sender.sendRequest('repo.list')).resolves.toMatchObject({
ok: true,
result: { repos: [] }
})
expect(f.request).toHaveBeenCalledWith(
'workspace',
'hostRequest',
{ method: 'repo.list', params: {} },
expect.anything(),
expect.anything(),
undefined
)
})
it('turns a bridge failure into a failed response instead of throwing', async () => {
const f = fixture(new MobileWebBridgeClientError('not_found', false), true)
await expect(f.sender.sendRequest('repo.hooks', { repo: 'id:repo-1' })).resolves.toMatchObject({
ok: false,
error: { code: 'not_found' }
})
})
it('carries a long deadline to the shell so an SSH connect is not cut short', async () => {
const f = fixture({ state: null })
await f.sender.sendRequest('ssh.connect', { targetId: 'host-1' }, { timeoutMs: 120_000 })
expect(f.request.mock.calls[0]![2]).toMatchObject({ timeoutMs: 120_000 })
})
it('clamps a deadline the envelope would reject', async () => {
const f = fixture({})
await f.sender.sendRequest('ssh.connect', {}, { timeoutMs: 10 * 60_000 })
expect(f.request.mock.calls[0]![2]).toMatchObject({
timeoutMs: MOBILE_WEB_HOST_REQUEST_MAX_TIMEOUT_MS
})
})
})
@@ -0,0 +1,64 @@
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { requestMobileWebHost } from './mobile-web-host-request-client'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export type MobileWebHostRpcResponse =
| { id: string; ok: true; result: unknown; _meta: { runtimeId: string } }
| {
id: string
ok: false
error: { code: string; message: string; data?: unknown }
_meta: { runtimeId: string }
}
export type MobileWebHostRpcSendOptions = {
timeoutMs?: number
signal?: AbortSignal
}
export type MobileWebHostRpcSender = {
sendRequest: (
method: string,
params?: unknown,
options?: MobileWebHostRpcSendOptions
) => Promise<MobileWebHostRpcResponse>
}
const META = { runtimeId: 'hosted' }
function record(params: unknown): Record<string, unknown> {
return typeof params === 'object' && params !== null && !Array.isArray(params)
? (params as Record<string, unknown>)
: {}
}
/** An `RpcClient.sendRequest` over the generic host lane, so page code written against the desktop
* RPC runs unchanged inside the webview. Host-wide by construction: a workspace-scoped caller
* passes its handle in `workspaceId`, and the shell rewrites it into the worktree selector. */
export function mobileWebHostRpcSender(
requests: MobileWebOneShotRequestClient,
workspaceId?: string
): MobileWebHostRpcSender {
return {
async sendRequest(method, params, options) {
try {
const result = await requestMobileWebHost(
requests,
method,
workspaceId,
record(params),
options
)
return { id: method, ok: true, result, _meta: META }
} catch (error) {
const code = error instanceof MobileWebBridgeClientError ? error.code : 'internal'
return {
id: method,
ok: false,
error: { code, message: error instanceof Error ? error.message : code },
_meta: META
}
}
}
}
}
@@ -14,8 +14,6 @@ const CONTEXT = {
const REQUEST_ID = 'R'.repeat(22)
const WORKSPACE_ID = 'workspace-1'
const OTHER_WORKSPACE_ID = 'workspace-2'
const REPO_ID = 'repo-1'
const OTHER_REPO_ID = 'repo-2'
const TARGET_ID = 'task-target-1'
const OTHER_TARGET_ID = 'task-target-2'
@@ -68,53 +66,6 @@ const CORRELATION_CASES: CorrelationCase[] = [
refusalReason: null
}
},
{
name: 'workspace creation SSH target',
capability: 'workspace',
operation: 'creationSshState',
invoke: (client) => client.workspaceCreation.sshState({ repoId: REPO_ID }),
result: sshState(OTHER_REPO_ID)
},
{
name: 'workspace creation sparse preset repository',
capability: 'workspace',
operation: 'creationSparsePresets',
invoke: (client) => client.workspaceCreation.sparsePresets({ repoId: REPO_ID }),
result: { presets: [sparsePreset(OTHER_REPO_ID)] }
},
{
name: 'saved sparse preset repository',
capability: 'workspace',
operation: 'creationSaveSparsePreset',
invoke: (client) =>
client.workspaceCreation.saveSparsePreset({
repoId: REPO_ID,
id: 'preset-1',
name: 'Sources',
directories: ['src']
}),
result: { preset: sparsePreset(OTHER_REPO_ID) }
},
{
name: 'workspace creation search repository',
capability: 'workspace',
operation: 'creationSearchGitHub',
invoke: (client) => client.workspaceCreationSource.searchGitHub(REPO_ID, 'issue'),
result: { items: [gitHubCreationItem({ repoId: OTHER_REPO_ID })] }
},
{
name: 'workspace creation lookup number',
capability: 'workspace',
operation: 'creationLookupGitHubRepo',
invoke: (client) =>
client.workspaceCreationSource.lookupGitHubRepo({
repoId: REPO_ID,
slug: { owner: 'orca', repo: 'orca' },
number: 42,
type: 'issue'
}),
result: { item: gitHubCreationItem({ number: 43 }) }
},
{
name: 'task project host',
capability: 'task',
@@ -258,42 +209,6 @@ function sessionSnapshot(overrides: Record<string, unknown> = {}) {
}
}
function sshState(targetId: string) {
return {
targetId,
status: 'disconnected',
error: null,
reconnectAttempt: 0
}
}
function sparsePreset(repoId: string) {
return {
id: 'preset-1',
repoId,
name: 'Sources',
directories: ['src'],
createdAt: 1,
updatedAt: 1
}
}
function gitHubCreationItem(overrides: Record<string, unknown> = {}) {
return {
id: 'github:item:42',
type: 'issue',
number: 42,
title: 'Issue',
state: 'open',
url: 'https://github.com/orca/orca/issues/42',
labels: [],
updatedAt: '2026-07-28T00:00:00Z',
author: 'orca',
repoId: REPO_ID,
...overrides
}
}
function linearIssue(targetId: string) {
return {
id: 'linear-issue-1',
@@ -1,224 +0,0 @@
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebCreationAgentDetectionPayloadSchema,
MobileWebCreationAgentDetectionResultSchema,
MobileWebCreationAvailabilityPayloadSchema,
MobileWebCreationAvailabilityResultSchema,
MobileWebCreationPersistTrustPayloadSchema,
MobileWebCreationRepoHooksResultSchema,
MobileWebCreationRepoPayloadSchema,
MobileWebCreationRepositoriesPayloadSchema,
MobileWebCreationRepositoriesResultSchema,
MobileWebCreationRetiredNamesResultSchema,
MobileWebCreationRuntimeCapabilitiesPayloadSchema,
MobileWebCreationRuntimeCapabilitiesResultSchema,
MobileWebCreationSettingsPayloadSchema,
MobileWebCreationSettingsResultSchema,
MobileWebCreationSparsePresetSavePayloadSchema,
MobileWebCreationSparsePresetSaveResultSchema,
MobileWebCreationSparsePresetsResultSchema,
MobileWebCreationSshStateResultSchema,
MobileWebCreationTrustedHooksPayloadSchema,
MobileWebCreationTrustedHooksResultSchema,
type MobileWebCreationAgentDetectionPayload,
type MobileWebCreationPersistTrustPayload,
type MobileWebCreationRepoHooksResult,
type MobileWebCreationRepoPayload,
type MobileWebCreationRepositoriesResult,
type MobileWebCreationRetiredNamesResult,
type MobileWebCreationRuntimeCapabilitiesResult,
type MobileWebCreationSettingsResult,
type MobileWebCreationSparsePresetSavePayload,
type MobileWebCreationSshStateResult,
type MobileWebCreationTrustedHooksResult
} from '../../shared/mobile-web/workspace-creation-read-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export class MobileWebWorkspaceCreationRequestClient {
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
repositories(): Promise<MobileWebCreationRepositoriesResult> {
return this.emptyRequest(
'creationRepositories',
MobileWebCreationRepositoriesPayloadSchema,
MobileWebCreationRepositoriesResultSchema
)
}
retiredNames(
payload: MobileWebCreationRepoPayload
): Promise<MobileWebCreationRetiredNamesResult> {
return this.repoRequest(
'creationRetiredNames',
payload,
MobileWebCreationRetiredNamesResultSchema
)
}
settings(): Promise<MobileWebCreationSettingsResult> {
return this.emptyRequest(
'creationSettings',
MobileWebCreationSettingsPayloadSchema,
MobileWebCreationSettingsResultSchema
)
}
trustedHooks(): Promise<MobileWebCreationTrustedHooksResult> {
return this.emptyRequest(
'creationTrustedHooks',
MobileWebCreationTrustedHooksPayloadSchema,
MobileWebCreationTrustedHooksResultSchema
)
}
gitLabAvailable(): Promise<boolean> {
return this.availability('creationGitLabAvailability')
}
linearAvailable(): Promise<boolean> {
return this.availability('creationLinearAvailability')
}
sshState(payload: MobileWebCreationRepoPayload): Promise<MobileWebCreationSshStateResult> {
return this.repoRequest<MobileWebCreationSshStateResult>(
'creationSshState',
payload,
MobileWebCreationSshStateResultSchema
).then((result) => matchingRepoTarget(payload, result))
}
sshConnect(payload: MobileWebCreationRepoPayload): Promise<MobileWebCreationSshStateResult> {
return this.repoRequest<MobileWebCreationSshStateResult>(
'creationSshConnect',
payload,
MobileWebCreationSshStateResultSchema
).then((result) => matchingRepoTarget(payload, result))
}
detectAgents(payload: MobileWebCreationAgentDetectionPayload): Promise<string[]> {
return this.requests
.request(
'workspace',
'creationDetectAgents',
payload,
MobileWebCreationAgentDetectionPayloadSchema,
MobileWebCreationAgentDetectionResultSchema
)
.then((result) => result.agentIds)
}
repoHooks(payload: MobileWebCreationRepoPayload): Promise<MobileWebCreationRepoHooksResult> {
return this.repoRequest('creationRepoHooks', payload, MobileWebCreationRepoHooksResultSchema)
}
runtimeCapabilities(): Promise<MobileWebCreationRuntimeCapabilitiesResult> {
return this.emptyRequest(
'creationRuntimeCapabilities',
MobileWebCreationRuntimeCapabilitiesPayloadSchema,
MobileWebCreationRuntimeCapabilitiesResultSchema
)
}
sparsePresets(payload: MobileWebCreationRepoPayload) {
return this.requests
.request(
'workspace',
'creationSparsePresets',
payload,
MobileWebCreationRepoPayloadSchema,
MobileWebCreationSparsePresetsResultSchema
)
.then((result) => {
if (result.presets.some((preset) => preset.repoId !== payload.repoId)) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return result.presets
})
}
saveSparsePreset(payload: MobileWebCreationSparsePresetSavePayload) {
return this.requests
.request(
'workspace',
'creationSaveSparsePreset',
payload,
MobileWebCreationSparsePresetSavePayloadSchema,
MobileWebCreationSparsePresetSaveResultSchema
)
.then((result) => {
const preset = result.preset
if (
preset.repoId !== payload.repoId ||
(payload.id !== undefined && preset.id !== payload.id) ||
preset.name !== payload.name ||
!sameStrings(preset.directories, payload.directories)
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return preset
})
}
persistTrust(
payload: MobileWebCreationPersistTrustPayload
): Promise<MobileWebCreationTrustedHooksResult> {
return this.requests.request(
'workspace',
'creationPersistTrust',
payload,
MobileWebCreationPersistTrustPayloadSchema,
MobileWebCreationTrustedHooksResultSchema
)
}
private availability(operation: MobileWebBridgeOperationName<'workspace'>): Promise<boolean> {
return this.emptyRequest<{ available: boolean }>(
operation,
MobileWebCreationAvailabilityPayloadSchema,
MobileWebCreationAvailabilityResultSchema
).then((result) => result.available)
}
private repoRequest<TResult>(
operation: MobileWebBridgeOperationName<'workspace'>,
payload: MobileWebCreationRepoPayload,
resultSchema: Parameters<MobileWebOneShotRequestClient['request']>[4]
): Promise<TResult> {
return this.requests.request(
'workspace',
operation,
payload,
MobileWebCreationRepoPayloadSchema,
resultSchema
) as Promise<TResult>
}
private emptyRequest<TResult>(
operation: MobileWebBridgeOperationName<'workspace'>,
payloadSchema: Parameters<MobileWebOneShotRequestClient['request']>[3],
resultSchema: Parameters<MobileWebOneShotRequestClient['request']>[4]
): Promise<TResult> {
return this.requests.request(
'workspace',
operation,
{},
payloadSchema,
resultSchema
) as Promise<TResult>
}
}
function matchingRepoTarget<TResult extends { targetId: string }>(
payload: MobileWebCreationRepoPayload,
result: TResult
): TResult {
if (result.targetId !== payload.repoId) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return result
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
@@ -1,191 +0,0 @@
import type { z } from 'zod'
import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry'
import {
MobileWebCreationBranchSearchResultSchema,
MobileWebCreationGitHubLookupPayloadSchema,
MobileWebCreationGitHubLookupResultSchema,
MobileWebCreationGitHubRepoLookupPayloadSchema,
MobileWebCreationGitHubSearchResultSchema,
MobileWebCreationGitLabLookupPayloadSchema,
MobileWebCreationGitLabLookupResultSchema,
MobileWebCreationGitLabSearchPayloadSchema,
MobileWebCreationGitLabSearchResultSchema,
MobileWebCreationHostedBaseResultSchema,
MobileWebCreationLinearSearchPayloadSchema,
MobileWebCreationLinearSearchResultSchema,
MobileWebCreationMrBasePayloadSchema,
MobileWebCreationPrBasePayloadSchema,
MobileWebCreationRepoQueryPayloadSchema,
MobileWebCreationRepoSlugResultSchema,
type MobileWebCreationGitHubItem,
type MobileWebCreationGitLabItem,
type MobileWebCreationHostedBaseResult,
type MobileWebCreationLinearIssue
} from '../../shared/mobile-web/workspace-creation-source-contract'
import { MobileWebCreationRepoPayloadSchema } from '../../shared/mobile-web/workspace-creation-read-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export class MobileWebWorkspaceCreationSourceRequestClient {
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
searchGitHub(repoId: string, query: string): Promise<MobileWebCreationGitHubItem[]> {
return this.request(
'creationSearchGitHub',
{ repoId, query },
MobileWebCreationRepoQueryPayloadSchema,
MobileWebCreationGitHubSearchResultSchema
).then((result) => matchingRepoItems(repoId, result.items))
}
searchGitLab(
repoId: string,
query: string,
state: 'opened' | 'merged' | 'closed' | 'all'
): Promise<MobileWebCreationGitLabItem[]> {
return this.request(
'creationSearchGitLab',
{ repoId, query, state },
MobileWebCreationGitLabSearchPayloadSchema,
MobileWebCreationGitLabSearchResultSchema
).then((result) => matchingRepoItems(repoId, result.items))
}
searchLinear(
query: string,
linearWorkspaceId: string | null | undefined
): Promise<MobileWebCreationLinearIssue[]> {
return this.request(
'creationSearchLinear',
{ query, linearWorkspaceId },
MobileWebCreationLinearSearchPayloadSchema,
MobileWebCreationLinearSearchResultSchema
).then((result) => result.issues)
}
searchBranches(repoId: string, query: string) {
return this.request(
'creationSearchBranches',
{ repoId, query },
MobileWebCreationRepoQueryPayloadSchema,
MobileWebCreationBranchSearchResultSchema
).then((result) => result.branches)
}
resolveRepoSlug(repoId: string) {
return this.request(
'creationResolveRepoSlug',
{ repoId },
MobileWebCreationRepoPayloadSchema,
MobileWebCreationRepoSlugResultSchema
)
}
lookupGitHub(repoId: string, number: number): Promise<MobileWebCreationGitHubItem | null> {
return this.request(
'creationLookupGitHub',
{ repoId, number },
MobileWebCreationGitHubLookupPayloadSchema,
MobileWebCreationGitHubLookupResultSchema
).then((result) => matchingLookup(result.item, { repoId, number }))
}
lookupGitHubRepo(payload: {
repoId: string
slug: { owner: string; repo: string; host?: string }
number: number
type: 'issue' | 'pr'
}): Promise<MobileWebCreationGitHubItem | null> {
return this.request(
'creationLookupGitHubRepo',
payload,
MobileWebCreationGitHubRepoLookupPayloadSchema,
MobileWebCreationGitHubLookupResultSchema
).then((result) => matchingLookup(result.item, payload))
}
lookupGitLab(payload: {
repoId: string
host: string
path: string
iid: number
type: 'issue' | 'mr'
}): Promise<MobileWebCreationGitLabItem | null> {
return this.request(
'creationLookupGitLab',
payload,
MobileWebCreationGitLabLookupPayloadSchema,
MobileWebCreationGitLabLookupResultSchema
).then((result) =>
matchingLookup(result.item, {
repoId: payload.repoId,
number: payload.iid,
type: payload.type
})
)
}
resolvePrBase(payload: {
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}): Promise<MobileWebCreationHostedBaseResult> {
return this.request(
'creationResolvePrBase',
payload,
MobileWebCreationPrBasePayloadSchema,
MobileWebCreationHostedBaseResultSchema
)
}
resolveMrBase(payload: {
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}): Promise<MobileWebCreationHostedBaseResult> {
return this.request(
'creationResolveMrBase',
payload,
MobileWebCreationMrBasePayloadSchema,
MobileWebCreationHostedBaseResultSchema
)
}
private request<TPayload, TResult>(
operation: MobileWebBridgeOperationName<'workspace'>,
payload: TPayload,
payloadSchema: z.ZodType<TPayload>,
resultSchema: z.ZodType<TResult>
): Promise<TResult> {
return this.requests.request('workspace', operation, payload, payloadSchema, resultSchema)
}
}
function matchingRepoItems<TItem extends { repoId: string }>(
repoId: string,
items: TItem[]
): TItem[] {
if (items.some((item) => item.repoId !== repoId)) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return items
}
function matchingLookup<
TItem extends { repoId: string; number: number; type: string },
TPayload extends { repoId: string; number: number; type?: string }
>(item: TItem | null, payload: TPayload): TItem | null {
if (
item &&
(item.repoId !== payload.repoId ||
item.number !== payload.number ||
(payload.type !== undefined && item.type !== payload.type))
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return item
}
@@ -66,11 +66,7 @@ const EXPECTED_ECHO_FIELDS: Record<string, readonly string[]> = {
'task.loadLinearDetail': ['issue.targetId'],
'task.loadLinearIssue': ['issue.targetId'],
'task.projectTable': ['project', 'selectedView.id'],
'task.resolveProjectRef': ['host'],
'workspace.creationSaveSparsePreset': ['directories', 'id', 'name', 'repoId'],
'workspace.creationSparsePresets': ['repoId'],
'workspace.creationSshConnect': ['targetId'],
'workspace.creationSshState': ['targetId']
'task.resolveProjectRef': ['host']
}
/** Echo helpers shared across request clients, and the result fields each one compares. Resolved
@@ -293,7 +289,7 @@ describe('mobile web bridge operation echo census', () => {
)
expect(Object.keys(EXPECTED_ECHO_FIELDS).filter((key) => !registered.has(key))).toEqual([])
expect(Object.keys(EXPECTED_ECHO_FIELDS).length).toBeGreaterThanOrEqual(46)
expect(Object.keys(EXPECTED_ECHO_FIELDS).length).toBeGreaterThanOrEqual(42)
})
it('guards the page workspace handle on every workspace-scoped echo it records', () => {
@@ -301,6 +297,6 @@ describe('mobile web bridge operation echo census', () => {
fields.some((field) => field === 'workspaceId')
)
expect(workspaceScoped.length).toBeGreaterThanOrEqual(37)
expect(workspaceScoped.length).toBeGreaterThanOrEqual(34)
})
})
@@ -30,7 +30,7 @@ describe('mobile web bridge operation registry census', () => {
}
expect(files.length).toBeGreaterThanOrEqual(40)
expect(named.size).toBeGreaterThanOrEqual(123)
expect(named.size).toBeGreaterThanOrEqual(119)
expect([...named].filter((pair) => !registered.has(pair))).toEqual([])
})
@@ -61,7 +61,7 @@ describe('mobile web bridge operation registry census', () => {
}
}
expect(pairs.size).toBeGreaterThanOrEqual(123)
expect(pairs.size).toBeGreaterThanOrEqual(119)
expect([...pairs].filter(([, schemas]) => schemas.size !== 1).map(([key]) => key)).toEqual([])
expect([...pairs.keys()].filter((key) => !registered.has(key))).toEqual([])
expect(
@@ -9,30 +9,6 @@ export const MOBILE_WEB_BRIDGE_OPERATIONS = {
hostSubscribe: 'subscription',
hostRequest: 'mutation',
snapshot: 'read',
creationRepositories: 'read',
creationRetiredNames: 'read',
creationSettings: 'read',
creationTrustedHooks: 'read',
creationGitLabAvailability: 'read',
creationLinearAvailability: 'read',
creationSshState: 'read',
creationSshConnect: 'mutation',
creationDetectAgents: 'read',
creationRepoHooks: 'read',
creationRuntimeCapabilities: 'read',
creationSparsePresets: 'read',
creationSaveSparsePreset: 'mutation',
creationPersistTrust: 'mutation',
creationSearchGitHub: 'read',
creationSearchGitLab: 'read',
creationSearchLinear: 'read',
creationSearchBranches: 'read',
creationResolveRepoSlug: 'read',
creationLookupGitHub: 'read',
creationLookupGitHubRepo: 'read',
creationLookupGitLab: 'read',
creationResolvePrBase: 'read',
creationResolveMrBase: 'read',
creationCreateBlank: 'mutation',
creationCreateFromSource: 'mutation'
},
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { MobileWebCreationTrustedHooksResultSchema } from './workspace-creation-read-contract'
import { MobileWebCreationTrustedHooksResultSchema } from './workspace-creation-repo-trust-contract'
const EmptyPayloadSchema = z.object({}).strict()
const TaskProviderSchema = z.enum(['github', 'gitlab', 'linear'])
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { MobileWebCreationRepoIdSchema } from './workspace-creation-read-contract'
import { MobileWebCreationRepoIdSchema } from './workspace-creation-repo-trust-contract'
const NameSchema = z.string().min(1).max(160)
const OptionalTextSchema = z.string().max(4096).optional()
@@ -1,245 +0,0 @@
import { z } from 'zod'
import { isMobileWebSha256 } from './protocol-token-contract'
export type MobileWebExecutionHostId = 'local' | `ssh:${string}` | `runtime:${string}`
export const MobileWebCreationRepoIdSchema = z.string().min(1).max(128)
const EmptyPayloadSchema = z.object({}).strict()
const TrustedHookEntrySchema = z
.object({
contentHash: z.string().refine(isMobileWebSha256),
approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
})
.strict()
const TrustedHookRepoSchema = z
.object({
all: z
.object({ approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) })
.strict()
.optional(),
setup: TrustedHookEntrySchema.optional(),
archive: TrustedHookEntrySchema.optional(),
issueCommand: TrustedHookEntrySchema.optional(),
vmRecipe: TrustedHookEntrySchema.optional()
})
.strict()
export const MobileWebCreationRepositoriesPayloadSchema = EmptyPayloadSchema
export const MobileWebCreationRepositoriesResultSchema = z
.object({
repositories: z
.array(
z
.object({
id: MobileWebCreationRepoIdSchema,
displayName: z.string().min(1).max(240),
path: z.string().max(4_096),
badgeColor: z.string().max(64).optional(),
connectionId: MobileWebCreationRepoIdSchema.nullable().optional(),
executionHostId: z.custom<MobileWebExecutionHostId>(
(value) =>
value === 'local' ||
(typeof value === 'string' &&
(value.startsWith('ssh:executionHost_') ||
value.startsWith('runtime:executionHost_')))
),
executionHostLabel: z.string().min(1).max(240),
projectId: z.string().min(1).max(128),
upstream: z
.object({
owner: z.string().min(1).max(240),
repo: z.string().min(1).max(240),
host: z.string().min(1).max(240).optional()
})
.strict()
.nullable()
.optional(),
kind: z.enum(['git', 'folder']).optional()
})
.strict()
)
.max(10_000)
})
.strict()
export const MobileWebCreationSettingsPayloadSchema = EmptyPayloadSchema
export const MobileWebCreationSettingsResultSchema = z
.object({
defaultTuiAgent: z.string().min(1).max(64).nullable().optional(),
disabledTuiAgents: z.array(z.string().min(1).max(64)).max(64).optional(),
visibleTaskProviders: z
.array(z.enum(['github', 'gitlab', 'linear']))
.max(3)
.optional()
})
.strict()
export const MobileWebCreationTrustedHooksPayloadSchema = EmptyPayloadSchema
export const MobileWebCreationTrustedHooksResultSchema = z.record(
MobileWebCreationRepoIdSchema,
TrustedHookRepoSchema
)
export const MobileWebCreationAvailabilityPayloadSchema = EmptyPayloadSchema
export const MobileWebCreationAvailabilityResultSchema = z
.object({ available: z.boolean() })
.strict()
export const MobileWebCreationRepoPayloadSchema = z
.object({ repoId: MobileWebCreationRepoIdSchema })
.strict()
export const MobileWebCreationRetiredNamesResultSchema = z
.object({
exhaustedTiers: z.number().int().nonnegative().max(999_999),
names: z.array(z.string().min(1).max(240)).max(1_000)
})
.strict()
export const MobileWebCreationAgentDetectionPayloadSchema = z
.object({ repoId: MobileWebCreationRepoIdSchema.nullable() })
.strict()
export const MobileWebCreationAgentDetectionResultSchema = z
.object({ agentIds: z.array(z.string().min(1).max(64)).max(64) })
.strict()
export const MobileWebCreationSshStateResultSchema = z
.object({
targetId: MobileWebCreationRepoIdSchema,
status: z.enum([
'disconnected',
'connecting',
'auth-failed',
'deploying-relay',
'connected',
'reconnecting',
'reconnection-failed',
'error'
]),
error: z.string().max(160).nullable(),
reconnectAttempt: z.number().int().nonnegative().max(1_000_000),
supportsFolderDownload: z.boolean().optional(),
remotePlatform: z.enum(['linux', 'darwin', 'win32']).optional()
})
.strict()
export const MobileWebCreationRepoHooksResultSchema = z
.object({
hooks: z
.object({
scripts: z
.object({
setup: z
.string()
.max(64 * 1024)
.optional()
})
.strict()
.optional()
})
.strict()
.nullable(),
source: z.string().max(80).nullable(),
setupRunPolicy: z.enum(['ask', 'run-by-default', 'skip-by-default']).optional(),
setupTrust: z
.object({
contentHash: z.string().refine(isMobileWebSha256),
scriptContent: z.string().max(64 * 1024)
})
.strict()
.optional()
})
.strict()
export const MobileWebCreationRuntimeCapabilitiesPayloadSchema = EmptyPayloadSchema
export const MobileWebCreationRuntimeCapabilitiesResultSchema = z
.object({
tasksSupported: z.boolean(),
idempotentWorktreeCreateSupported: z.boolean(),
worktreeCreateIdempotency: z
.object({ dedupeTtlMs: z.number().int().nonnegative() })
.strict()
.or(z.literal(false)),
hostPlatform: z
.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
])
.nullable()
})
.strict()
export const MobileWebCreationSparsePresetsResultSchema = z
.object({
presets: z
.array(
z
.object({
id: z.string().min(1).max(240),
repoId: MobileWebCreationRepoIdSchema,
name: z.string().min(1).max(240),
directories: z.array(z.string().min(1).max(4_096)).max(1_000),
createdAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
updatedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
})
.strict()
)
.max(1_000)
})
.strict()
export const MobileWebCreationSparsePresetSavePayloadSchema = z
.object({
repoId: MobileWebCreationRepoIdSchema,
id: z.string().min(1).max(240).optional(),
name: z.string().trim().min(1).max(240),
directories: z.array(z.string().min(1).max(4_096)).min(1).max(1_000)
})
.strict()
export const MobileWebCreationSparsePresetSaveResultSchema = z
.object({ preset: MobileWebCreationSparsePresetsResultSchema.shape.presets.element })
.strict()
export const MobileWebCreationPersistTrustPayloadSchema = z
.object({
trust: MobileWebCreationTrustedHooksResultSchema,
repoId: MobileWebCreationRepoIdSchema,
contentHash: z.string().refine(isMobileWebSha256),
alwaysTrust: z.boolean()
})
.strict()
export type MobileWebCreationRepositoriesResult = z.infer<
typeof MobileWebCreationRepositoriesResultSchema
>
export type MobileWebCreationSettingsResult = z.infer<typeof MobileWebCreationSettingsResultSchema>
export type MobileWebCreationRuntimeCapabilitiesResult = z.infer<
typeof MobileWebCreationRuntimeCapabilitiesResultSchema
>
export type MobileWebCreationTrustedHooksResult = z.infer<
typeof MobileWebCreationTrustedHooksResultSchema
>
export type MobileWebCreationRepoPayload = z.infer<typeof MobileWebCreationRepoPayloadSchema>
export type MobileWebCreationRetiredNamesResult = z.infer<
typeof MobileWebCreationRetiredNamesResultSchema
>
export type MobileWebCreationAgentDetectionPayload = z.infer<
typeof MobileWebCreationAgentDetectionPayloadSchema
>
export type MobileWebCreationSshStateResult = z.infer<typeof MobileWebCreationSshStateResultSchema>
export type MobileWebCreationRepoHooksResult = z.infer<
typeof MobileWebCreationRepoHooksResultSchema
>
export type MobileWebCreationPersistTrustPayload = z.infer<
typeof MobileWebCreationPersistTrustPayloadSchema
>
export type MobileWebCreationSparsePresetSavePayload = z.infer<
typeof MobileWebCreationSparsePresetSavePayloadSchema
>
@@ -0,0 +1,33 @@
import { z } from 'zod'
import { isMobileWebSha256 } from './protocol-token-contract'
export const MobileWebCreationRepoIdSchema = z.string().min(1).max(128)
const TrustedHookEntrySchema = z
.object({
contentHash: z.string().refine(isMobileWebSha256),
approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
})
.strict()
const TrustedHookRepoSchema = z
.object({
all: z
.object({ approvedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) })
.strict()
.optional(),
setup: TrustedHookEntrySchema.optional(),
archive: TrustedHookEntrySchema.optional(),
issueCommand: TrustedHookEntrySchema.optional(),
vmRecipe: TrustedHookEntrySchema.optional()
})
.strict()
export const MobileWebCreationTrustedHooksResultSchema = z.record(
MobileWebCreationRepoIdSchema,
TrustedHookRepoSchema
)
export type MobileWebCreationTrustedHooksResult = z.infer<
typeof MobileWebCreationTrustedHooksResultSchema
>
@@ -1,187 +0,0 @@
import { z } from 'zod'
import { MobileWebCreationRepoIdSchema } from './workspace-creation-read-contract'
const QuerySchema = z.string().max(2048)
const TitleSchema = z.string().min(1).max(512)
const UrlSchema = z.string().url().max(2048)
const OptionalBranchSchema = z.string().min(1).max(512).optional()
const IssueNumberSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
export const MobileWebCreationRepoQueryPayloadSchema = z
.object({ repoId: MobileWebCreationRepoIdSchema, query: QuerySchema })
.strict()
export const MobileWebCreationGitLabSearchPayloadSchema =
MobileWebCreationRepoQueryPayloadSchema.extend({
state: z.enum(['opened', 'merged', 'closed', 'all'])
}).strict()
export const MobileWebCreationLinearSearchPayloadSchema = z
.object({
query: QuerySchema,
linearWorkspaceId: z.string().min(1).max(256).nullable().optional()
})
.strict()
export const MobileWebCreationGitHubItemSchema = z
.object({
id: z.string().min(1).max(256),
type: z.enum(['issue', 'pr']),
number: IssueNumberSchema,
title: TitleSchema,
state: z.enum(['open', 'closed', 'merged', 'draft']),
url: UrlSchema,
labels: z.array(z.string().max(120)).max(100),
updatedAt: z.string().max(80),
author: z.string().max(160).nullable(),
branchName: OptionalBranchSchema,
baseRefName: OptionalBranchSchema,
isCrossRepository: z.boolean().optional(),
repoId: MobileWebCreationRepoIdSchema
})
.strict()
export const MobileWebCreationGitLabItemSchema = z
.object({
id: z.string().min(1).max(256),
type: z.enum(['issue', 'mr']),
number: IssueNumberSchema,
title: TitleSchema,
state: z.enum(['opened', 'closed', 'merged', 'locked', 'draft']),
url: UrlSchema,
labels: z.array(z.string().max(120)).max(100),
updatedAt: z.string().max(80),
author: z.string().max(160).nullable(),
branchName: OptionalBranchSchema,
baseRefName: OptionalBranchSchema,
isCrossRepository: z.boolean().optional(),
repoId: MobileWebCreationRepoIdSchema
})
.strict()
export const MobileWebCreationLinearIssueSchema = z
.object({
id: z.string().min(1).max(256),
workspaceId: z.string().min(1).max(256).optional(),
identifier: z.string().min(1).max(80),
title: TitleSchema,
branchName: OptionalBranchSchema,
url: UrlSchema,
state: z
.object({
name: z.string().max(120),
type: z.string().max(80),
color: z.string().max(64)
})
.strict(),
team: z
.object({
id: z.string().min(1).max(256),
name: z.string().max(160),
key: z.string().max(40)
})
.strict(),
labels: z.array(z.string().max(120)).max(100),
labelIds: z.array(z.string().max(256)).max(100),
priority: z.number().int().min(0).max(10),
updatedAt: z.string().max(80)
})
.strict()
export const MobileWebCreationGitHubSearchResultSchema = z
.object({ items: z.array(MobileWebCreationGitHubItemSchema).max(50) })
.strict()
export const MobileWebCreationGitLabSearchResultSchema = z
.object({ items: z.array(MobileWebCreationGitLabItemSchema).max(50) })
.strict()
export const MobileWebCreationLinearSearchResultSchema = z
.object({ issues: z.array(MobileWebCreationLinearIssueSchema).max(50) })
.strict()
export const MobileWebCreationBranchResultSchema = z
.object({
refName: z.string().min(1).max(512),
localBranchName: z.string().max(512)
})
.strict()
export const MobileWebCreationBranchSearchResultSchema = z
.object({ branches: z.array(MobileWebCreationBranchResultSchema).max(20) })
.strict()
export const MobileWebCreationRepoSlugResultSchema = z
.object({
supported: z.boolean(),
slug: z
.object({
owner: z.string().min(1).max(256),
repo: z.string().min(1).max(256),
host: z.string().min(1).max(256).optional()
})
.strict()
.nullable()
})
.strict()
export const MobileWebCreationGitHubLookupPayloadSchema = z
.object({ repoId: MobileWebCreationRepoIdSchema, number: IssueNumberSchema })
.strict()
export const MobileWebCreationGitHubRepoLookupPayloadSchema = z
.object({
repoId: MobileWebCreationRepoIdSchema,
slug: MobileWebCreationRepoSlugResultSchema.shape.slug.unwrap(),
number: IssueNumberSchema,
type: z.enum(['issue', 'pr'])
})
.strict()
export const MobileWebCreationGitLabLookupPayloadSchema = z
.object({
repoId: MobileWebCreationRepoIdSchema,
host: z.string().min(1).max(256),
path: z.string().min(1).max(1024),
iid: IssueNumberSchema,
type: z.enum(['issue', 'mr'])
})
.strict()
export const MobileWebCreationGitHubLookupResultSchema = z
.object({ item: MobileWebCreationGitHubItemSchema.nullable() })
.strict()
export const MobileWebCreationGitLabLookupResultSchema = z
.object({ item: MobileWebCreationGitLabItemSchema.nullable() })
.strict()
const HostedBaseShape = {
baseBranch: z.string().min(1).max(512),
compareBaseRef: z.string().min(1).max(512).optional(),
pushTarget: z
.object({
remoteName: z.string().min(1).max(256),
branchName: z.string().min(1).max(512)
})
.strict()
.optional(),
branchNameOverride: z.string().min(1).max(512).optional(),
maintainerCanModify: z.boolean().optional()
} as const
export const MobileWebCreationHostedBaseResultSchema = z.object(HostedBaseShape).strict()
export const MobileWebCreationPrBasePayloadSchema = z
.object({
repoId: MobileWebCreationRepoIdSchema,
prNumber: IssueNumberSchema,
headRefName: OptionalBranchSchema,
baseRefName: OptionalBranchSchema,
isCrossRepository: z.boolean().optional()
})
.strict()
export const MobileWebCreationMrBasePayloadSchema = z
.object({
repoId: MobileWebCreationRepoIdSchema,
mrIid: IssueNumberSchema,
sourceBranch: OptionalBranchSchema,
targetBranch: OptionalBranchSchema,
isCrossRepository: z.boolean().optional()
})
.strict()
export type MobileWebCreationGitHubItem = z.infer<typeof MobileWebCreationGitHubItemSchema>
export type MobileWebCreationGitLabItem = z.infer<typeof MobileWebCreationGitLabItemSchema>
export type MobileWebCreationLinearIssue = z.infer<typeof MobileWebCreationLinearIssueSchema>
export type MobileWebCreationHostedBaseResult = z.infer<
typeof MobileWebCreationHostedBaseResultSchema
>