diff --git a/apps/desktop/src/main/maker-ipc/__tests__/taskActionAuthorization.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/taskActionAuthorization.test.ts index 5ae251c2dd3..aaada52385f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/taskActionAuthorization.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/taskActionAuthorization.test.ts @@ -15,6 +15,7 @@ import { createOrcaMcpServer } from '../../../../../../packages/lizi-mcps/src/or import type { AgentInputQueuedMessage } from '../../../shared/agentInputQueue.js'; import { approvedTaskDelegation, + assertTaskActionCurrent, bindTaskActionInput, createTaskActionAuthorizer, taskActionInputGuard, @@ -882,3 +883,60 @@ describe('teammate turn principal', () => { expect(await withToolCallAuthority(h.authorize, ownerTaskWrites[0], async () => 'changed')).toBe('changed'); }); }); + +// A bare allow must not reuse an outer action's authority, even through the +// in-process MCP path. Existing Desktop fixtures called the gate at top level. +// Production Desktop admission + guards; unit tier, in-memory Host facts. +describe('independent Desktop tool invocation', () => { + it('rechecks an ordinary call without inheriting stale authorization or forcing its queue', async () => { + const outer = fixture(true); + const h = desktopFixture('private'); + h.caller.botId = null; + h.caller.source = 'desktop'; + await withToolCallAuthority(outer.authorize, call('start_session_task'), async () => { + outer.revoke(); + const result = await withToolCallAuthority(h.authorize, { + ...call('send_to_session'), sessionId: 'ordinary-task', + }, async () => { + await assertTaskActionCurrent(); + expect(hasTaskActionAuthorization()).toBe(false); + expect(approvedTaskDelegation('companion')).toBeUndefined(); + return 'sent'; + }); + expect(result).toBe('sent'); + // Isolation must not clear the outer scope or its revocation checks. + await expect(assertTaskActionCurrent()).rejects.toThrow('revoked'); + }); + expect(h.confirm).not.toHaveBeenCalled(); + }); + + it('keeps concurrent admitted callers separate across awaits', async () => { + const h = desktopFixture('private'); + let release!: () => void; + const bothEntered = new Promise((resolve) => { release = resolve; }); + let entered = 0; + await Promise.all(['first-task', 'second-task'].map((sessionId) => + withToolCallAuthority(h.authorize, { ...call('send_to_session'), sessionId }, async () => { + if (++entered === 2) release(); + await bothEntered; + await assertTaskActionCurrent(); + expect(hasTaskActionAuthorization(sessionId)).toBe(true); + expect(hasTaskActionAuthorization(sessionId === 'first-task' ? 'second-task' : 'first-task')).toBe(false); + }), + )); + expect(entered).toBe(2); + expect(hasTaskActionAuthorization()).toBe(false); + }); + + it('does not use a valid outer grant to bypass rejection of the current caller', async () => { + const outer = fixture(true); + const h = desktopFixture('automatic'); + const effect = vi.fn(async () => 'sent'); + await withToolCallAuthority(outer.authorize, call('start_session_task'), async () => { + const result = await withToolCallAuthority(h.authorize, call('send_to_session'), effect); + expect(JSON.stringify(result)).toContain('NEEDS_OWNER_REQUEST'); + expect(effect).not.toHaveBeenCalled(); + expect(approvedTaskDelegation('companion')).toBeDefined(); + }); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/taskActionAuthorization.ts b/apps/desktop/src/main/maker-ipc/taskActionAuthorization.ts index a0600c63cca..b60ced74964 100644 --- a/apps/desktop/src/main/maker-ipc/taskActionAuthorization.ts +++ b/apps/desktop/src/main/maker-ipc/taskActionAuthorization.ts @@ -196,6 +196,28 @@ export async function assertTaskActionCurrent(): Promise { await actionContext.getStore()?.assertCurrent(); } +/** An independent inbound request must not inherit the action that lazily started + * its shared transport. This clears only task-action evidence, not MCP routing; + * the request still passes the normal token, caller and per-tool authorization. */ +export function runTaskActionRequest(operation: () => Promise): Promise { + return actionContext.exit(operation); +} + +/** Cover in-process callers too: authorize this invocation without ambient evidence, + * then execute under only the returned admission. A bare allow intentionally keeps + * ordinary-task semantics (no Bot delegation evidence or forced queue). */ +export function isolateTaskActionAuthorizer(authorize: ToolCallAuthorizer): ToolCallAuthorizer { + return (call) => runTaskActionRequest(async () => { + const decision = await authorize(call); + if (!decision.ok) return decision; + return { + ok: true, + run: (operation: () => Promise) => runTaskActionRequest(() => + decision.run ? decision.run(operation) : operation()), + }; + }); +} + /** Host lifecycle only: durable completion delivery is not another invocation * of the tool that started the task. Its caller must validate the persisted * delegation/run and recipient; this does not authorize any Agent task action. */ diff --git a/apps/desktop/src/main/maker-ipc/taskActionHost.ts b/apps/desktop/src/main/maker-ipc/taskActionHost.ts index 2dd2ec52d0d..a7f03339a39 100644 --- a/apps/desktop/src/main/maker-ipc/taskActionHost.ts +++ b/apps/desktop/src/main/maker-ipc/taskActionHost.ts @@ -16,6 +16,7 @@ import { readChannelToolScope } from '../im/shared/channelToolScope.js'; import { BUILTIN_ROUTINE_SCHEDULE_PREFIX } from '../../shared/botAutonomy.js'; import { admitTaskAction, + isolateTaskActionAuthorizer, createTaskActionAuthorizer, DELEGATED_TASK_ACTIONS, NEEDS_OWNER_REQUEST, @@ -212,7 +213,7 @@ export function createDesktopTaskActionAuthorizer(deps: { scheduleId: origin?.kind === 'scheduler' ? origin.scheduleId : undefined, }); }; - return async (call) => { + return isolateTaskActionAuthorizer(async (call) => { if ((call.server === 'cindy_helper' && TEAMMATE_STANDING_GRANT_TOOLS.has(call.tool)) || (call.server === 'cindy_scheduler' && TEAMMATE_SCHEDULER_WRITE_TOOLS.has(call.tool))) { if (!call.sessionId) return { ok: true }; @@ -472,5 +473,5 @@ export function createDesktopTaskActionAuthorizer(deps: { })(call); return rejectedDecision ?? (decision.ok && admittedScope ? admitTaskAction(call, admittedScope, fingerprint) : decision); - }; + }); } diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/codexHttpBridge.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/codexHttpBridge.test.ts index bd6c080f786..3b4c72a8de7 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/codexHttpBridge.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/codexHttpBridge.test.ts @@ -4,6 +4,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createXdtHelperMcpServer, getLiziMcpSessionContext } from '@cindy/mcps'; import type { Logger } from '@cindy/maker-core'; +import { withToolCallAuthority } from '../../../../../../packages/lizi-mcps/src/tool-call-authority.js'; +import { admitTaskAction, assertTaskActionCurrent, createTaskActionAuthorizer, hasTaskActionAuthorization } from '../../maker-ipc/taskActionAuthorization.js'; import { computeRemoteMcpFingerprint, selectRemoteInjectableServerNames, @@ -158,6 +160,77 @@ describe('codexHttpBridge', () => { bridge = null; }); + // Regression: lazy bridge startup inside an admitted action must not attach that + // action to later requests. Existing routing tests started outside any action. + // Real HTTP/MCP entry, loopback ephemeral port, unit tier; no account or device IO. + it('isolates a stale bridge creator while enforcing each current tool authorization', async () => { + let creatorCurrent = true; + let allowCurrent = true; + let guardedCurrent = false; + let currentValid = true; + const authorize = createTaskActionAuthorizer({ + capture: async () => guardedCurrent || !allowCurrent ? { + key: 'current-input', ownerInput: false, delegated: allowCurrent, + assertCurrent: async () => { + if (!currentValid) throw new Error('current-call-revoked'); + }, + } : null, + confirm: async () => false, + }); + const creator = await admitTaskAction({ + sessionId: 'bridge-creator', server: 'cindy_helper', tool: 'send_to_session', args: {}, + }, { + key: 'creator-input', ownerInput: true, delegated: false, + assertCurrent: async () => { + if (!creatorCurrent) throw new Error('TASK_ACTION_AUTHORIZATION_EXPIRED'); + }, + }); + if (!creator.ok || !creator.run) throw new Error('missing creator admission'); + bridge = await creator.run(() => startCodexHttpBridge({ + serverFactories: { cindy_helper: () => { + const server = createTestServer(); + server.tool('send_to_session', {}, async () => withToolCallAuthority(authorize, { + sessionId: getLiziMcpSessionContext()?.sessionId, + server: 'cindy_helper', tool: 'send_to_session', args: {}, + }, async () => { + await assertTaskActionCurrent(); + return { content: [{ type: 'text' as const, text: hasTaskActionAuthorization() + ? 'current-admission' : 'ordinary-current-call' }] }; + })); + return server; + } }, logger: noopLogger(), + })); + creatorCurrent = false; + const current = bridge; + current.registerThreadContext('current-thread', { + agentKind: 'codex', sessionId: 'current-task', workingDir: '/test', + }); + const headers: Record = { + authorization: `Bearer ${current.token}`, accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }; + let id = 0; + const request = (method: string, params: unknown) => fetch(current.url('cindy_helper'), { + method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), + }); + const initialized = await request('initialize', { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'auth-test', version: '1' }, + }); + headers['mcp-session-id'] = initialized.headers.get('mcp-session-id')!; + await initialized.text(); + const invoke = async () => readRpcResponse(await request('tools/call', { + name: 'send_to_session', arguments: {}, _meta: { threadId: 'current-thread' }, + })); + expect(await invoke()).toMatchObject({ result: { content: [{ text: 'ordinary-current-call' }] } }); + allowCurrent = false; + expect(JSON.stringify(await invoke())).toContain('TASK_ACTION_AUTHORIZATION_REQUIRED'); + allowCurrent = true; + guardedCurrent = true; + expect(await invoke()).toMatchObject({ result: { content: [{ text: 'current-admission' }] } }); + currentValid = false; + expect(JSON.stringify(await invoke())).toContain('TASK_ACTION_AUTHORIZATION_EXPIRED'); + }); + it('scopes startup tools/list before thread registration without authorizing execution', async () => { bridge = await startCodexHttpBridge({ serverFactories: { cindy_helper: () => { diff --git a/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts b/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts index a221facafeb..f260bd8c02e 100644 --- a/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts +++ b/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts @@ -30,6 +30,7 @@ import { } from './codexMcpThreadContextStore.js'; import { isFrozenBuiltinPluginAllowed, isFrozenMcpServerAllowed } from './codexBuiltinToolPolicy.js'; import { appServerIds } from '../mcp-apps/runtime.js'; +import { runTaskActionRequest } from '../maker-ipc/taskActionAuthorization.js'; const SERVER_HEADER = 'Lizi_MCPS/1.0'; const MCP_PATH_PREFIX = '/mcp/'; @@ -237,7 +238,7 @@ export async function startCodexHttpBridge( const sessionTokenBySessionId = new Map(); let nextTokenGeneration = 0; - const httpServer = http.createServer(async (req, res) => { + const httpServer = http.createServer((req, res) => runTaskActionRequest(async () => { res.setHeader('Server', SERVER_HEADER); try { @@ -419,7 +420,7 @@ export async function startCodexHttpBridge( res.end(err instanceof Error ? err.message : 'Internal server error'); } } - }); + })); // streamable-http 是长连接 SSE 流;默认 5s keep-alive 会切断 codex 连接。 httpServer.keepAliveTimeout = 0; diff --git a/docs/product-rules/agent-user-parity.md b/docs/product-rules/agent-user-parity.md index c4025962d99..a7366151065 100644 --- a/docs/product-rules/agent-user-parity.md +++ b/docs/product-rules/agent-user-parity.md @@ -143,3 +143,10 @@ Host 不因当前输入缺少主人标记再追加行动同意判定。只认运 已有委托的执行权不依赖自动观察规则的开关;仅凭普通 Orca Lead 的当前 team 回报只延续原 team 范围。 跨电脑任务互发要求双方支持 行动回执,旧端明确提示不支持;未增加观察数据权限。实机多端验收仍待完成。 + +2026-10-24 修订:每个独立 MCP 请求隔离启动共享传输时遗留的行动上下文, +再按绑定的当前调用方执行原有权限判定;进程内调用沿同一隔离边界。 +普通任务获准后不继承旧调用的行动证据,也不因此强制排队。 +当前调用的委托、权限撤销和延迟交付检查继续保留;不恢复过期授权,不自动重发。 +实现见 `maker-ipc/taskActionAuthorization.ts`、`taskActionHost.ts` 与 +`mcp-integrations/codexHttpBridge.ts`;回归见各自授权及 HTTP 桥接测试。