fix(mobile): fail closed on escaped RPC forwarders

Reject partial caller families when sender bindings escape or alias calls are not enumerated. Inventory unsubscribe frame constructions and sender calls as a separate kind, and pin the failure modes with mutation-checked regressions.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-10 14:52:26 -04:00
parent 1cfd6ab7cb
commit d08c258ff1
5 changed files with 196 additions and 30 deletions
+20 -9
View File
@@ -2,13 +2,13 @@
"schemaVersion": 2,
"scope": ["mobile/src", "mobile/app"],
"summary": {
"callSites": 431,
"literalMethod": 387,
"resolvedFamily": 26,
"unresolvedDynamic": 18,
"referenceOccurrences": 1183,
"callSites": 442,
"literalMethod": 395,
"resolvedFamily": 23,
"unresolvedDynamic": 24,
"referenceOccurrences": 1184,
"referenceFileCount": 167,
"note": "calls are `file:line:column kind method`, where method is one literal, a `|`-joined family the checker resolved, or `dynamic:<callee>` when it resolved nothing. References are bare occurrences of the `sendRequest`/`subscribe` token with no call attached; only their count and files are recorded."
"note": "calls are `file:line:column kind method`, where method is one literal, a `|`-joined family the checker resolved, or `dynamic:<callee>` when completeness cannot be proven. Kinds: request = sendRequest calls; subscribe = subscribe calls (including local listeners); unsubscribe = sendUnsubscribe calls and literal *.unsubscribe method fields in frame constructions, including test fixtures, without a reachability claim. References are bare occurrences of the `sendRequest`/`subscribe`/`sendUnsubscribe` token with no call attached; only their count and files are recorded."
},
"calls": [
"mobile/app/connection-log.tsx:89:20 subscribe dynamic:connectionLogStore.subscribe",
@@ -31,7 +31,7 @@
"mobile/src/browser/use-mobile-browser-commands.ts:137:17 request browser.mouseMove",
"mobile/src/browser/use-mobile-browser-commands.ts:141:17 request browser.mouseDown",
"mobile/src/browser/use-mobile-browser-commands.ts:145:17 request browser.mouseUp",
"mobile/src/browser/use-mobile-browser-request.ts:41:32 request browser.back|browser.forward|browser.goto|browser.reload",
"mobile/src/browser/use-mobile-browser-request.ts:41:32 request dynamic:client.sendRequest",
"mobile/src/browser/use-mobile-browser-stream.ts:229:25 subscribe browser.screencast",
"mobile/src/components/codex-reset-credit-capability.ts:14:28 request status.get",
"mobile/src/components/codex-reset-credit.ts:228:26 request accounts.consumeCodexResetCredit",
@@ -118,7 +118,7 @@
"mobile/src/session/mobile-structured-agent-session-launch.ts:85:31 request agentSession.createSupport",
"mobile/src/session/mobile-structured-agent-session-launch.ts:123:22 request agentSession.create",
"mobile/src/session/mobile-structured-agent-session-launch.ts:130:24 request agentSession.create",
"mobile/src/session/mobile-structured-agent-session-rpc.ts:41:26 request agentSession.cancel|agentSession.conversationCommand|agentSession.history|agentSession.hold|agentSession.options|agentSession.release|agentSession.respondToApproval|agentSession.respondToQuestion|agentSession.send|agentSession.setOption",
"mobile/src/session/mobile-structured-agent-session-rpc.ts:41:26 request dynamic:client.sendRequest",
"mobile/src/session/mobile-terminal-stream-subscribe.ts:10:12 subscribe terminal.subscribe",
"mobile/src/session/mobile-worker-takeover-send-sites.test.ts:178:25 request terminal.send",
"mobile/src/session/pr-ai-triage-launch.ts:18:25 request session.tabs.createTerminal",
@@ -192,7 +192,7 @@
"mobile/src/source-control/mobile-pr-link.ts:121:28 request worktree.show",
"mobile/src/source-control/MobileGitHistoryList.tsx:108:10 request git.commitCompare",
"mobile/src/source-control/reveal-mobile-source-control-session-diff.ts:64:28 request session.tabs.list",
"mobile/src/source-control/use-mobile-git-requests.ts:25:30 request git.commit|git.fetch|git.pull|git.push|git.status|git.upstreamStatus",
"mobile/src/source-control/use-mobile-git-requests.ts:25:30 request dynamic:client.sendRequest",
"mobile/src/source-control/use-mobile-source-control-loaders.ts:120:32 request git.branchCompare",
"mobile/src/source-control/use-mobile-source-control-loaders.ts:198:36 request git.status",
"mobile/src/source-control/use-mobile-source-control-openers.ts:114:30 request files.openDiff",
@@ -346,10 +346,12 @@
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:145:20 subscribe dynamic:streams.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:165:22 subscribe session.tabs.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:170:7 subscribe session.tabs.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:182:9 unsubscribe session.tabs.unsubscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:202:27 subscribe dynamic:streams.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:203:27 subscribe dynamic:streams.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:229:25 subscribe nativeChat.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:232:5 subscribe nativeChat.subscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:237:7 unsubscribe nativeChat.unsubscribe",
"mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts:246:22 subscribe runtime.clientEvents.subscribe",
"mobile/src/transport/mobile-relay-rpc-streams.test.ts:23:20 subscribe session.tabs.subscribe",
"mobile/src/transport/mobile-relay-rpc-streams.test.ts:50:20 subscribe session.tabs.subscribe",
@@ -357,6 +359,10 @@
"mobile/src/transport/mobile-relay-rpc-streams.test.ts:103:20 subscribe session.tabs.subscribe",
"mobile/src/transport/mobile-relay-rpc-streams.test.ts:126:5 subscribe session.tabs.subscribe",
"mobile/src/transport/mobile-relay-rpc-streams.test.ts:145:5 subscribe session.tabs.subscribe",
"mobile/src/transport/mobile-relay-rpc-streams.ts:41:34 unsubscribe terminal.unsubscribe",
"mobile/src/transport/mobile-relay-rpc-streams.ts:196:11 unsubscribe dynamic:this.sendUnsubscribe",
"mobile/src/transport/mobile-relay-rpc-streams.ts:206:11 unsubscribe dynamic:this.sendUnsubscribe",
"mobile/src/transport/mobile-relay-rpc-streams.ts:214:11 unsubscribe dynamic:this.sendUnsubscribe",
"mobile/src/transport/mobile-runtime-capability-negotiation.ts:21:22 request runtime.clientCapabilities.update",
"mobile/src/transport/pairing-candidate-race.ts:19:12 request status.get",
"mobile/src/transport/pairing-relay-candidate.test.ts:88:18 request status.get",
@@ -397,10 +403,15 @@
"mobile/src/transport/rpc-client-request-deadline.test.ts:161:21 request terminal.send",
"mobile/src/transport/rpc-client-runtime-events.test.ts:112:25 subscribe runtime.clientEvents.subscribe",
"mobile/src/transport/rpc-client-runtime-events.test.ts:127:25 subscribe runtime.clientEvents.subscribe",
"mobile/src/transport/rpc-client-server-subscription.ts:6:14 unsubscribe browser.screencast.unsubscribe",
"mobile/src/transport/rpc-client-server-subscription.ts:9:14 unsubscribe runtime.clientEvents.unsubscribe",
"mobile/src/transport/rpc-client-stream-registry.test.ts:57:5 subscribe terminal.subscribe",
"mobile/src/transport/rpc-client-stream-registry.test.ts:84:21 subscribe browser.screencast",
"mobile/src/transport/rpc-client-stream-registry.test.ts:97:7 unsubscribe browser.screencast.unsubscribe",
"mobile/src/transport/rpc-client-terminal-reconnect.test.ts:133:5 subscribe terminal.subscribe",
"mobile/src/transport/rpc-client-terminal-reconnect.test.ts:139:21 request status.get",
"mobile/src/transport/rpc-client-terminal-subscription.ts:51:11 unsubscribe session.tabs.unsubscribe",
"mobile/src/transport/rpc-client-terminal-subscription.ts:59:16 unsubscribe nativeChat.unsubscribe",
"mobile/src/transport/rpc-client.test.ts:194:25 subscribe session.tabs.subscribe",
"mobile/src/transport/rpc-client.test.ts:212:9 subscribe notifications.subscribe",
"mobile/src/transport/rpc-client.test.ts:235:25 subscribe browser.screencast",
+19 -5
View File
@@ -1,10 +1,13 @@
import { join } from 'node:path'
import ts from 'typescript'
import { createRpcAccessResolver } from './rpc-access-resolution.mts'
import {
createRpcAccessResolver,
unsubscribeFrameMethod,
type RpcAccessKind
} from './rpc-access-resolution.mts'
import { emit, files, root } from './rpc-artifact-io.mts'
type AccessKind = 'request' | 'subscribe'
type CallSite = { file: string; line: number; column: number; kind: AccessKind; method: string }
type CallSite = { file: string; line: number; column: number; kind: RpcAccessKind; method: string }
const paths = [...files(join(root, 'mobile/src')), ...files(join(root, 'mobile/app'))].filter(
(file) => /\.[jt]sx?$/.test(file)
)
@@ -24,7 +27,7 @@ const { entryKind, resolveKind, methods } = createRpcAccessResolver(
for (const file of paths) {
const sf = program.getSourceFile(join(root, file))!
const positions = new Set<number>()
function add(node: ts.Node, kind: AccessKind, call?: ts.CallExpression): void {
function add(node: ts.Node, kind: RpcAccessKind, call?: ts.CallExpression): void {
const start = node.getStart(sf)
if (positions.has(start)) {
return
@@ -50,6 +53,17 @@ for (const file of paths) {
})
}
function visit(node: ts.Node): void {
const unsubscribe = unsubscribeFrameMethod(node)
if (unsubscribe) {
const location = sf.getLineAndCharacterOfPosition(node.getStart(sf))
calls.push({
file,
line: location.line + 1,
column: location.character + 1,
kind: 'unsubscribe',
method: unsubscribe
})
}
if (ts.isCallExpression(node)) {
const kind = resolveKind(node.expression)
if (kind) {
@@ -88,7 +102,7 @@ await emit('access-inventory', {
unresolvedDynamic: calls.filter((call) => call.method.startsWith('dynamic:')).length,
referenceOccurrences: referenceCount,
referenceFileCount: referenceFiles.size,
note: 'calls are `file:line:column kind method`, where method is one literal, a `|`-joined family the checker resolved, or `dynamic:<callee>` when it resolved nothing. References are bare occurrences of the `sendRequest`/`subscribe` token with no call attached; only their count and files are recorded.'
note: 'calls are `file:line:column kind method`, where method is one literal, a `|`-joined family the checker resolved, or `dynamic:<callee>` when completeness cannot be proven. Kinds: request = sendRequest calls; subscribe = subscribe calls (including local listeners); unsubscribe = sendUnsubscribe calls and literal *.unsubscribe method fields in frame constructions, including test fixtures, without a reachability claim. References are bare occurrences of the `sendRequest`/`subscribe`/`sendUnsubscribe` token with no call attached; only their count and files are recorded.'
},
calls: rows,
referenceFiles: [...referenceFiles].sort()
+72 -10
View File
@@ -1,9 +1,25 @@
import ts from 'typescript'
export type RpcAccessKind = 'request' | 'subscribe'
export type RpcAccessKind = 'request' | 'subscribe' | 'unsubscribe'
export function unsubscribeFrameMethod(node: ts.Node): string | undefined {
if (
ts.isPropertyAssignment(node) &&
node.name.getText().replace(/['"]/g, '') === 'method' &&
ts.isStringLiteralLike(node.initializer) &&
node.initializer.text.endsWith('.unsubscribe')
) {
return node.initializer.text
}
}
export function createRpcAccessResolver(program: ts.Program, paths: string[]) {
const checker = program.getTypeChecker()
function entryKind(name: string | undefined): RpcAccessKind | undefined {
return name === 'sendRequest' ? 'request' : name === 'subscribe' ? 'subscribe' : undefined
return name === 'sendRequest'
? 'request'
: name === 'subscribe'
? 'subscribe'
: name === 'sendUnsubscribe'
? 'unsubscribe'
: undefined
}
function resolveKind(node: ts.Node, seen = new Set<ts.Node>()): RpcAccessKind | undefined {
if (seen.has(node)) {
@@ -55,6 +71,7 @@ export function createRpcAccessResolver(program: ts.Program, paths: string[]) {
}
}
const calls: ts.CallExpression[] = []
const references = new Map<ts.Symbol, ts.Identifier[]>()
/** Symbol of the binding a function value is stored in, mapped to every function-type alias it
* is contextually assigned to. A call through such an alias resolves to the alias signature,
* not to the function, so without this the caller set is silently partial. */
@@ -73,6 +90,19 @@ export function createRpcAccessResolver(program: ts.Program, paths: string[]) {
if (ts.isCallExpression(node)) {
calls.push(node)
}
if (ts.isIdentifier(node)) {
let symbol = ts.isShorthandPropertyAssignment(node.parent)
? checker.getShorthandAssignmentValueSymbol(node.parent)
: checker.getSymbolAtLocation(node)
if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol)
}
if (symbol) {
const uses = references.get(symbol) ?? []
uses.push(node)
references.set(symbol, uses)
}
}
if (ts.isShorthandPropertyAssignment(node)) {
// The symbol at a shorthand name is the object's property, not the value it carries.
recordAliasCarrier(
@@ -105,18 +135,50 @@ export function createRpcAccessResolver(program: ts.Program, paths: string[]) {
? checker.getSymbolAtLocation(owner.name)
: undefined
}
function callers(owner: ts.SignatureDeclaration): ts.CallExpression[] {
const direct = calls.filter((call) => checker.getResolvedSignature(call)?.declaration === owner)
const binding = ownerBinding(owner)
const aliases = binding && aliasCarriers.get(binding)
if (!aliases?.size) {
return direct
function escapes(
binding: ts.Symbol,
enumerated: Set<ts.CallExpression>,
seen = new Set<ts.Symbol>()
): boolean {
if (seen.has(binding)) {
return false
}
seen.add(binding)
return (references.get(binding) ?? []).some((use) => {
const parent = use.parent
if (
(ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent)) &&
parent.name === use
) {
return false
}
if (ts.isImportSpecifier(parent)) {
return false
}
if (ts.isCallExpression(parent) && parent.expression === use) {
return !enumerated.has(parent)
}
if (ts.isVariableDeclaration(parent) && parent.initializer === use) {
const alias = checker.getSymbolAtLocation(parent.name)
return !alias || escapes(alias, enumerated, seen)
}
// Only direct calls and fully enumerated local aliases prove a complete caller set.
return true
})
}
function callers(owner: ts.SignatureDeclaration): ts.CallExpression[] {
const binding = ownerBinding(owner)
if (!binding) {
return []
}
const direct = calls.filter((call) => checker.getResolvedSignature(call)?.declaration === owner)
const aliases = binding && aliasCarriers.get(binding)
const aliased = calls.filter((call) => {
const declaration = checker.getResolvedSignature(call)?.declaration
return Boolean(declaration?.parent && aliases.has(declaration.parent))
return Boolean(declaration?.parent && aliases?.has(declaration.parent))
})
return [...direct, ...aliased.filter((call) => !direct.includes(call))]
const enumerated = new Set([...direct, ...aliased])
return escapes(binding, enumerated) ? [] : [...enumerated]
}
function methods(node: ts.Expression | undefined, seen = new Set<ts.Node>()): string[] {
if (!node) {
+3 -3
View File
@@ -39,9 +39,9 @@ export async function emit(name: string, value: unknown): Promise<void> {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
// Committed artifacts are checked by `format:check`; format here so regeneration cannot leave CI red.
const formatted = await runProcess({
program: resolve(root, 'node_modules/.bin/oxfmt'),
args: ['--write', path],
cwd: root
program: 'pnpm',
args: ['exec', 'oxfmt', '--write', path],
cwd: resolve(root, 'mobile')
})
if (formatted.code !== 0) {
throw new Error(`oxfmt --write ${path}: ${formatted.stderr}`)
@@ -1,6 +1,9 @@
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
import { createRpcAccessResolver } from '../../scripts/rpc-access-resolution.mts'
import {
createRpcAccessResolver,
unsubscribeFrameMethod
} from '../../scripts/rpc-access-resolution.mts'
function fixture(source: string) {
const file = 'rpc-fixture.ts'
@@ -64,7 +67,7 @@ describe('RPC access symbol resolution', () => {
const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')!
expect(resolver.methods(raw.arguments[0])).toEqual(['hidden.viaTypeAlias', 'resolved.direct'])
})
it('carries the alias through a shorthand property handed to another function', () => {
it('fails closed for a property handed to another function', () => {
const resolver = fixture(`
declare const client: { sendRequest(method: string): void }
type RunHook = (method: string) => void
@@ -76,7 +79,7 @@ describe('RPC access symbol resolution', () => {
args.run('hidden.viaShorthand')
`)
const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')!
expect(resolver.methods(raw.arguments[0])).toEqual(['hidden.viaShorthand', 'resolved.direct'])
expect(resolver.methods(raw.arguments[0])).toEqual([])
})
it('does not hide an unresolved caller behind an object binding', () => {
const resolver = fixture(`
@@ -88,4 +91,80 @@ describe('RPC access symbol resolution', () => {
const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')!
expect(resolver.methods(raw.arguments[0])).toEqual([])
})
it.each([
'return { send }',
'consume(send); return { send }',
'target.send = send; return target',
'const alias = send; return { send: alias }'
])('fails closed across a destructured hook result: %s', (escape) => {
const resolver = fixture(`
declare const client: { sendRequest(method: string): void }
declare function consume(send: (method: string) => void): void
declare const target: { send: (method: string) => void }
function useFoo() {
const send = (method: string) => { client.sendRequest(method) }
send('git.status')
${escape}
}
const { send: x } = useFoo()
x('git.checkout')
`)
const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')!
expect(resolver.methods(raw.arguments[0])).toEqual([])
})
it('fails closed when an alias call is absent from the enumerated signatures', () => {
const resolver = fixture(`
declare const client: { sendRequest(method: string): void }
type RunHook = (method: string) => void
const send = (method: string) => { client.sendRequest(method) }
send('git.status')
const alias = send
const run: RunHook = alias
run('git.checkout')
`)
const raw = resolver.calls.find((call) => resolver.resolveKind(call.expression) === 'request')!
expect(resolver.methods(raw.arguments[0])).toEqual([])
})
it('records unsubscribe frame methods separately from requests and subscriptions', () => {
const source = ts.createSourceFile(
'frames.ts',
`
const frames = [
{ method: 'terminal.unsubscribe' },
{ method: 'session.tabs.unsubscribe' },
{ method: 'nativeChat.unsubscribe' },
{ method: 'browser.screencast.unsubscribe' },
{ method: 'runtime.clientEvents.unsubscribe' },
{ method: 'terminal.subscribe' },
{ label: 'ignored.unsubscribe' }
]
const dead = new Set(['github.prComments', 'ignored.unsubscribe'])
`,
ts.ScriptTarget.Latest,
true
)
const methods: string[] = []
function visit(node: ts.Node): void {
const method = unsubscribeFrameMethod(node)
if (method) {
methods.push(method)
}
ts.forEachChild(node, visit)
}
visit(source)
expect(methods).toEqual([
'terminal.unsubscribe',
'session.tabs.unsubscribe',
'nativeChat.unsubscribe',
'browser.screencast.unsubscribe',
'runtime.clientEvents.unsubscribe'
])
const resolver = fixture(`
declare const streams: { sendUnsubscribe(frame: unknown): void }
streams.sendUnsubscribe({ method: unknownMethod })
`)
expect(resolver.calls.map((call) => resolver.resolveKind(call.expression))).toEqual([
'unsubscribe'
])
})
})