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 79548822f8d..55f685dfae5 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 @@ -23,6 +23,7 @@ const CREATE_LIMITS = { export const MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS = [ { capability: 'workspace', operation: 'creationRepositories', limits: READ_LIMITS }, + { capability: 'workspace', operation: 'creationRetiredNames', limits: READ_LIMITS }, { capability: 'workspace', operation: 'creationSettings', limits: READ_LIMITS }, { capability: 'workspace', operation: 'creationTrustedHooks', limits: READ_LIMITS }, { capability: 'workspace', operation: 'creationGitLabAvailability', limits: READ_LIMITS }, 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 index da819a6689d..0a5127fdf5e 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-creation-roundtrip.test.ts @@ -1,6 +1,7 @@ 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' @@ -90,4 +91,45 @@ describe('mobile web workspace creation round trip', () => { 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/src/mobile-web/src/mobile-web-one-shot-request-client.ts b/src/mobile-web/src/mobile-web-one-shot-request-client.ts index 4d5efe3d9fe..3f29ed74c31 100644 --- a/src/mobile-web/src/mobile-web-one-shot-request-client.ts +++ b/src/mobile-web/src/mobile-web-one-shot-request-client.ts @@ -2,6 +2,7 @@ import type { z } from 'zod' import { MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS, type MobileWebBridgeCapability, + type MobileWebBridgeOperationName, type MobileWebBridgePageMessage, type MobileWebBridgeShellMessage } from '../../shared/mobile-web/bridge-contract' @@ -23,7 +24,7 @@ export class MobileWebOneShotRequestClient { private readonly options: { getGrant: ( capability: MobileWebBridgeCapability, - operation: string + operation: MobileWebBridgeOperationName ) => OperationGrant | undefined postMessage: (message: MobileWebBridgePageMessage) => boolean envelope: () => Pick @@ -33,9 +34,9 @@ export class MobileWebOneShotRequestClient { } ) {} - request( - capability: MobileWebBridgeCapability, - operation: string, + request( + capability: TCapability, + operation: MobileWebBridgeOperationName, payload: TPayload, payloadSchema: z.ZodType, resultSchema: z.ZodType, diff --git a/src/mobile-web/src/mobile-web-task-item-request-client.ts b/src/mobile-web/src/mobile-web-task-item-request-client.ts index 4462fc99fcf..39845c14c2c 100644 --- a/src/mobile-web/src/mobile-web-task-item-request-client.ts +++ b/src/mobile-web/src/mobile-web-task-item-request-client.ts @@ -1,4 +1,5 @@ import type { ZodType } from 'zod' +import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry' import { MobileWebTaskItemChecksPayloadSchema, MobileWebTaskItemChecksResultSchema, @@ -148,7 +149,11 @@ export class MobileWebTaskItemRequestClient extends MobileWebTaskProjectRequestC ) } - private mutateReview(operation: string, payload: unknown, schema: ZodType) { + private mutateReview( + operation: MobileWebBridgeOperationName<'task'>, + payload: unknown, + schema: ZodType + ) { return this.requests.request( 'task', operation, @@ -158,7 +163,11 @@ export class MobileWebTaskItemRequestClient extends MobileWebTaskProjectRequestC ) } - private mutateFile(operation: string, payload: unknown, schema: ZodType) { + private mutateFile( + operation: MobileWebBridgeOperationName<'task'>, + payload: unknown, + schema: ZodType + ) { return this.requests.request( 'task', operation, diff --git a/src/mobile-web/src/mobile-web-task-linear-request-client.ts b/src/mobile-web/src/mobile-web-task-linear-request-client.ts index 23cca3c1d21..f5e26269232 100644 --- a/src/mobile-web/src/mobile-web-task-linear-request-client.ts +++ b/src/mobile-web/src/mobile-web-task-linear-request-client.ts @@ -1,3 +1,4 @@ +import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry' import { MobileWebTaskLinearCommentPayloadSchema, MobileWebTaskLinearCommentResultSchema, @@ -122,7 +123,11 @@ export class MobileWebTaskLinearRequestClient extends MobileWebTaskItemRequestCl ) } - private mutateLinear(operation: string, payload: unknown, schema: ZodType) { + private mutateLinear( + operation: MobileWebBridgeOperationName<'task'>, + payload: unknown, + schema: ZodType + ) { return mutate(schema, this.requests, operation, payload) } } @@ -130,7 +135,7 @@ export class MobileWebTaskLinearRequestClient extends MobileWebTaskItemRequestCl function mutate( schema: ZodType, requests: MobileWebOneShotRequestClient, - operation: string, + operation: MobileWebBridgeOperationName<'task'>, payload: unknown ) { return requests.request( diff --git a/src/mobile-web/src/mobile-web-task-project-request-client.ts b/src/mobile-web/src/mobile-web-task-project-request-client.ts index b5aa94a68b4..dafc1c2eed0 100644 --- a/src/mobile-web/src/mobile-web-task-project-request-client.ts +++ b/src/mobile-web/src/mobile-web-task-project-request-client.ts @@ -1,3 +1,4 @@ +import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry' import { MobileWebTaskProjectAssignableUsersPayloadSchema, MobileWebTaskProjectAssignableUsersResultSchema, @@ -266,7 +267,11 @@ export class MobileWebTaskProjectRequestClient { return this.mutate('mergeProjectPullRequest', payload, MobileWebTaskProjectMergePayloadSchema) } - private mutate(operation: string, payload: unknown, payloadSchema: ZodType) { + private mutate( + operation: MobileWebBridgeOperationName<'task'>, + payload: unknown, + payloadSchema: ZodType + ) { return this.requests.request( 'task', operation, 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 index 6562573dad7..53e96d7135a 100644 --- a/src/mobile-web/src/mobile-web-workspace-creation-request-client.ts +++ b/src/mobile-web/src/mobile-web-workspace-creation-request-client.ts @@ -1,3 +1,4 @@ +import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry' import { MobileWebCreationAgentDetectionPayloadSchema, MobileWebCreationAgentDetectionResultSchema, @@ -171,7 +172,7 @@ export class MobileWebWorkspaceCreationRequestClient { ) } - private availability(operation: string): Promise { + private availability(operation: MobileWebBridgeOperationName<'workspace'>): Promise { return this.emptyRequest<{ available: boolean }>( operation, MobileWebCreationAvailabilityPayloadSchema, @@ -180,7 +181,7 @@ export class MobileWebWorkspaceCreationRequestClient { } private repoRequest( - operation: string, + operation: MobileWebBridgeOperationName<'workspace'>, payload: MobileWebCreationRepoPayload, resultSchema: Parameters[4] ): Promise { @@ -194,7 +195,7 @@ export class MobileWebWorkspaceCreationRequestClient { } private emptyRequest( - operation: string, + operation: MobileWebBridgeOperationName<'workspace'>, payloadSchema: Parameters[3], resultSchema: Parameters[4] ): Promise { 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 index 67284af01cf..c9523081d22 100644 --- 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 @@ -1,4 +1,5 @@ import type { z } from 'zod' +import type { MobileWebBridgeOperationName } from '../../shared/mobile-web/bridge-operation-registry' import { MobileWebCreationBranchSearchResultSchema, MobileWebCreationGitHubLookupPayloadSchema, @@ -155,7 +156,7 @@ export class MobileWebWorkspaceCreationSourceRequestClient { } private request( - operation: string, + operation: MobileWebBridgeOperationName<'workspace'>, payload: TPayload, payloadSchema: z.ZodType, resultSchema: z.ZodType diff --git a/src/shared/mobile-web/bridge-contract.ts b/src/shared/mobile-web/bridge-contract.ts index 8b10769ea76..047d8edc4ed 100644 --- a/src/shared/mobile-web/bridge-contract.ts +++ b/src/shared/mobile-web/bridge-contract.ts @@ -23,7 +23,10 @@ export { isMobileWebBridgeOperation, MOBILE_WEB_BRIDGE_OPERATIONS } from './bridge-operation-registry' -export type { MobileWebBridgeCapability } from './bridge-operation-registry' +export type { + MobileWebBridgeCapability, + MobileWebBridgeOperationName +} from './bridge-operation-registry' export { MOBILE_WEB_BRIDGE_ENVELOPE_RESERVE_BYTES, MOBILE_WEB_BRIDGE_MAX_GRANTS, diff --git a/src/shared/mobile-web/bridge-operation-registry-census.test.ts b/src/shared/mobile-web/bridge-operation-registry-census.test.ts new file mode 100644 index 00000000000..e5feb0ab9a3 --- /dev/null +++ b/src/shared/mobile-web/bridge-operation-registry-census.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_BRIDGE_OPERATIONS } from './bridge-operation-registry' + +const REPO_ROOT = resolve(__dirname, '..', '..', '..') +const PAGE_DIR = join(REPO_ROOT, 'src', 'mobile-web', 'src') +const SHELL_DIR = join(REPO_ROOT, 'mobile', 'src', 'mobile-web') + +const registered = new Set( + Object.entries(MOBILE_WEB_BRIDGE_OPERATIONS).flatMap(([capability, operations]) => + operations.map((operation) => `${capability}.${operation}`) + ) +) + +function sources(dir: string, keep: (name: string) => boolean): { name: string; text: string }[] { + return readdirSync(dir) + .filter((name) => name.endsWith('.ts') && !name.includes('.test.') && keep(name)) + .map((name) => ({ name, text: readFileSync(join(dir, name), 'utf8') })) +} + +describe('mobile web bridge operation registry census', () => { + it('registers every capability and operation pair a page request client names', () => { + const files = sources(PAGE_DIR, () => true) + const named = new Set() + for (const { text } of files) { + for (const match of text.matchAll(/\.request\(\s*'([A-Za-z]+)',\s*'([A-Za-z]+)'/g)) { + named.add(`${match[1]}.${match[2]}`) + } + } + + expect(files.length).toBeGreaterThanOrEqual(40) + expect(named.size).toBeGreaterThanOrEqual(150) + expect([...named].filter((pair) => !registered.has(pair))).toEqual([]) + }) + + // A bare `string` here is how `workspace.creationRetiredNames` shipped unregistered: the helper + // erased the operation name before the compiler could check it against the registry. + it('types every page request client operation parameter against the registry', () => { + const files = sources(PAGE_DIR, (name) => name.endsWith('-request-client.ts')) + const untyped = files + .filter(({ text }) => /\boperation\??:\s*string\b/.test(text)) + .map(({ name }) => name) + + expect(files.map(({ name }) => name)).toContain('mobile-web-one-shot-request-client.ts') + expect(files.length).toBeGreaterThanOrEqual(20) + expect(untyped).toEqual([]) + }) + + it('names every registered operation in a shell module outside the grant tables', () => { + const files = sources(SHELL_DIR, (name) => !name.startsWith('mobile-web-production-')) + const named = new Set() + for (const { text } of files) { + for (const match of text.matchAll(/'([A-Za-z][A-Za-z0-9]*)'/g)) { + named.add(match[1]!) + } + } + const undispatched = [...registered].filter( + (pair) => !named.has(pair.slice(pair.indexOf('.') + 1)) + ) + + expect(files.length).toBeGreaterThanOrEqual(100) + expect(undispatched).toEqual([]) + }) +}) diff --git a/src/shared/mobile-web/bridge-operation-registry.ts b/src/shared/mobile-web/bridge-operation-registry.ts index fbd833ec4f3..122f6b80b83 100644 --- a/src/shared/mobile-web/bridge-operation-registry.ts +++ b/src/shared/mobile-web/bridge-operation-registry.ts @@ -9,6 +9,7 @@ export const MOBILE_WEB_BRIDGE_OPERATIONS = { 'update', 'remove', 'creationRepositories', + 'creationRetiredNames', 'creationSettings', 'creationTrustedHooks', 'creationGitLabAvailability', @@ -252,6 +253,12 @@ export const MobileWebBridgeCapabilitySchema = z.enum([ export type MobileWebBridgeCapability = z.infer +/** Operation names a page may request for one capability. Keeps request clients from naming an + * operation the shell never granted. */ +export type MobileWebBridgeOperationName< + TCapability extends MobileWebBridgeCapability = MobileWebBridgeCapability +> = (typeof MOBILE_WEB_BRIDGE_OPERATIONS)[TCapability][number] + export function isMobileWebBridgeOperation( capability: MobileWebBridgeCapability, operation: string