diff --git a/src/cli/command-spec.ts b/src/cli/command-spec.ts index 1922888a292..dbb28a66d71 100644 --- a/src/cli/command-spec.ts +++ b/src/cli/command-spec.ts @@ -15,8 +15,13 @@ export type CommandSpec = { positionalArgs?: string[] examples?: string[] notes?: string[] + // Why: `--from`/`--terminal` names either the acting caller or a target, and only the spec can + // say which. An agent session refuses a caller flag naming anyone else before dispatch. + identityFlagRoles?: Partial> } +export type IdentityFlag = 'from' | 'terminal' + export function specPaths(spec: CommandSpec): string[][] { return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] } diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index 5162ca6e291..5f28d4bbcd3 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -5,7 +5,8 @@ import { RuntimeClientError } from '../../runtime-client' import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' -import { injectedSessionAddress, resolveCoordinatorTerminalHandle } from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { injectedSessionAddress } from '../../session-caller-flags' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index 9af0faa1be9..f9ec4c6583f 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -2,10 +2,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { callOrchestrationMutation } from './mutation-request' -import { - refuseConflictingSessionCaller, - resolveCoordinatorTerminalHandle -} from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-create': async ({ flags, client, cwd, json }) => { @@ -40,9 +37,7 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run - ? refuseConflictingSessionCaller(flags, 'from') - : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index 9533b1e5f8a..c4c943261e8 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -4,10 +4,7 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' import { callOrchestrationMutation } from './mutation-request' -import { - refuseConflictingSessionCaller, - resolveCoordinatorTerminalHandle -} from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' const TASK_STATUS_VALUES = [ 'pending', @@ -42,7 +39,7 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') const callerTerminalHandle = run - ? refuseConflictingSessionCaller(flags, 'from') + ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index fce1696b036..572b9096bce 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -4,8 +4,7 @@ import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' -import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' -import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +import { injectedSessionAddress } from '../../session-caller-flags' /** * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: @@ -19,7 +18,8 @@ export async function resolveOrchestrationTerminalHandle( flagName: 'from' | 'terminal', options: { validateEnvHandle?: boolean } = {} ): Promise { - if (resolveInjectedSessionCaller(flags, flagName)) { + // A caller flag naming anyone else was already refused at the CLI entry, from the command's spec. + if (readInjectedAgentSessionId()) { return undefined } const explicit = getOptionalStringFlag(flags, flagName) @@ -168,61 +168,6 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } -/** - * The injected session id when this command runs as an agent session. The id wins over every other - * 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. - */ -function resolveInjectedSessionCaller( - flags: Map, - flagName: 'from' | 'terminal' -): string | undefined { - const sessionId = readInjectedAgentSessionId() - if (!sessionId) { - return undefined - } - const declared = getOptionalStringFlag(flags, flagName) - if (declared !== undefined && !namesInjectedSession(declared, sessionId)) { - 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 ` + - `session:${sessionId}. No request was sent.` - ) - } - return sessionId -} - -/** - * For a listing scoped by `--run`, which needs no caller: a session still refuses a caller flag - * naming someone else rather than dropping it. - */ -export function refuseConflictingSessionCaller( - flags: Map, - flagName: 'from' | 'terminal' -): undefined { - resolveInjectedSessionCaller(flags, flagName) - return undefined -} - -/** The session's own spellings, plus the handle a structured worker session was minted. */ -function namesInjectedSession(value: string, sessionId: string): boolean { - 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) ? ownHandle : `session:${sessionId}` -} - /** How check output names its caller: the handle, or the session's address. */ export function orchestrationCallerLabel(handle: string | undefined): string { return handle ?? injectedSessionAddress() ?? 'unknown' diff --git a/src/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index c4e4438f2e9..52fc7222e46 100644 --- a/src/cli/index-orchestration.test.ts +++ b/src/cli/index-orchestration.test.ts @@ -85,6 +85,25 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) + it("refuses an agent session's caller flag naming another actor before any request", async () => { + // One chokepoint for every verb: the spec says which flag names the caller. + process.env.ORCA_AGENT_SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await main(['orchestration', 'check', '--terminal', 'term_sibling', '--json'], '/tmp/repo') + } finally { + delete process.env.ORCA_AGENT_SESSION_ID + } + + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { code: 'consumer_fenced' } + }) + process.exitCode = undefined + }) + it('rejects no-flag orchestration reset before calling the runtime', async () => { await main(['orchestration', 'reset'], '/tmp/repo') diff --git a/src/cli/index.ts b/src/cli/index.ts index f88d7ff2221..ede5fd9f9b4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' import { runAsSessionCli } from './session-cli-reexec' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -111,6 +112,10 @@ export async function main( // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) + refuseConflictingSessionCallerFlags( + findCommandSpec(COMMAND_SPECS, parsed.commandPath), + parsed.flags + ) const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 7118dcac4ab..9ca398e35b2 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -19,6 +19,9 @@ vi.mock('./format', () => ({ printResult: vi.fn() })) vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { findCommandSpec } from './args' +import { COMMAND_SPECS } from './specs' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' import { formatCliError, reportCliError } from './cli-error' import { RuntimeRpcFailureError } from './runtime/types' @@ -171,10 +174,27 @@ const CALLER_VERBS: Verb[] = [ { command: 'worker-list', flags: {}, method: 'runCurrent', callerParam: 'from' } ] +/** 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 + function flagMap(flags: Record): Map { return new Map(Object.entries(flags)) } +/** What `main()` does between parsing and dispatch: the spec-driven caller check, then the handler. */ async function invoke( command: string, flags: Map, @@ -184,6 +204,10 @@ async function invoke( if (!handler) { throw new Error(`no handler for ${command}`) } + refuseConflictingSessionCallerFlags( + findCommandSpec(COMMAND_SPECS, ['orchestration', command]), + flags + ) 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. @@ -488,23 +512,62 @@ describe('the orchestration envelope', () => { }) }) -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 +describe('which flag names the caller, declared on every spec', () => { + const ORCHESTRATION_SPECS = COMMAND_SPECS.filter((spec) => spec.path[0] === 'orchestration') + it('classifies every --from and --terminal an orchestration verb accepts', () => { + // A new verb cannot take either flag without saying whether it names the caller, so the entry + // check covers it by construction instead of each handler remembering to refuse. + const unclassified = ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.allowedFlags.includes(flag) && !spec.identityFlagRoles?.[flag]) + .map((flag) => `${spec.path.join(' ')} --${flag}`) + ) + expect(unclassified).toEqual([]) + }) + + const callerFlagVerbs = ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.identityFlagRoles?.[flag] === 'caller') + .map((flag) => ({ command: spec.path[1] ?? '', flag })) + ) + + it('covers the verbs whose requests name a caller', () => { + expect(callerFlagVerbs.length).toBeGreaterThanOrEqual(CALLER_VERBS.length) + }) + + it.each(callerFlagVerbs)( + '$command refuses --$flag naming another actor, before any request', + async ({ command, flag }) => { + asSessionInTerminalView() + await expect( + invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + } + ) + + it.each( + ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.identityFlagRoles?.[flag] === 'target') + .map((flag) => ({ command: spec.path[1] ?? '', flag })) + ) + )('$command passes a --$flag target through unfenced', async ({ command, flag }) => { + asSessionInTerminalView() + await invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })).catch( + (error: unknown) => { + expect(error).not.toMatchObject({ code: 'consumer_fenced' }) + } + ) + expect(callMock.mock.calls.flatMap(([, params]) => Object.values(params ?? {}))).toContain( + 'term_sibling' + ) + }) +}) + +describe('every orchestration verb, enumerated', () => { /** Runs every verb once; returns the ones that guessed an implicit terminal. */ async function verbsThatGuess(): Promise { const guessed: string[] = [] diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts new file mode 100644 index 00000000000..b136bccb4bc --- /dev/null +++ b/src/cli/session-caller-flags.ts @@ -0,0 +1,63 @@ +/** + * An agent session is its own orchestration caller. A flag that names the caller may restate that + * session, but one naming anyone else is refused before any handler runs — never dropped, never + * allowed to win. Against a host that predates session callers this is the only guard: such a host + * would honor the flag as the caller, which is how a chat consumed a sibling's mail (#21097). + * + * Whether `--from`/`--terminal` names the caller or a target is declared on the command's spec + * (`identityFlagRoles`) and checked once, here, at the CLI entry, so a handler cannot forget it. + */ + +import type { CommandSpec, IdentityFlag } from './command-spec' +import { RuntimeClientError } from './runtime/types' +import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' +import { normalizeOrchestrationActor } from '../shared/orchestration-actor' +import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' + +export function refuseConflictingSessionCallerFlags( + spec: CommandSpec | undefined, + flags: ReadonlyMap, + env: NodeJS.ProcessEnv = process.env +): void { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId || !spec?.identityFlagRoles) { + return + } + for (const flagName of IDENTITY_FLAGS) { + const declared = flags.get(flagName) + if ( + spec.identityFlagRoles[flagName] === 'caller' && + typeof declared === 'string' && + !namesInjectedSession(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 ` + + `session:${sessionId}. No request was sent.` + ) + } + } +} + +const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] + +/** The session's own spellings, plus the handle a structured worker session was minted. */ +function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { + return ( + normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress(env) + ) +} + +/** + * 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(env: NodeJS.ProcessEnv = process.env): string | undefined { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId) { + return undefined + } + const ownHandle = env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` +} diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 1448667e54a..04423ffc6fb 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -31,6 +31,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'from', 'retry-request' ], + identityFlagRoles: { from: 'caller', terminal: 'target' }, notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 458afa2e525..8c4418f38ba 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -9,6 +9,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration run-create --objective [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: [ 'A Run is a namespace and home inbox. It never schedules or places workers.', '--retry-request is only for exact recovery after an unknown mutation result.' @@ -20,6 +21,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration run-use --id [--from ] [--takeover-legacy] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: [ '--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.' ] @@ -28,7 +30,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'run-current'], summary: 'Show the Run bound to this coordinator terminal', usage: 'orca orchestration run-current [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'from'] + allowedFlags: [...GLOBAL_FLAGS, 'from'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'run-list'], @@ -67,6 +70,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'report-path', 'phase' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.', 'To answer a worker question, use orchestration reply --id --body with the same Orca CLI executable.', @@ -109,6 +113,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'timeout-ms', 'retry-request' ], + identityFlagRoles: { terminal: 'caller' }, notes: [ 'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".', '--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Without --wait it has no effect on consuming checks. Only --peek and --all filter their rows.', @@ -121,13 +126,15 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Reply to a message', usage: 'orca orchestration reply --id --body [--run ] [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'inbox'], summary: 'Show messages across (or for) recipients', usage: 'orca orchestration inbox [--limit ] [--terminal ] [--full] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'] + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'], + identityFlagRoles: { terminal: 'target' } }, { path: ['orchestration', 'task-create'], @@ -144,7 +151,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'run', 'from', 'retry-request' - ] + ], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'task-list'], @@ -152,6 +160,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration task-list [--status ] [--ready] [--brief] [--run ] [--from ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief', 'run', 'from'], + identityFlagRoles: { from: 'caller' }, notes: ['--brief collapses whitespace and caps each spec at 160 characters.'] }, { @@ -160,6 +169,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration task-update --id --status [--result ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] }, ...ORCHESTRATION_WORKER_COMMAND_SPECS, @@ -178,7 +188,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'dry-run', 'return-preamble', 'retry-request' - ] + ], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'request-show'], @@ -196,7 +207,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Show dispatch context for a task', usage: 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'], + identityFlagRoles: { from: 'target' } }, { path: ['orchestration', 'ask'], @@ -215,6 +227,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'from', 'retry-request' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'From an active Dispatch, a new question defaults to its owning Run mailbox.', 'Timeout leaves the question pending; resume with the original message ID.' @@ -234,6 +247,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'max-concurrent', 'worktree' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.', 'Use the lightweight Run, Task, and worker-start primitives described by the current skill.' @@ -254,14 +268,16 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Create a decision gate blocking a task', usage: 'orca orchestration gate-create --task --question [--options ] [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'gate-resolve'], summary: 'Resolve a pending decision gate', usage: 'orca orchestration gate-resolve --id --resolution [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'gate-list'], @@ -269,6 +285,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration gate-list [--task ] [--status ] [--run ] [--from ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'task', 'status', 'run', 'from'], + identityFlagRoles: { from: 'caller' }, notes: ['--run inspects a named Run without binding; otherwise gates are scoped to the caller.'] }, {