refactor(mobile): brand the host-side workspace and repo identifiers

The authority now mints branded MobileWebHostWorkspaceId and MobileWebHostRepoId,
so passing a page handle where a resolved host identifier belongs is a compile
error. Reauthorization is the case that matters: assertHostWorkspaceBinding no
longer accepts the page handle as its expected host value.

Values the host itself reported go through two named boundary functions rather
than a bare cast, so every such site is greppable. No runtime check changed.

The page-side handles stay unbranded: branding MobileWebWorkspaceIdSchema alone
produced 527 type errors across the page and shell, and six more handle types
remain.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-01 19:18:11 -04:00
parent d8127aa5b1
commit ff8a13adaf
8 changed files with 61 additions and 22 deletions
@@ -20,7 +20,10 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure } from '../transport/types'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import type {
MobileWebHostWorkspaceId,
MobileWebWorkspaceAuthority
} from './mobile-web-workspace-authority'
type MarkdownOperationArgs = {
operation: string
@@ -102,7 +105,7 @@ export async function executeMobileWebMarkdownOperation(
async function verifyMarkdownTarget(
args: MarkdownOperationArgs,
target: MarkdownTarget
): Promise<string> {
): Promise<MobileWebHostWorkspaceId> {
const hostWorkspaceId = args.workspaceAuthority.hostWorkspaceId(target.workspaceId)
const response = await args.client.sendRequest('session.tabs.list', {
worktree: `id:${hostWorkspaceId}`
@@ -1,8 +1,9 @@
import type { MobileWebHostWorkspaceId } from './mobile-web-workspace-authority'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { MOBILE_WEB_NATIVE_CHAT_IMAGE_LIMIT } from '../../../src/shared/mobile-web/native-chat-operation-contract'
export type MobileWebHostNativeChatBinding = {
hostWorkspaceId: string
hostWorkspaceId: MobileWebHostWorkspaceId
hostTabId: string
hostTerminalId: string | null
agent: string
@@ -21,7 +21,10 @@ import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-h
import { loadMobileNewTabAgentOptions } from '../session/mobile-new-tab-agent-loader'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import type {
MobileWebHostWorkspaceId,
MobileWebWorkspaceAuthority
} from './mobile-web-workspace-authority'
export async function executeMobileWebSessionQuickCommandOperation(args: {
operation: string
@@ -99,7 +102,7 @@ async function readQuickCommands(client: RpcClient): Promise<TerminalQuickComman
function projectQuickCommands(
result: unknown,
hostWorkspaceId: string,
hostWorkspaceId: MobileWebHostWorkspaceId,
pageWorkspaceId: string
): MobileWebQuickCommandSnapshotResult {
const commands = parseNormalizedTerminalQuickCommands(
@@ -127,7 +130,7 @@ function projectQuickCommands(
function hostMutation(
mutation: MobileWebQuickCommandMutationPayload['mutation'],
commands: TerminalQuickCommand[],
hostWorkspaceId: string,
hostWorkspaceId: MobileWebHostWorkspaceId,
pageWorkspaceId: string
): TerminalQuickCommandMutation {
const repoId = workspaceRepoId(hostWorkspaceId)
@@ -161,7 +164,7 @@ function hostMutation(
async function launchQuickCommand(args: {
client: RpcClient
command: TerminalQuickCommand
hostWorkspaceId: string
hostWorkspaceId: MobileWebHostWorkspaceId
pageWorkspaceId: string
requestId: string
workspaceAuthority: MobileWebWorkspaceAuthority
@@ -1,3 +1,4 @@
import type { MobileWebHostWorkspaceId } from './mobile-web-workspace-authority'
import {
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
@@ -23,7 +24,7 @@ const TAB_TYPES = ['terminal', 'markdown', 'file', 'browser'] as const
export function mobileWebSessionSnapshot(
result: unknown,
hostWorkspaceId: string,
hostWorkspaceId: MobileWebHostWorkspaceId,
pageWorkspaceId: string,
browserAuthority: MobileWebBrowserAuthority,
nativeChatAuthority: MobileWebNativeChatAuthority
@@ -101,7 +102,7 @@ function isActiveSessionTab(value: unknown): boolean {
function mobileWebSessionTab(
value: unknown,
hostWorkspaceId: string,
hostWorkspaceId: MobileWebHostWorkspaceId,
browserAuthority: MobileWebBrowserAuthority,
nativeChatAuthority: MobileWebNativeChatAuthority
): MobileWebSessionTab | null {
@@ -236,7 +237,7 @@ function safeNonnegativeInteger(value: unknown): number | undefined {
function mobileWebNativeChatBinding(
value: unknown,
hostWorkspaceId: string
hostWorkspaceId: MobileWebHostWorkspaceId
): MobileWebHostNativeChatBinding | null {
if (
!isRecord(value) ||
@@ -1,3 +1,4 @@
import type { MobileWebHostWorkspaceId } from './mobile-web-workspace-authority'
import { MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS } from '../../../src/shared/mobile-web/bridge-contract'
import {
MOBILE_WEB_SESSION_EVENT_MAX_BYTES,
@@ -37,7 +38,7 @@ export class MobileWebSessionSubscriptions {
requestId: string
subscriptionId: string
pageWorkspaceId: string
hostWorkspaceId: string
hostWorkspaceId: MobileWebHostWorkspaceId
client: RpcClient
}): void {
if (this.records.has(args.subscriptionId)) {
@@ -121,7 +122,7 @@ export class MobileWebSessionSubscriptions {
private receive(
subscriptionId: string,
record: SubscriptionRecord,
hostWorkspaceId: string,
hostWorkspaceId: MobileWebHostWorkspaceId,
pageWorkspaceId: string,
event: unknown
): void {
@@ -1,4 +1,7 @@
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import {
mobileWebHostWorkspaceIdFromHost,
MobileWebWorkspaceAuthority
} from './mobile-web-workspace-authority'
export function createMobileWebWorkspaceAuthorityFixture(
pageWorkspaceId = 'workspace-1',
@@ -12,7 +15,7 @@ export function createMobileWebWorkspaceAuthorityFixture(
if (candidate !== pageWorkspaceId) {
throw new Error('not_found')
}
return hostWorkspaceId
return mobileWebHostWorkspaceIdFromHost(hostWorkspaceId)
}
}
return authority
@@ -7,6 +7,24 @@ import {
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
import type { NewWorkspaceRepository } from '../worktree/host-workspace-creation-operations'
declare const hostWorkspaceIdBrand: unique symbol
declare const hostRepoIdBrand: unique symbol
/** Host-side identifiers the authority mints from an opaque page handle. Branding keeps a page
* handle from being passed back in as if it were already resolved. */
export type MobileWebHostWorkspaceId = string & { readonly [hostWorkspaceIdBrand]: true }
export type MobileWebHostRepoId = string & { readonly [hostRepoIdBrand]: true }
/** A value the host itself reported as a host identifier, so it never came from the page. Every
* use is a boundary annotation, not a conversion of a page handle. */
export function mobileWebHostWorkspaceIdFromHost(value: string): MobileWebHostWorkspaceId {
return value as MobileWebHostWorkspaceId
}
export function mobileWebHostRepoIdFromHost(value: string): MobileWebHostRepoId {
return value as MobileWebHostRepoId
}
export type MobileWebHostWorkspaceBinding = {
workspaceId: string
repoId: string
@@ -63,15 +81,15 @@ export class MobileWebWorkspaceAuthority {
return pageRepoId
}
hostRepoId(pageRepoId: string): string {
hostRepoId(pageRepoId: string): MobileWebHostRepoId {
const hostRepoId = this.hostRepoIdByPageId.get(pageRepoId)
if (!hostRepoId) {
throw new MobileWebBrokerError('not_found')
}
return hostRepoId
return hostRepoId as MobileWebHostRepoId
}
assertHostRepoBinding(pageRepoId: string, expectedHostRepoId: string): void {
assertHostRepoBinding(pageRepoId: string, expectedHostRepoId: MobileWebHostRepoId): void {
if (this.hostRepoId(pageRepoId) !== expectedHostRepoId) {
throw new MobileWebBrokerError('conflict')
}
@@ -119,15 +137,18 @@ export class MobileWebWorkspaceAuthority {
return connectionId
}
hostWorkspaceId(pageWorkspaceId: string): string {
hostWorkspaceId(pageWorkspaceId: string): MobileWebHostWorkspaceId {
const hostWorkspaceId = this.hostWorkspaceIdByPageId.get(pageWorkspaceId)
if (!hostWorkspaceId) {
throw new MobileWebBrokerError('not_found')
}
return hostWorkspaceId
return hostWorkspaceId as MobileWebHostWorkspaceId
}
assertHostWorkspaceBinding(pageWorkspaceId: string, expectedHostWorkspaceId: string): void {
assertHostWorkspaceBinding(
pageWorkspaceId: string,
expectedHostWorkspaceId: MobileWebHostWorkspaceId
): void {
if (this.hostWorkspaceId(pageWorkspaceId) !== expectedHostWorkspaceId) {
throw new MobileWebBrokerError('conflict')
}
@@ -13,7 +13,10 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import {
mobileWebHostRepoIdFromHost,
type MobileWebWorkspaceAuthority
} from './mobile-web-workspace-authority'
export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
operation: string
@@ -73,7 +76,10 @@ function assertSelectionRepoBinding(
) {
throw new MobileWebBrokerError('conflict')
}
authority.assertHostRepoBinding(pageSelection.item.repoId, hostSelection.item.repoId)
authority.assertHostRepoBinding(
pageSelection.item.repoId,
mobileWebHostRepoIdFromHost(hostSelection.item.repoId)
)
}
}