diff --git a/mobile/src/mobile-web/mobile-web-capability-broker-backpressure.test.ts b/mobile/src/mobile-web/mobile-web-capability-broker-backpressure.test.ts index d869f6ee6f3..31fb87fc2e8 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker-backpressure.test.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker-backpressure.test.ts @@ -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 = {}) { @@ -225,7 +228,19 @@ function createHarness(nativeAuthority: Record = {}) { now: () => 1000 }) const send = async (slot: Slot, requestId: string): Promise => { - 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 } diff --git a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts index 0a3ea7b9091..28fad5ef88c 100644 --- a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts @@ -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', () => { diff --git a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts index 21d3303270d..c554df43e6d 100644 --- a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts @@ -36,8 +36,7 @@ const REAUTHORIZATION_SITES: Record = { '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 { @@ -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', () => { diff --git a/mobile/src/mobile-web/mobile-web-production-workspace-creation-grants.ts b/mobile/src/mobile-web/mobile-web-production-workspace-creation-grants.ts index f9477af4d46..015e6b95bbd 100644 --- a/mobile/src/mobile-web/mobile-web-production-workspace-creation-grants.ts +++ b/mobile/src/mobile-web/mobile-web-production-workspace-creation-grants.ts @@ -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) }) diff --git a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts index a58ce9558f6..2193212efe2 100644 --- a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts @@ -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) diff --git a/mobile/src/mobile-web/mobile-web-workspace-authority.ts b/mobile/src/mobile-web/mobile-web-workspace-authority.ts index cafc44e7549..19fd0959752 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-authority.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-authority.ts @@ -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) } diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts index 522ee29fd5c..5cee8f97db7 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.test.ts @@ -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 = {}) { + 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', diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts index 02a4726eae3..8e9e2b09e91 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-create-operations.ts @@ -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, - authority: MobileWebWorkspaceAuthority + operations: ReturnType ): Promise { - 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 }) diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts deleted file mode 100644 index 455d8437d56..00000000000 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-provider-revalidation.test.ts +++ /dev/null @@ -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 - 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' - } -} diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-read-operations.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-read-operations.test.ts deleted file mode 100644 index 7ae10657979..00000000000 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-read-operations.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-read-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-read-operations.ts deleted file mode 100644 index 706a6d633c7..00000000000 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-read-operations.ts +++ /dev/null @@ -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 { - 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 -): string { - const host = parseExecutionHostId(executionHostId) - return host?.kind === 'local' ? getExecutionHostLabel(executionHostId) : 'Host' -} - -async function executeRepoCreationRead( - args: { - operation: string - payload: unknown - authority: MobileWebWorkspaceAuthority - }, - operations: ReturnType -): Promise { - 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]) - ) -} diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts deleted file mode 100644 index c7d71806daa..00000000000 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-source-operations.test.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-source-operations.test.ts deleted file mode 100644 index 56e52f3ef01..00000000000 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-source-operations.test.ts +++ /dev/null @@ -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)) -} diff --git a/mobile/src/mobile-web/mobile-web-workspace-creation-source-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-creation-source-operations.ts deleted file mode 100644 index d30503cedcf..00000000000 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-source-operations.ts +++ /dev/null @@ -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 { - 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, - repoId: string, - query: string -): ReturnType { - 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 -): Promise { - 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() -} diff --git a/mobile/src/mobile-web/mobile-web-workspace-operations.ts b/mobile/src/mobile-web/mobile-web-workspace-operations.ts index 9aaf8304132..15dc465393a 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-operations.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-operations.ts @@ -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') } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts index 7e99dd27013..45da479393a 100644 --- a/mobile/src/tasks/composer-source-base-resolve.ts +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -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 diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 72381393989..f779c2141f9 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -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 diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts index 876e2ef1a20..734b435657f 100644 --- a/mobile/src/tasks/smart-source-search-requests.ts +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -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 { @@ -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 { @@ -94,7 +94,7 @@ export async function searchLinearIssues( } export async function searchBranches( - client: RpcClient, + client: RpcRequestSender, repoId: string, query: string ): Promise { diff --git a/mobile/src/tasks/web-host-task-preference-operations.test.ts b/mobile/src/tasks/web-host-task-preference-operations.test.ts index 57970a51474..ef329b1cb1f 100644 --- a/mobile/src/tasks/web-host-task-preference-operations.test.ts +++ b/mobile/src/tasks/web-host-task-preference-operations.test.ts @@ -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 } } } }) }) }) diff --git a/mobile/src/tasks/web-host-task-preference-operations.ts b/mobile/src/tasks/web-host-task-preference-operations.ts index eec5a20cd47..ee50c96417e 100644 --- a/mobile/src/tasks/web-host-task-preference-operations.ts +++ b/mobile/src/tasks/web-host-task-preference-operations.ts @@ -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 }) } } diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index ef32e15c488..b5868c866c9 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -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 { for (let migrationRetry = 0; ; migrationRetry += 1) { try { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 01bdd2e8fce..08d5a387310 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 + export type ConnectOptions = { onStateChange?: (state: ConnectionState) => void onLog?: ConnectionLogSink diff --git a/mobile/src/worktree/native-host-workspace-creation-operations.ts b/mobile/src/worktree/native-host-workspace-creation-operations.ts index 7544ea3e9e5..3f76cb4e8d1 100644 --- a/mobile/src/worktree/native-host-workspace-creation-operations.ts +++ b/mobile/src/worktree/native-host-workspace-creation-operations.ts @@ -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> - } - ).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> - } - ).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, diff --git a/mobile/src/worktree/native-host-workspace-creation-read-operations.ts b/mobile/src/worktree/native-host-workspace-creation-read-operations.ts index 6aba91c5de7..cb13a1caae0 100644 --- a/mobile/src/worktree/native-host-workspace-creation-read-operations.ts +++ b/mobile/src/worktree/native-host-workspace-creation-read-operations.ts @@ -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( - responsePromise: ReturnType + responsePromise: ReturnType ): Promise { const response = await responsePromise if (!response.ok) { diff --git a/mobile/src/worktree/native-host-workspace-creation-source-operations.ts b/mobile/src/worktree/native-host-workspace-creation-source-operations.ts index 29e9f7fa893..57743c4a1af 100644 --- a/mobile/src/worktree/native-host-workspace-creation-source-operations.ts +++ b/mobile/src/worktree/native-host-workspace-creation-source-operations.ts @@ -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), diff --git a/mobile/src/worktree/rpc-workspace-creation-operations.ts b/mobile/src/worktree/rpc-workspace-creation-operations.ts new file mode 100644 index 00000000000..0a411d65fc4 --- /dev/null +++ b/mobile/src/worktree/rpc-workspace-creation-operations.ts @@ -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> + } + ).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> + } + ).preset + if (!preset) { + throw new Error('Failed to save sparse preset.') + } + return preset + }, + persistSetupTrust: (args) => persistSetupHookTrustApproval({ client, ...args }) + } +} diff --git a/mobile/src/worktree/web-host-workspace-creation-operations.test.ts b/mobile/src/worktree/web-host-workspace-creation-operations.test.ts index f23f4a14906..51151523ea9 100644 --- a/mobile/src/worktree/web-host-workspace-creation-operations.test.ts +++ b/mobile/src/worktree/web-host-workspace-creation-operations.test.ts @@ -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([]) } } } diff --git a/mobile/src/worktree/web-host-workspace-creation-operations.ts b/mobile/src/worktree/web-host-workspace-creation-operations.ts index b95709c6746..94f449f84bb 100644 --- a/mobile/src/worktree/web-host-workspace-creation-operations.ts +++ b/mobile/src/worktree/web-host-workspace-creation-operations.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts index 6f5dbeae6a9..891abf25bd8 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts @@ -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', diff --git a/src/mobile-web/src/mobile-web-bridge-client.ts b/src/mobile-web/src/mobile-web-bridge-client.ts index cba8b1ec43c..1b53a83b269 100644 --- a/src/mobile-web/src/mobile-web-bridge-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-client.ts @@ -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 type OperationGrant = InitMessage['grants'][number] @@ -67,6 +66,7 @@ export class MobileWebBridgeClient { private readonly grants = new Map() 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({ diff --git a/src/mobile-web/src/mobile-web-host-rpc-sender.test.ts b/src/mobile-web/src/mobile-web-host-rpc-sender.test.ts new file mode 100644 index 00000000000..5ddd779792e --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-rpc-sender.test.ts @@ -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 + }) + }) +}) diff --git a/src/mobile-web/src/mobile-web-host-rpc-sender.ts b/src/mobile-web/src/mobile-web-host-rpc-sender.ts new file mode 100644 index 00000000000..edd113b6a42 --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-rpc-sender.ts @@ -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 +} + +const META = { runtimeId: 'hosted' } + +function record(params: unknown): Record { + return typeof params === 'object' && params !== null && !Array.isArray(params) + ? (params as Record) + : {} +} + +/** 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 + } + } + } + } +} diff --git a/src/mobile-web/src/mobile-web-operation-response-correlation.test.ts b/src/mobile-web/src/mobile-web-operation-response-correlation.test.ts index 3b28a9d8a1f..59d0718c398 100644 --- a/src/mobile-web/src/mobile-web-operation-response-correlation.test.ts +++ b/src/mobile-web/src/mobile-web-operation-response-correlation.test.ts @@ -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 = {}) { } } -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 = {}) { - 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', diff --git a/src/mobile-web/src/mobile-web-workspace-creation-request-client.ts b/src/mobile-web/src/mobile-web-workspace-creation-request-client.ts deleted file mode 100644 index 53e96d7135a..00000000000 --- a/src/mobile-web/src/mobile-web-workspace-creation-request-client.ts +++ /dev/null @@ -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 { - return this.emptyRequest( - 'creationRepositories', - MobileWebCreationRepositoriesPayloadSchema, - MobileWebCreationRepositoriesResultSchema - ) - } - - retiredNames( - payload: MobileWebCreationRepoPayload - ): Promise { - return this.repoRequest( - 'creationRetiredNames', - payload, - MobileWebCreationRetiredNamesResultSchema - ) - } - - settings(): Promise { - return this.emptyRequest( - 'creationSettings', - MobileWebCreationSettingsPayloadSchema, - MobileWebCreationSettingsResultSchema - ) - } - - trustedHooks(): Promise { - return this.emptyRequest( - 'creationTrustedHooks', - MobileWebCreationTrustedHooksPayloadSchema, - MobileWebCreationTrustedHooksResultSchema - ) - } - - gitLabAvailable(): Promise { - return this.availability('creationGitLabAvailability') - } - - linearAvailable(): Promise { - return this.availability('creationLinearAvailability') - } - - sshState(payload: MobileWebCreationRepoPayload): Promise { - return this.repoRequest( - 'creationSshState', - payload, - MobileWebCreationSshStateResultSchema - ).then((result) => matchingRepoTarget(payload, result)) - } - - sshConnect(payload: MobileWebCreationRepoPayload): Promise { - return this.repoRequest( - 'creationSshConnect', - payload, - MobileWebCreationSshStateResultSchema - ).then((result) => matchingRepoTarget(payload, result)) - } - - detectAgents(payload: MobileWebCreationAgentDetectionPayload): Promise { - return this.requests - .request( - 'workspace', - 'creationDetectAgents', - payload, - MobileWebCreationAgentDetectionPayloadSchema, - MobileWebCreationAgentDetectionResultSchema - ) - .then((result) => result.agentIds) - } - - repoHooks(payload: MobileWebCreationRepoPayload): Promise { - return this.repoRequest('creationRepoHooks', payload, MobileWebCreationRepoHooksResultSchema) - } - - runtimeCapabilities(): Promise { - 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 { - return this.requests.request( - 'workspace', - 'creationPersistTrust', - payload, - MobileWebCreationPersistTrustPayloadSchema, - MobileWebCreationTrustedHooksResultSchema - ) - } - - private availability(operation: MobileWebBridgeOperationName<'workspace'>): Promise { - return this.emptyRequest<{ available: boolean }>( - operation, - MobileWebCreationAvailabilityPayloadSchema, - MobileWebCreationAvailabilityResultSchema - ).then((result) => result.available) - } - - private repoRequest( - operation: MobileWebBridgeOperationName<'workspace'>, - payload: MobileWebCreationRepoPayload, - resultSchema: Parameters[4] - ): Promise { - return this.requests.request( - 'workspace', - operation, - payload, - MobileWebCreationRepoPayloadSchema, - resultSchema - ) as Promise - } - - private emptyRequest( - operation: MobileWebBridgeOperationName<'workspace'>, - payloadSchema: Parameters[3], - resultSchema: Parameters[4] - ): Promise { - return this.requests.request( - 'workspace', - operation, - {}, - payloadSchema, - resultSchema - ) as Promise - } -} - -function matchingRepoTarget( - 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]) -} diff --git a/src/mobile-web/src/mobile-web-workspace-creation-source-request-client.ts b/src/mobile-web/src/mobile-web-workspace-creation-source-request-client.ts deleted file mode 100644 index c9523081d22..00000000000 --- a/src/mobile-web/src/mobile-web-workspace-creation-source-request-client.ts +++ /dev/null @@ -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 { - 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 { - return this.request( - 'creationSearchGitLab', - { repoId, query, state }, - MobileWebCreationGitLabSearchPayloadSchema, - MobileWebCreationGitLabSearchResultSchema - ).then((result) => matchingRepoItems(repoId, result.items)) - } - - searchLinear( - query: string, - linearWorkspaceId: string | null | undefined - ): Promise { - 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 { - 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 { - 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 { - 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 { - return this.request( - 'creationResolvePrBase', - payload, - MobileWebCreationPrBasePayloadSchema, - MobileWebCreationHostedBaseResultSchema - ) - } - - resolveMrBase(payload: { - repoId: string - mrIid: number - sourceBranch?: string - targetBranch?: string - isCrossRepository?: boolean - }): Promise { - return this.request( - 'creationResolveMrBase', - payload, - MobileWebCreationMrBasePayloadSchema, - MobileWebCreationHostedBaseResultSchema - ) - } - - private request( - operation: MobileWebBridgeOperationName<'workspace'>, - payload: TPayload, - payloadSchema: z.ZodType, - resultSchema: z.ZodType - ): Promise { - return this.requests.request('workspace', operation, payload, payloadSchema, resultSchema) - } -} - -function matchingRepoItems( - 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 -} diff --git a/src/shared/mobile-web/bridge-operation-echo-census.test.ts b/src/shared/mobile-web/bridge-operation-echo-census.test.ts index 4882005ab38..44b3cda0a8b 100644 --- a/src/shared/mobile-web/bridge-operation-echo-census.test.ts +++ b/src/shared/mobile-web/bridge-operation-echo-census.test.ts @@ -66,11 +66,7 @@ const EXPECTED_ECHO_FIELDS: Record = { '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) }) }) diff --git a/src/shared/mobile-web/bridge-operation-registry-census.test.ts b/src/shared/mobile-web/bridge-operation-registry-census.test.ts index 1e4bc46d6f3..39b3e00f22d 100644 --- a/src/shared/mobile-web/bridge-operation-registry-census.test.ts +++ b/src/shared/mobile-web/bridge-operation-registry-census.test.ts @@ -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( diff --git a/src/shared/mobile-web/bridge-operation-registry.ts b/src/shared/mobile-web/bridge-operation-registry.ts index 25447b17e0c..ef41b2852d5 100644 --- a/src/shared/mobile-web/bridge-operation-registry.ts +++ b/src/shared/mobile-web/bridge-operation-registry.ts @@ -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' }, diff --git a/src/shared/mobile-web/task-read-contract.ts b/src/shared/mobile-web/task-read-contract.ts index 5edd7d68e09..bf5299183ec 100644 --- a/src/shared/mobile-web/task-read-contract.ts +++ b/src/shared/mobile-web/task-read-contract.ts @@ -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']) diff --git a/src/shared/mobile-web/workspace-creation-create-contract.ts b/src/shared/mobile-web/workspace-creation-create-contract.ts index 116ae3908cb..57be54508f2 100644 --- a/src/shared/mobile-web/workspace-creation-create-contract.ts +++ b/src/shared/mobile-web/workspace-creation-create-contract.ts @@ -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() diff --git a/src/shared/mobile-web/workspace-creation-read-contract.ts b/src/shared/mobile-web/workspace-creation-read-contract.ts deleted file mode 100644 index 28b8afd3a47..00000000000 --- a/src/shared/mobile-web/workspace-creation-read-contract.ts +++ /dev/null @@ -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( - (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 -export type MobileWebCreationRuntimeCapabilitiesResult = z.infer< - typeof MobileWebCreationRuntimeCapabilitiesResultSchema -> -export type MobileWebCreationTrustedHooksResult = z.infer< - typeof MobileWebCreationTrustedHooksResultSchema -> -export type MobileWebCreationRepoPayload = z.infer -export type MobileWebCreationRetiredNamesResult = z.infer< - typeof MobileWebCreationRetiredNamesResultSchema -> -export type MobileWebCreationAgentDetectionPayload = z.infer< - typeof MobileWebCreationAgentDetectionPayloadSchema -> -export type MobileWebCreationSshStateResult = z.infer -export type MobileWebCreationRepoHooksResult = z.infer< - typeof MobileWebCreationRepoHooksResultSchema -> -export type MobileWebCreationPersistTrustPayload = z.infer< - typeof MobileWebCreationPersistTrustPayloadSchema -> -export type MobileWebCreationSparsePresetSavePayload = z.infer< - typeof MobileWebCreationSparsePresetSavePayloadSchema -> diff --git a/src/shared/mobile-web/workspace-creation-repo-trust-contract.ts b/src/shared/mobile-web/workspace-creation-repo-trust-contract.ts new file mode 100644 index 00000000000..07a4c282a0a --- /dev/null +++ b/src/shared/mobile-web/workspace-creation-repo-trust-contract.ts @@ -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 +> diff --git a/src/shared/mobile-web/workspace-creation-source-contract.ts b/src/shared/mobile-web/workspace-creation-source-contract.ts deleted file mode 100644 index 2042006361e..00000000000 --- a/src/shared/mobile-web/workspace-creation-source-contract.ts +++ /dev/null @@ -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 -export type MobileWebCreationGitLabItem = z.infer -export type MobileWebCreationLinearIssue = z.infer -export type MobileWebCreationHostedBaseResult = z.infer< - typeof MobileWebCreationHostedBaseResultSchema ->