Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>((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();
});
});
});
22 changes: 22 additions & 0 deletions apps/desktop/src/main/maker-ipc/taskActionAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,28 @@ export async function assertTaskActionCurrent(): Promise<void> {
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<T>(operation: () => Promise<T>): Promise<T> {
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: <T>(operation: () => Promise<T>) => 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. */
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/maker-ipc/taskActionHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -472,5 +473,5 @@ export function createDesktopTaskActionAuthorizer(deps: {
})(call);
return rejectedDecision ?? (decision.ok && admittedScope
? admitTaskAction(call, admittedScope, fingerprint) : decision);
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = {
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: () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -237,7 +238,7 @@ export async function startCodexHttpBridge(
const sessionTokenBySessionId = new Map<string, { token: string; generation: number }>();
let nextTokenGeneration = 0;

const httpServer = http.createServer(async (req, res) => {
const httpServer = http.createServer((req, res) => runTaskActionRequest(async () => {
res.setHeader('Server', SERVER_HEADER);

try {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions docs/product-rules/agent-user-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,10 @@ Host 不因当前输入缺少主人标记再追加行动同意判定。只认运
已有委托的执行权不依赖自动观察规则的开关;仅凭普通 Orca Lead 的当前 team 回报只延续原 team 范围。
跨电脑任务互发要求双方支持
行动回执,旧端明确提示不支持;未增加观察数据权限。实机多端验收仍待完成。

2026-10-24 修订:每个独立 MCP 请求隔离启动共享传输时遗留的行动上下文,
再按绑定的当前调用方执行原有权限判定;进程内调用沿同一隔离边界。
普通任务获准后不继承旧调用的行动证据,也不因此强制排队。
当前调用的委托、权限撤销和延迟交付检查继续保留;不恢复过期授权,不自动重发。
实现见 `maker-ipc/taskActionAuthorization.ts`、`taskActionHost.ts` 与
`mcp-integrations/codexHttpBridge.ts`;回归见各自授权及 HTTP 桥接测试。
Loading