diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index f9ec4c6583f..c68112f9aa3 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -1,6 +1,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { refuseUnsentSessionAddress } from '../../session-caller-flags' import { callOrchestrationMutation } from './mutation-request' import { resolveCoordinatorTerminalHandle } from './terminal-identity' @@ -36,6 +37,9 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') + if (run) { + refuseUnsentSessionAddress(flags, 'from') + } // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index c4c943261e8..a6172c54f06 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -3,6 +3,7 @@ import { printResult } from '../../format' import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' +import { refuseUnsentSessionAddress } from '../../session-caller-flags' import { callOrchestrationMutation } from './mutation-request' import { resolveCoordinatorTerminalHandle } from './terminal-identity' @@ -38,6 +39,9 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { 'orchestration task-list': async ({ flags, client, cwd, json }) => { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') + if (run) { + refuseUnsentSessionAddress(flags, 'from') + } const callerTerminalHandle = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 94beace608f..940a42ff2e1 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -360,6 +360,17 @@ describe.each([ await invoke(command, flagMap({ run: 'run_1', from: `session:${SESSION}` })) expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() }) + + it('refuses a session address it would not send for the host to check, before any request', async () => { + // With --run no caller is sent, so a `session:` address the host alone could place (another + // chat's, or this chat's pre-/clear root) would be dropped unchecked. + for (const other of [`session:${ROOT}`, 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08']) { + await expect(invoke(command, flagMap({ run: 'run_1', from: other }))).rejects.toMatchObject({ + code: 'consumer_fenced' + }) + } + expect(callMock).not.toHaveBeenCalled() + }) }) describe('the identity a session presents', () => { diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index 80078ffda7b..069c3230cfc 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -36,16 +36,44 @@ export function refuseConflictingSessionCallerFlags( !namesInjectedSession(declared, sessionId, env) && !sessionAddressForHost(declared, sessionId, env) ) { - throw new RuntimeClientError( - 'consumer_fenced', - `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + - `different caller. Drop --${flagName}: this session's orchestration commands already act ` + - `as this session. No request was sent.` - ) + throw conflictingCallerFlag(sessionId, flagName, declared) } } } +/** + * For a verb that sends no caller (`--run` names the Run), a session address the host would + * otherwise have checked is refused here, rather than dropped unchecked. + */ +export function refuseUnsentSessionAddress( + flags: ReadonlyMap, + flagName: IdentityFlag, + env: NodeJS.ProcessEnv = process.env +): void { + const sessionId = readInjectedAgentSessionId(env) + const declared = flags.get(flagName) + if ( + sessionId && + typeof declared === 'string' && + sessionAddressForHost(declared, sessionId, env) + ) { + throw conflictingCallerFlag(sessionId, flagName, declared) + } +} + +function conflictingCallerFlag( + sessionId: string, + flagName: IdentityFlag, + declared: string +): RuntimeClientError { + return new RuntimeClientError( + 'consumer_fenced', + `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + + `different caller. Drop --${flagName}: this session's orchestration commands already act ` + + `as this session. No request was sent.` + ) +} + const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] /**