Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
49 changes: 49 additions & 0 deletions docs/design/trusted-daemon-invocation-context.md
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 integration-tests/cli/daemon-invocation-context.test.ts
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', () => {
Comment thread
callmeYe marked this conversation as resolved.
Resolved
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);
});
49 changes: 49 additions & 0 deletions integration-tests/fixtures/invocation-context-echo.mjs
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());
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests ./fake-openai-server.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts",
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests ./fake-openai-server.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/daemon-invocation-context.test.ts",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
Expand Down
Loading
Loading