Repository navigation
feat(core): propagate trusted daemon invocation context #7279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
5308104
feat(core): propagate trusted daemon invocation context
callmeYe 01a37e6
merge: resolve conflicts in coreToolScheduler.test.ts
qwen-code-dev-bot a59ebe8
Merge branch 'main' into codex/daemon-stdio-invocation-context-v1
yiliang114 c9a1883
test(cli): update ACP startup expectation
callmeYe d99aae0
refactor(core): centralize ACP capability env key
callmeYe 3a119c9
test(cli): update worktree ACP core mock
callmeYe 838dfa8
test(integration): run daemon context smoke on PRs
callmeYe cd9054e
test(ci): update no-AK smoke expectation
callmeYe bb3eb92
test(core): cover invocation context isolation
callmeYe f40e315
fix(cli): compare ACP capability safely
callmeYe 4d345e9
Merge remote-tracking branch 'origin/main' into codex/daemon-stdio-in…
callmeYe 0c07fb0
fix(docs): restore GitHub action input names
callmeYe 976517e
Merge remote-tracking branch 'origin/main' into codex/daemon-stdio-in…
callmeYe d2df6fa
fix(core): sanitize private ACP capability from child env
callmeYe 7d55086
fix(core): reuse private ACP capability env constant
callmeYe 4980bc0
test(cli): cover malformed trusted invocation context
callmeYe 65d4f23
test(acp-bridge): assert exact child environment
callmeYe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # Trusted daemon invocation context | ||
|
|
||
| ## Goal | ||
|
|
||
| Carry daemon-attested identity for one accepted root prompt to MCP servers that Qwen launches over stdio. The context is correlation metadata, not an authorization credential. | ||
|
|
||
| The complete production path is: | ||
|
|
||
| ```text | ||
| daemon prompt admission | ||
| -> private ACP child | ||
| -> root Session turn | ||
| -> Qwen-launched stdio MCP tools/call request metadata | ||
| ``` | ||
|
|
||
| ## Wire contract | ||
|
|
||
| Qwen adds the following value to `tools/call.params._meta["qwen-code/invocation"]`: | ||
|
|
||
| ```ts | ||
| interface InvocationContextV1 { | ||
| version: 1; | ||
| sessionId: string; | ||
| promptId: string; | ||
| originatorClientId?: string; | ||
| } | ||
| ``` | ||
|
|
||
| - `sessionId` is the live daemon session selected by the request route. | ||
| - `promptId` is fixed when the daemon admits the prompt, before it waits in the per-session queue. Non-blocking callers may supply the correlation id used by existing terminal-event matching; otherwise the daemon generates a UUID. In either case the value identifies the prompt the daemon actually admitted, rather than metadata copied from the prompt body. | ||
| - `originatorClientId`, when present, is the request header value after the daemon verifies it is registered on that session. | ||
| - Unknown fields, unknown versions, and blank identifiers are invalid. | ||
|
|
||
| The daemon removes caller-provided values for the reserved metadata keys and reconstructs the context from its own state. It passes the value only to the ACP child it launched and authenticated with a per-process capability. Standalone ACP callers cannot inject the reserved context. | ||
|
|
||
| ## Lifetime and disclosure | ||
|
|
||
| The ACP Session verifies that the context session matches its actual session and binds it to the root prompt with `AsyncLocalStorage`. Concurrent prompts remain isolated, including when they share a pooled MCP transport. Deferred confirmation callbacks explicitly restore the captured context. | ||
|
|
||
| Automatic cron turns, background notifications, resumed background agents, and subagent reasoning loops run with no invocation context. The context is not persisted after the root turn settles. | ||
|
|
||
| Only a transport instance created by Qwen as `StdioClientTransport` from an MCP `command` configuration is marked eligible. HTTP, SSE, WebSocket, reverse, SDK-provided, and client-hosted transports do not receive the metadata. The eligibility marker follows tool discovery, cloning, pooling, reconnect, and retry without becoming a public MCP configuration option. | ||
|
|
||
| ## Non-goals | ||
|
|
||
| - No Browser Use, opencode, local/remote backend, page, or skill-specific behavior. | ||
| - No ingress enumeration or general provenance graph. | ||
| - No new TypeScript SDK API or qwen-serve MCP lifecycle behavior. | ||
| - No authorization decision based on `originatorClientId`. |
216 changes: 216 additions & 0 deletions
216
integration-tests/cli/daemon-invocation-context.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import * as path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { Client } from '@modelcontextprotocol/sdk/client/index.js'; | ||
| import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { fakeToolCall, startFakeOpenAIServer } from '../fake-openai-server.js'; | ||
| import { | ||
| approveWorkspaceMcpServers, | ||
| spawnDaemon, | ||
| type SpawnedDaemon, | ||
| writeWorkspaceSettings, | ||
| } from './_daemon-harness.js'; | ||
|
|
||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const REPO_ROOT = path.resolve(__dirname, '../..'); | ||
| const ECHO_SERVER = path.join( | ||
| REPO_ROOT, | ||
| 'integration-tests', | ||
| 'fixtures', | ||
| 'invocation-context-echo.mjs', | ||
| ); | ||
| const TSX_BIN = path.join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs'); | ||
| const SERVE_BRIDGE_SOURCE = path.join( | ||
| REPO_ROOT, | ||
| 'packages', | ||
| 'sdk-typescript', | ||
| 'src', | ||
| 'daemon-mcp', | ||
| 'serve-bridge', | ||
| 'bin.ts', | ||
| ); | ||
| const ECHO_TOOL = 'mcp__invocation-echo__capture_invocation_context'; | ||
| const INVOCATION_META_KEY = 'qwen-code/invocation'; | ||
| const PROMPT_SENTINEL = 'CAPTURE_DAEMON_INVOCATION_CONTEXT'; | ||
| const FINAL_ASSISTANT_TEXT = 'INVOCATION_CONTEXT_CAPTURED'; | ||
| const UUID_PATTERN = | ||
| /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | ||
|
|
||
| type CaptureRecord = { | ||
| arguments: Record<string, unknown>; | ||
| metadata: Record<string, unknown> | null; | ||
| privateCapabilityInEnv: boolean; | ||
| }; | ||
|
|
||
| type BridgeSession = { | ||
| sessionId: string; | ||
| clientId?: string; | ||
| }; | ||
|
|
||
| function stringEnv(env: NodeJS.ProcessEnv): Record<string, string> { | ||
| return Object.fromEntries( | ||
| Object.entries(env).filter( | ||
| (entry): entry is [string, string] => typeof entry[1] === 'string', | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| function pendingSentinel(body: Record<string, unknown>): boolean { | ||
| const messages = body['messages']; | ||
| if (!Array.isArray(messages)) return false; | ||
| const userIndex = messages.findLastIndex( | ||
| (message) => | ||
| typeof message === 'object' && | ||
| message !== null && | ||
| (message as Record<string, unknown>)['role'] === 'user', | ||
| ); | ||
| if (userIndex === -1) return false; | ||
| if (!JSON.stringify(messages[userIndex]).includes(PROMPT_SENTINEL)) { | ||
| return false; | ||
| } | ||
| return !messages | ||
| .slice(userIndex + 1) | ||
| .some( | ||
| (message) => | ||
| typeof message === 'object' && | ||
| message !== null && | ||
| (message as Record<string, unknown>)['role'] === 'tool', | ||
| ); | ||
| } | ||
|
|
||
| async function callBridgeTool<T>( | ||
| client: Client, | ||
| name: string, | ||
| args: Record<string, unknown>, | ||
| ): Promise<T> { | ||
| const result = await client.callTool({ name, arguments: args }); | ||
| const content = result.content as Array<{ type?: string; text?: string }>; | ||
| const text = content.find((item) => item.type === 'text')?.text; | ||
| if (!text || result.isError) { | ||
| throw new Error(`${name} failed: ${JSON.stringify(result)}`); | ||
| } | ||
| return JSON.parse(text) as T; | ||
| } | ||
|
|
||
| describe('trusted daemon invocation context', () => { | ||
| it('reaches a Qwen-launched local stdio MCP tool without leaking the private capability', async ({ | ||
| skip, | ||
| }) => { | ||
| skip(process.platform === 'win32', 'requires the POSIX daemon harness'); | ||
| const root = mkdtempSync(path.join(tmpdir(), 'qwen-daemon-invocation-')); | ||
| const workspace = path.join(root, 'workspace'); | ||
| const home = path.join(root, 'home'); | ||
| const captureFile = path.join(root, 'capture.jsonl'); | ||
| mkdirSync(workspace, { recursive: true }); | ||
| mkdirSync(home, { recursive: true }); | ||
|
|
||
| const fakeModel = await startFakeOpenAIServer(({ body }) => | ||
| pendingSentinel(body) | ||
| ? { toolCalls: [fakeToolCall(ECHO_TOOL, { probe: PROMPT_SENTINEL })] } | ||
| : { content: FINAL_ASSISTANT_TEXT }, | ||
| ); | ||
|
|
||
| let daemon: SpawnedDaemon | undefined; | ||
| let bridgeClient: Client | undefined; | ||
| try { | ||
| const mcpServers = { | ||
| 'invocation-echo': { | ||
| command: process.execPath, | ||
| args: [ECHO_SERVER], | ||
| env: { INVOCATION_CONTEXT_ECHO_FILE: captureFile }, | ||
| trust: true, | ||
| alwaysLoadTools: true, | ||
| }, | ||
| }; | ||
| writeWorkspaceSettings(workspace, { | ||
| tools: { approvalMode: 'yolo' }, | ||
| mcpServers, | ||
| }); | ||
| const runtimeEnv = { | ||
| ...process.env, | ||
| ...approveWorkspaceMcpServers(workspace, mcpServers), | ||
| HOME: home, | ||
| QWEN_HOME: path.join(home, '.qwen'), | ||
| QWEN_SANDBOX: 'false', | ||
| QWEN_CODE_NO_RELAUNCH: 'true', | ||
| QWEN_CODE_LEGACY_MCP_BLOCKING: '1', | ||
| QWEN_CODE_SUPPRESS_YOLO_WARNING: '1', | ||
| OPENAI_API_KEY: 'fake-key', | ||
| OPENAI_BASE_URL: fakeModel.baseUrl, | ||
| OPENAI_MODEL: 'fake-model', | ||
| QWEN_MODEL: 'fake-model', | ||
| NO_PROXY: '127.0.0.1,localhost', | ||
| no_proxy: '127.0.0.1,localhost', | ||
| }; | ||
| daemon = await spawnDaemon({ | ||
| workspaceCwd: workspace, | ||
| bootTimeoutMs: 20_000, | ||
| env: runtimeEnv, | ||
| }); | ||
| bridgeClient = new Client({ | ||
| name: 'daemon-invocation-context-test', | ||
| version: '1.0.0', | ||
| }); | ||
| await bridgeClient.connect( | ||
| new StdioClientTransport({ | ||
| command: process.execPath, | ||
| args: [TSX_BIN, SERVE_BRIDGE_SOURCE], | ||
| env: stringEnv({ | ||
| ...runtimeEnv, | ||
| QWEN_DAEMON_URL: daemon.base, | ||
| QWEN_DAEMON_TOKEN: daemon.token, | ||
| QWEN_WORKSPACE_CWD: workspace, | ||
| }), | ||
| }), | ||
| ); | ||
|
|
||
| const session = await callBridgeTool<BridgeSession>( | ||
| bridgeClient, | ||
| 'session_create', | ||
| { workspace_cwd: workspace, session_scope: 'thread' }, | ||
| ); | ||
| await callBridgeTool(bridgeClient, 'prompt', { | ||
| session_id: session.sessionId, | ||
| prompt: PROMPT_SENTINEL, | ||
| }); | ||
|
|
||
| const records = readFileSync(captureFile, 'utf8') | ||
| .trim() | ||
| .split('\n') | ||
| .filter(Boolean) | ||
| .map((line) => JSON.parse(line) as CaptureRecord); | ||
| expect(records).toHaveLength(1); | ||
| expect(records[0]?.arguments['probe']).toBe(PROMPT_SENTINEL); | ||
| expect(records[0]?.privateCapabilityInEnv).toBe(false); | ||
| expect(records[0]?.metadata?.[INVOCATION_META_KEY]).toEqual({ | ||
| version: 1, | ||
| sessionId: session.sessionId, | ||
| promptId: expect.stringMatching(UUID_PATTERN), | ||
| }); | ||
| expect( | ||
| ( | ||
| records[0]?.metadata?.[INVOCATION_META_KEY] as | ||
| | Record<string, unknown> | ||
| | undefined | ||
| )?.['originatorClientId'], | ||
| ).toBeUndefined(); | ||
|
|
||
| await callBridgeTool(bridgeClient, 'session_close', { | ||
| session_id: session.sessionId, | ||
| }); | ||
| } finally { | ||
| await bridgeClient?.close().catch(() => undefined); | ||
| await daemon?.dispose(); | ||
| await fakeModel.close(); | ||
| rmSync(root, { recursive: true, force: true }); | ||
| } | ||
| }, 120_000); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { appendFile } from 'node:fs/promises'; | ||
| import process from 'node:process'; | ||
|
|
||
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import { | ||
| CallToolRequestSchema, | ||
| ListToolsRequestSchema, | ||
| } from '@modelcontextprotocol/sdk/types.js'; | ||
|
|
||
| const outputFile = process.env['INVOCATION_CONTEXT_ECHO_FILE']; | ||
| if (!outputFile) { | ||
| throw new Error('INVOCATION_CONTEXT_ECHO_FILE is required'); | ||
| } | ||
|
|
||
| const server = new Server( | ||
| { name: 'invocation-context-echo', version: '1.0.0' }, | ||
| { capabilities: { tools: {} } }, | ||
| ); | ||
|
|
||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: [ | ||
| { | ||
| name: 'capture_invocation_context', | ||
| description: 'Capture this tool request metadata.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { probe: { type: 'string' } }, | ||
| required: ['probe'], | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| ], | ||
| })); | ||
|
|
||
| server.setRequestHandler(CallToolRequestSchema, async (request) => { | ||
| const record = { | ||
| arguments: request.params.arguments ?? {}, | ||
| metadata: request.params._meta ?? null, | ||
| privateCapabilityInEnv: | ||
| process.env['QWEN_CODE_PRIVATE_ACP_CAPABILITY'] !== undefined, | ||
| }; | ||
| await appendFile(outputFile, `${JSON.stringify(record)}\n`); | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify(record) }], | ||
| }; | ||
| }); | ||
|
|
||
| await server.connect(new StdioServerTransport()); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.