diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index afe79ab8e2f..5162ca6e291 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -5,7 +5,7 @@ import { RuntimeClientError } from '../../runtime-client' import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { injectedSessionAddress, resolveCoordinatorTerminalHandle } from './terminal-identity' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { @@ -42,9 +42,12 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record = { export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { const showPreamble = flags.has('preamble') ? true : undefined - // Why: a preview must embed the same real coordinator handle as an actual dispatch. + // Why: a preview must embed the same real coordinator handle as an actual dispatch. Its --from + // only fills preview text and names no caller, so it passes through unfenced. const from = showPreamble - ? await resolveCoordinatorTerminalHandle(flags, cwd, client) + ? (getOptionalStringFlag(flags, 'from') ?? + injectedSessionAddress() ?? + (await resolveCoordinatorTerminalHandle(flags, cwd, client))) : undefined const result = await client.call<{ dispatch: { id: string; task_id: string; status: string } | null diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index f9d72d76e3a..732fb13a77f 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -173,7 +173,7 @@ function getClientErrorMessage(err: unknown): string | undefined { * identity this process carries; a caller flag may restate that same session but never name * another, and a conflicting one is refused here, before any request is sent. */ -export function resolveInjectedSessionCaller( +function resolveInjectedSessionCaller( flags: Map, flagName: 'from' | 'terminal' ): string | undefined { @@ -195,17 +195,25 @@ export function resolveInjectedSessionCaller( /** The session's own spellings, plus the handle a structured worker session was minted. */ function namesInjectedSession(value: string, sessionId: string): boolean { - if (normalizeOrchestrationActor(value)?.id === sessionId) { - return true + return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() +} + +/** + * The address the host gives this session: a structured worker keeps the handle it was minted, any + * other session is `session:`. Only for text that must match what the host writes. + */ +export function injectedSessionAddress(): string | undefined { + const sessionId = readInjectedAgentSessionId() + if (!sessionId) { + return undefined } const ownHandle = process.env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) && value === ownHandle + return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` } /** How check output names its caller: the handle, or the session's address. */ export function orchestrationCallerLabel(handle: string | undefined): string { - const sessionId = handle === undefined ? readInjectedAgentSessionId() : undefined - return handle ?? (sessionId ? `session:${sessionId}` : 'unknown') + return handle ?? injectedSessionAddress() ?? 'unknown' } export async function resolveCoordinatorTerminalHandle( diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.ts b/src/cli/handlers/orchestration/worker-list-run-scope.ts index 83bfcd5b9e0..13037616153 100644 --- a/src/cli/handlers/orchestration/worker-list-run-scope.ts +++ b/src/cli/handlers/orchestration/worker-list-run-scope.ts @@ -1,9 +1,6 @@ import { getOptionalStringFlag } from '../../flags' import type { RuntimeClient } from '../../runtime-client' -import { - resolveInjectedSessionCaller, - resolveOrchestrationTerminalHandle -} from './terminal-identity' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' /** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */ export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' } @@ -23,9 +20,6 @@ export async function resolveWorkerListRunScope( if (explicit) { return { run: explicit, source: 'flag' } } - // Outside the catch: a --terminal naming someone other than this session is refused, never - // widened into an unscoped listing. - resolveInjectedSessionCaller(flags, 'terminal') try { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', { diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts new file mode 100644 index 00000000000..93a76645a30 --- /dev/null +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -0,0 +1,476 @@ +/** + * A command that runs inside a structured agent session is that session: the injected + * `ORCA_AGENT_SESSION_ID` names the caller, and nothing resolves or guesses a terminal for it. + * + * One rule for every verb that names a caller: a caller flag may restate the session, but a flag + * naming anyone else is refused before any request — never silently dropped, never allowed to win. + * The #21097 accident was a chat that named a sibling's terminal and consumed that sibling's mail. + * + * The session env here is the hardest case, a chat in terminal view: it also carries its pane's + * `ORCA_TERMINAL_HANDLE` and `ORCA_PANE_KEY`, and the implicit-terminal guess has a sibling to find. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.hoisted(() => vi.fn()) +const getTerminalHandleMock = vi.hoisted(() => vi.fn()) + +vi.mock('./format', () => ({ printResult: vi.fn() })) +vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) + +import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' + +const SESSION = 'f7a1c0de-1111-4222-8333-444455556666' +const IDENTITY_ENV = [ + 'ORCA_AGENT_SESSION_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_PANE_KEY', + 'ORCA_STRUCTURED_SESSION' +] as const +const originalEnv = Object.fromEntries(IDENTITY_ENV.map((name) => [name, process.env[name]])) + +/** Enough of every receipt shape that each handler finishes after its RPC. */ +const RESULT = { + result: { + run: { id: 'run_1', objective: 'o', consumer_generation: 1 }, + runs: [], + nextCursor: null, + messages: [], + count: 0, + message: { id: 'msg_1' }, + lifecycle: { action: 'completed' }, + dispatch: { id: 'dispatch_1', task_id: 'task_1', status: 'dispatched' }, + gate: { id: 'gate_1', task_id: 'task_1', status: 'pending', resolution: 'r' }, + gates: [], + task: { id: 'task_1', status: 'pending' }, + tasks: [], + answer: 'yes', + messageId: 'msg_1', + threadId: 'thread_1', + timedOut: false, + state: 'ready', + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'dispatch_1', + effects: [], + residualResources: [], + workers: [], + counts: {} + } +} + +type Verb = { + command: string + flags: Record + /** The flag that names the caller, when the verb has one. */ + callerFlag?: 'from' | 'terminal' + method: string + callerParam: 'from' | 'terminal' | 'callerTerminalHandle' +} + +/** Every verb whose request names its caller: the host's caller-param map, from the CLI side. + * `dispatch-show` is not one: its --from only fills preview text, so it is pinned on its own. */ +const CALLER_VERBS: Verb[] = [ + { + command: 'run-create', + flags: { objective: 'o' }, + callerFlag: 'from', + method: 'runCreate', + callerParam: 'from' + }, + { + command: 'run-use', + flags: { id: 'run_1' }, + callerFlag: 'from', + method: 'runUse', + callerParam: 'from' + }, + { + command: 'run-current', + flags: {}, + callerFlag: 'from', + method: 'runCurrent', + callerParam: 'from' + }, + { command: 'check', flags: {}, callerFlag: 'terminal', method: 'check', callerParam: 'terminal' }, + { + command: 'send', + flags: { to: 'term_worker', subject: 's', body: 'b' }, + callerFlag: 'from', + method: 'send', + callerParam: 'from' + }, + { + command: 'reply', + flags: { id: 'msg_1', body: 'b' }, + callerFlag: 'from', + method: 'reply', + callerParam: 'from' + }, + { + command: 'ask', + flags: { to: 'term_worker', question: 'q' }, + callerFlag: 'from', + method: 'ask', + callerParam: 'from' + }, + { + command: 'dispatch', + flags: { task: 'task_1', to: 'term_worker' }, + callerFlag: 'from', + method: 'dispatch', + callerParam: 'from' + }, + { + command: 'gate-create', + flags: { task: 'task_1', question: 'q' }, + callerFlag: 'from', + method: 'gateCreate', + callerParam: 'from' + }, + { + command: 'gate-resolve', + flags: { id: 'gate_1', resolution: 'r' }, + callerFlag: 'from', + method: 'gateResolve', + callerParam: 'from' + }, + { command: 'gate-list', flags: {}, callerFlag: 'from', method: 'gateList', callerParam: 'from' }, + { + command: 'task-create', + flags: { spec: 's' }, + callerFlag: 'from', + method: 'taskCreate', + callerParam: 'callerTerminalHandle' + }, + { + command: 'task-list', + flags: {}, + callerFlag: 'from', + method: 'taskList', + callerParam: 'callerTerminalHandle' + }, + { + command: 'task-update', + flags: { id: 'task_1', status: 'completed' }, + callerFlag: 'from', + method: 'taskUpdate', + callerParam: 'callerTerminalHandle' + }, + { + command: 'worker-start', + flags: { spec: 's' }, + callerFlag: 'from', + method: 'workerStart', + callerParam: 'from' + }, + // No caller flag: its spec takes none. It asks runCurrent for the caller's Run. + { command: 'worker-list', flags: {}, method: 'runCurrent', callerParam: 'from' } +] + +function flagMap(flags: Record): Map { + return new Map(Object.entries(flags)) +} + +async function invoke( + command: string, + flags: Map, + json = true +): Promise { + const handler = ORCHESTRATION_HANDLERS[`orchestration ${command}`] + if (!handler) { + throw new Error(`no handler for ${command}`) + } + await handler({ + flags, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only `call`; RuntimeClient is a class, so a structural double cannot satisfy it without the cast. + client: { call: callMock } as never, + cwd: '/tmp/repo', + json + }) +} + +function isParams(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function callsTo(method: string): Record[] { + return callMock.mock.calls + .filter(([name]) => name === `orchestration.${method}`) + .map(([, params]) => (isParams(params) ? params : {})) +} + +function setEnv(env: Partial>): void { + for (const name of IDENTITY_ENV) { + const value = env[name] + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } +} + +/** A chat in terminal view: its id, plus the pane identity that view inherits. */ +function asSessionInTerminalView(): void { + setEnv({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_TERMINAL_HANDLE: 'term_view_pane', + ORCA_PANE_KEY: 'tab_view:11111111-1111-4111-8111-111111111111' + }) +} + +beforeEach(() => { + callMock.mockReset().mockResolvedValue(RESULT) + getTerminalHandleMock.mockReset().mockResolvedValue('term_sibling') + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + setEnv(originalEnv) + process.exitCode = undefined +}) + +describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (verb) => { + beforeEach(asSessionInTerminalView) + + it('acts as the session: no terminal is resolved, guessed or sent', async () => { + await invoke(verb.command, flagMap(verb.flags)) + + const [params] = callsTo(verb.method) + expect(params, 'the verb reached its method').toBeDefined() + expect(params?.[verb.callerParam]).toBeUndefined() + // A terminal view's pane is not the session's identity. + expect(params?.terminalPaneKey).toBeUndefined() + expect(params?.senderPaneKey).toBeUndefined() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock.mock.calls.map(([name]) => name)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/^terminal\./)]) + ) + }) + + it.runIf(verb.callerFlag !== undefined)( + 'refuses a caller flag naming another actor, before any request', + async () => { + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_sibling' }) + + await expect(invoke(verb.command, flags)).rejects.toMatchObject({ + code: 'consumer_fenced', + message: expect.stringContaining(`agent session ${SESSION}`) + }) + expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + } + ) + + it.runIf(verb.callerFlag !== undefined)( + "refuses its own terminal view's pane handle too: the session, not the pane, is the caller", + async () => { + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_view_pane' }) + + await expect(invoke(verb.command, flags)).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + } + ) + + it.runIf(verb.callerFlag !== undefined).each([`session:${SESSION}`, SESSION])( + 'accepts a caller flag that restates the session (%s)', + async (restated) => { + await invoke(verb.command, flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: restated })) + + const [params] = callsTo(verb.method) + expect(params, 'the verb reached its method').toBeDefined() + expect(params?.[verb.callerParam]).toBeUndefined() + } + ) +}) + +describe('the identity a session presents', () => { + it("lets a structured worker restate its own minted handle, and nobody else's", async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) + + await invoke('send', flagMap({ from: 'structworker_self', to: 'run:run_1', subject: 's' })) + expect(callsTo('send')[0]?.from).toBeUndefined() + + callMock.mockClear() + await expect( + invoke('send', flagMap({ from: 'structworker_other', to: 'run:run_1', subject: 's' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + }) + + it("sends a structured worker's lifecycle report as the session, not refused as identity-less", async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION }) + + await invoke( + 'send', + flagMap({ to: 'run:run_1', subject: 'done', type: 'worker_done', outcome: 'succeeded' }) + ) + + expect(callsTo('send')[0]).toMatchObject({ type: 'worker_done' }) + expect(callsTo('send')[0]?.from).toBeUndefined() + }) + + it('never treats a session that has an id as identity-less, even beside the old marker', async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_STRUCTURED_SESSION: '1' }) + + await invoke('check', flagMap({})) + + expect(callsTo('check')).toHaveLength(1) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it('keeps the identity-less refusal, without --from advice, for a child that has no id', async () => { + setEnv({ ORCA_STRUCTURED_SESSION: '1' }) + + await expect(invoke('reply', flagMap({ id: 'msg_1', body: 'b' }))).rejects.toMatchObject({ + code: 'no_active_sender_terminal', + message: expect.not.stringContaining('Pass --from') + }) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + }) + + it('leaves a terminal agent exactly as it was: its own handle is the caller', async () => { + setEnv({ ORCA_TERMINAL_HANDLE: 'term_pty', ORCA_PANE_KEY: 'tab_pty:1:2' }) + callMock.mockImplementation(async (name: string) => + name === 'terminal.resolveIdentity' ? { result: { identity: { live: true } } } : RESULT + ) + + await invoke('run-create', flagMap({ objective: 'o' })) + await invoke('check', flagMap({})) + + expect(callsTo('runCreate')[0]?.from).toBe('term_pty') + expect(callsTo('check')[0]).toMatchObject({ + terminal: 'term_pty', + terminalPaneKey: 'tab_pty:1:2' + }) + }) + + it('previews a dispatch with the coordinator address the real dispatch would write', async () => { + const preview = async (flags: Record) => { + callMock.mockClear() + await invoke('dispatch-show', flagMap({ task: 'task_1', preamble: true, ...flags })) + return callsTo('dispatchShow')[0]?.from + } + asSessionInTerminalView() + expect(await preview({})).toBe(`session:${SESSION}`) + // Not a caller flag: it names the text to preview, so it is never fenced. + expect(await preview({ from: 'term_sibling' })).toBe('term_sibling') + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) + expect(await preview({})).toBe('structworker_self') + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it('resumes a timed-out ask as the session, without naming a terminal', async () => { + asSessionInTerminalView() + callMock.mockResolvedValue({ result: { ...RESULT.result, answer: null, timedOut: true } }) + const errors = vi.mocked(console.error) + + await invoke('ask', flagMap({ to: 'term_worker', question: 'q' }), false) + + const advice = errors.mock.calls.map(([line]) => String(line)).join('\n') + expect(advice).toContain('--resume msg_1') + expect(advice).not.toContain('--from') + }) +}) + +describe('the orchestration envelope', () => { + it('carries the injected id beside whatever terminal evidence the process also has', () => { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: ` ${SESSION} `, + ORCA_TERMINAL_HANDLE: 'term_view_pane' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + terminalHandle: 'term_view_pane', + agentSessionId: SESSION + }) + }) + + it('claims no session without an injected id', () => { + expect( + createOrchestrationCompatibilityEnvelope({ ORCA_AGENT_SESSION_ID: ' ' }) + .orchestrationCompatibilityEvidence + ).toBeUndefined() + }) + + it('keeps a WSL stamp beside the id, so the host can refuse the cross-host claim', () => { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + agentSessionId: SESSION, + host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' } + }) + }) +}) + +describe('every orchestration verb, enumerated', () => { + /** Enough flags for any verb to get past its own validation to identity resolution. */ + const EVERY_REQUIRED_FLAG = { + objective: 'o', + id: 'id_1', + task: 'task_1', + spec: 's', + question: 'q', + resolution: 'r', + subject: 's', + body: 'b', + to: 'term_worker', + status: 'completed', + preamble: true, + request: 'req_1' + } as const + + /** Runs every verb once; returns the ones that guessed an implicit terminal. */ + async function verbsThatGuess(): Promise { + const guessed: string[] = [] + for (const command of Object.keys(ORCHESTRATION_HANDLERS)) { + const verb = command.replace('orchestration ', '') + getTerminalHandleMock.mockClear() + callMock.mockClear() + await invoke(verb, flagMap(EVERY_REQUIRED_FLAG)).catch(() => undefined) + if (getTerminalHandleMock.mock.calls.length > 0) { + guessed.push(verb) + } + } + return guessed.sort() + } + + it('guesses a terminal for no verb when the session id is present', async () => { + // Positive control first: with no identity at all the same harness sees the guess, so an empty + // result below is about the id, not a harness that cannot observe a guess. + setEnv({}) + const population = await verbsThatGuess() + expect(population).toEqual([ + 'ask', + 'check', + 'dispatch', + 'dispatch-show', + 'gate-create', + 'gate-list', + 'gate-resolve', + 'reply', + 'run-create', + 'run-current', + 'run-use', + 'send', + 'task-create', + 'task-list', + 'task-update', + 'worker-list', + 'worker-start' + ]) + + asSessionInTerminalView() + expect(await verbsThatGuess()).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index c91ad0ff169..d96e214d442 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -43,6 +43,7 @@ const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] a const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', + 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' @@ -58,7 +59,6 @@ const MINIMAL_PARAMS: Readonly>> = { 'orchestration.reply': { id: 'msg_missing', body: 'b' }, 'orchestration.ask': { question: 'q', to: 'term_worker' }, 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, - 'orchestration.dispatchShow': { task: 'task_missing', preamble: true }, 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, 'orchestration.gateList': {}, diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index deeeb397ca3..e4bd09f2619 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -50,8 +50,6 @@ export const ORCHESTRATION_CALLER_PARAM: Readonly> = 'orchestration.reply': 'from', 'orchestration.ask': 'from', 'orchestration.dispatch': 'from', - // The preview names its caller as the coordinator, exactly as the dispatch it previews does. - 'orchestration.dispatchShow': 'from', 'orchestration.gateCreate': 'from', 'orchestration.gateResolve': 'from', 'orchestration.gateList': 'from', diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts index e36df813570..c9fd3927c02 100644 --- a/src/shared/structured-worker-handle.ts +++ b/src/shared/structured-worker-handle.ts @@ -2,6 +2,6 @@ // renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { +export function isStructuredWorkerHandle(handle: string | null | undefined): handle is string { return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) }