Repository navigation
feat(core): propagate trusted invocation context #6895
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
Closed
Closed
Changes from 8 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
c468b5f
feat(core): propagate invocation context
callmeYe b932b84
fix(sdk): preserve completed prompt responses
callmeYe aaa1834
fix(sdk): drain prompt cancellation during shutdown
callmeYe 1ccbf88
test(cli): cover Docker child environment passthrough
callmeYe b3ec2ca
Merge remote-tracking branch 'origin/main' into codex/invocation-cont…
callmeYe b2a37fc
refactor(cli): centralize invocation ingress mapping
callmeYe aa31ea3
test(cli): cover invalid trusted invocation metadata
callmeYe fd99e7c
Merge remote-tracking branch 'origin/main' into codex/invocation-cont…
callmeYe 523d3cd
fix(sdk): recover rejected session bindings
callmeYe 2438837
Merge remote-tracking branch 'origin/main' into codex/invocation-cont…
callmeYe 837b59d
feat(core): merge origin/main into invocation-context branch
qwen-code-dev-bot df781b2
feat(core): merge origin/main into invocation-context branch
qwen-code-dev-bot 9ea2685
fix(sdk): account for invocation ingress bundle size
callmeYe 8084e28
fix: address invocation context review feedback
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,196 @@ | ||
| # Invocation Context v1 | ||
|
|
||
| ## Status | ||
|
|
||
| Implementation design for Qwen Code v0.19.10 and later. | ||
|
|
||
| ## Problem | ||
|
|
||
| Qwen Code can enter a session through the CLI, ACP, the HTTP daemon, | ||
| messaging channels, scheduled work, or another MCP server. Once the prompt is | ||
| inside `Session`, those origins are no longer available to a local MCP tool. | ||
| Passing caller-provided metadata through unchanged would be incorrect: public | ||
| ACP and daemon clients could forge session, prompt, or client identities, and | ||
| client-hosted MCP transports could receive data intended only for a process | ||
| started by Qwen Code. | ||
|
|
||
| Invocation Context v1 provides correlation metadata with three constraints: | ||
|
|
||
| 1. Native entry points create or rebuild the context; request payloads do not | ||
| become authority by themselves. | ||
| 2. The context exists only for the lifetime of an async execution tree and is | ||
| not written into chat history. | ||
| 3. Only MCP servers launched through an actual stdio transport receive it. | ||
|
|
||
| The context is correlation metadata, not an authorization credential. | ||
|
|
||
| ## Wire contract | ||
|
|
||
| ```ts | ||
| type InvocationIngress = | ||
| | 'cli' | ||
| | 'acp' | ||
| | 'daemon' | ||
| | 'channel' | ||
| | 'scheduler' | ||
| | 'external_mcp' | ||
| | 'internal'; | ||
|
|
||
| interface InvocationContextV1 { | ||
| version: 1; | ||
| ingress: InvocationIngress; | ||
| sessionId: string; | ||
| promptId: string; | ||
| originatorClientId?: string; | ||
| } | ||
| ``` | ||
|
|
||
| The parser accepts only this shape. Version, ingress, and IDs are not coerced; | ||
| unknown properties and blank IDs are rejected. `promptId` is an opaque root | ||
| correlation ID. It does not replace the durable prompt ID used by history, | ||
| rewind, or file snapshots. | ||
|
|
||
| The reserved metadata keys are: | ||
|
|
||
| | Key | Use | | ||
| | ------------------------------------- | ------------------------------------------------------------------------- | | ||
| | `qwen-code/invocation` | Complete v1 context on a trusted ACP request or MCP tool request | | ||
| | `qwen-code/invocation-ingress` | Advisory ingress supplied to the daemon or a trusted standalone ACP child | | ||
| | `qwen-code/private-parent-capability` | Per-spawn capability in the first ACP initialize request | | ||
|
|
||
| ## Runtime propagation | ||
|
|
||
| Core owns a dedicated `AsyncLocalStorage<InvocationContextV1>`. Root entry | ||
| points call `runWithInvocationContext`; normal in-process async work and | ||
| subagents inherit the store. Deferred approval callbacks restore the snapshot | ||
| captured with the request. Timer-driven and persisted work must not rely on | ||
| implicit inheritance: cron, notifications, and background resume each create a | ||
| new root context and omit `originatorClientId`. | ||
|
|
||
| The sources are fixed: | ||
|
|
||
| | Entry point | ingress | promptId | client identity | | ||
| | ------------------------------------------- | -------------- | ----------------------- | ---------------- | | ||
| | CLI | `cli` | current CLI turn ID | omitted | | ||
| | Public ACP | `acp` | child durable prompt ID | omitted | | ||
| | Daemon prompt or public continue | `daemon` | daemon admission UUID | validated client | | ||
| | qwen-serve-mcp | `external_mcp` | daemon admission UUID | validated client | | ||
| | User channel message | `channel` | current root request ID | omitted | | ||
| | Channel loop, cron, unattended webhook | `scheduler` | new execution root ID | omitted | | ||
| | Notification, background resume, classifier | `internal` | new execution root ID | omitted | | ||
|
|
||
| ## Daemon authority | ||
|
|
||
| Daemon prompt metadata is advisory. The daemon first performs its existing | ||
| client attachment and identity validation. It then removes all three reserved | ||
| metadata keys and constructs a complete context from the route session ID, | ||
| daemon admission UUID, normalized ingress, and validated client ID. | ||
|
|
||
| `channel`, `scheduler`, and `internal` deliberately drop the client ID. | ||
| `daemon` and `external_mcp` retain the validated client ID. Missing or unknown | ||
| advisory values normalize to `daemon`. Consequently, forging ingress can only | ||
| change a label or attenuate an already validated identity; it cannot grant an | ||
| identity. | ||
|
|
||
| SDK defaults and per-prompt overrides are stamped after copying caller | ||
| metadata. The precedence is per-prompt override, `DaemonClient` default, then | ||
| `daemon`. Both blocking and non-blocking prompt paths use the same stamping | ||
| logic. | ||
|
|
||
| ## Private ACP capability | ||
|
|
||
| The daemon bridge and standalone channel bridge each create a 32-byte random | ||
| base64url nonce for a private ACP child. The parent forces the nonce into the | ||
| child environment as `QWEN_CODE_PRIVATE_ACP_CAPABILITY` and sends it in the | ||
| first `InitializeRequest._meta` under | ||
| `qwen-code/private-parent-capability`. | ||
|
|
||
| The ACP entry point captures and deletes the environment capability before | ||
| settings parsing, application initialization, or MCP discovery. A regular or | ||
| sandbox relaunch passes it only in the final child's spawn environment and | ||
| never restores it in the parent. The final process passes the captured value | ||
| in memory to the ACP agent. The child decides trust exactly once on the first | ||
| initialize request: | ||
|
|
||
| - no capability in the environment means the connection remains untrusted, | ||
| even if initialize or prompt metadata contains reserved keys; | ||
| - an environment capability with a missing or different initialize value | ||
| fails initialization rather than downgrading silently; | ||
| - the environment value is never visible to MCP servers or later subprocesses; | ||
| later initialize calls cannot upgrade either a trusted, untrusted, or | ||
| rejected connection. | ||
|
|
||
| A trusted daemon parent may send a complete context. A trusted standalone | ||
| channel parent sends only advisory ingress, because the child owns its native | ||
| session and prompt IDs. An untrusted ACP connection strips all reserved prompt | ||
| metadata and creates an `acp` root. | ||
|
|
||
| This prevents request-level spoofing by ordinary or remote ACP peers. It does | ||
| not attempt to defend against a hostile process running as the same OS user | ||
| that deliberately emulates the private launcher; that user already controls | ||
| the child process and local MCP processes. | ||
|
|
||
| ## MCP disclosure policy | ||
|
|
||
| Disclosure is based on transport ownership, not on a user-configurable server | ||
| type or URL. The internal allow bit is set only where Qwen Code constructs a | ||
| `StdioClientTransport`. Streamable HTTP, SSE, WebSocket, SDK/reverse transport, | ||
| and unknown transports deny disclosure, including loopback URLs. | ||
|
|
||
| The policy follows a discovered tool through qualification and trust wrappers. | ||
| Reconnect takes the policy from the newly discovered tool; a missing policy is | ||
| deny. It is used only by the raw `DiscoveredMCPToolInvocation` path. | ||
| Computer Use, A2UI, and callable-tool fallbacks do not inject v1 metadata. | ||
|
|
||
| For an allowed call, Qwen Code merges the context into | ||
| `CallToolRequest.params._meta['qwen-code/invocation']`. Existing request | ||
| metadata such as `progressToken` remains intact. Tool arguments, including an | ||
| `arguments._meta` property generated by a model, cannot overwrite request | ||
| metadata. When disclosure is denied or no runtime context exists, only the | ||
| reserved invocation property is removed. | ||
|
|
||
| ## qwen-serve-mcp bindings | ||
|
|
||
| qwen-serve-mcp keeps one immutable binding record per attached session. The | ||
| record contains the session ID, optional legacy client ID, SSE stream, and its | ||
| single release promise. Changing the default session changes only a pointer; | ||
| other attached sessions stay live. | ||
|
|
||
| Load, resume, and close operations are serialized per known session. Create is | ||
| committed under the returned session ID after the server responds. A binding | ||
| with an active prompt collector cannot be replaced. A successful replacement | ||
| installs the new record before releasing one acquisition of the old record; | ||
| failure preserves the old record. | ||
|
|
||
| Asynchronous stream finalizers and error handlers may remove state only when | ||
| their captured record is still current. Prompt cancellation captures the same | ||
| record as the prompt and does not replay on an invalid client ID. Idle cleanup | ||
| skips active collectors. | ||
|
|
||
| Release performs at most one detach attempt per acquisition. Pending detach | ||
| promises remain tracked after a record leaves the map. Session lifecycle | ||
| operations, including the acquisition phase before create has a session ID, | ||
| are tracked as well. The exported idempotent `dispose()` rejects new lifecycle | ||
| work, waits for in-flight lifecycle work to settle, releases any resulting | ||
| current records, and then waits for all pending releases. | ||
|
|
||
| An active prompt handler is tracked through its abort cancellation and | ||
| collector cleanup. Shutdown drains that handler before releasing its binding, | ||
| so detach cannot overtake cancellation and leave the daemon turn running. | ||
|
|
||
| For an accepted asynchronous daemon prompt, `DaemonClient` owns cancellation | ||
| until prompt admission has settled and awaits that cancel before returning an | ||
| abort. The bridge owns cancellation only after admission has settled while it | ||
| is still collecting events. This single-owner boundary prevents a delayed | ||
| session-level cancel from reaching the next turn. MCP close, SIGINT, SIGTERM, | ||
| and stdin close share one shutdown promise. | ||
|
|
||
| ## Compatibility and exclusions | ||
|
|
||
| There is no feature flag or persisted migration. Older MCP servers ignore the | ||
| namespaced request metadata, and older daemons continue to use the anonymous | ||
| client path. v1 does not change opencode, remote MCP transports, Computer Use, | ||
| A2UI, or the durable prompt history schema. | ||
|
|
||
| Normative examples, including rejected payloads, are in | ||
| `v1-conformance.json`. |
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,153 @@ | ||
| { | ||
| "schemaVersion": 1, | ||
| "metaKeys": { | ||
| "invocation": "qwen-code/invocation", | ||
| "ingress": "qwen-code/invocation-ingress", | ||
| "privateParentCapability": "qwen-code/private-parent-capability" | ||
| }, | ||
| "contextCases": [ | ||
| { | ||
| "name": "valid-with-originator", | ||
| "valid": true, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "external_mcp", | ||
| "sessionId": "session-1", | ||
| "promptId": "prompt-1", | ||
| "originatorClientId": "client-1" | ||
| } | ||
| }, | ||
| { | ||
| "name": "valid-without-originator", | ||
| "valid": true, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "cli", | ||
| "sessionId": "session-2", | ||
| "promptId": "prompt-2" | ||
| } | ||
| }, | ||
| { | ||
| "name": "missing-context", | ||
| "valid": false | ||
| }, | ||
| { | ||
| "name": "missing-session-id", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "cli", | ||
| "promptId": "prompt-missing-session" | ||
| } | ||
| }, | ||
| { | ||
| "name": "missing-prompt-id", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "cli", | ||
| "sessionId": "session-missing-prompt" | ||
| } | ||
| }, | ||
| { | ||
| "name": "string-version", | ||
| "valid": false, | ||
| "input": { | ||
| "version": "1", | ||
| "ingress": "cli", | ||
| "sessionId": "session-string-version", | ||
| "promptId": "prompt-string-version" | ||
| } | ||
| }, | ||
| { | ||
| "name": "future-version", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 2, | ||
| "ingress": "cli", | ||
| "sessionId": "session-3", | ||
| "promptId": "prompt-3" | ||
| } | ||
| }, | ||
| { | ||
| "name": "unknown-ingress", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "webhook", | ||
| "sessionId": "session-4", | ||
| "promptId": "prompt-4" | ||
| } | ||
| }, | ||
| { | ||
| "name": "blank-session-id", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "daemon", | ||
| "sessionId": " ", | ||
| "promptId": "prompt-5" | ||
| } | ||
| }, | ||
| { | ||
| "name": "empty-prompt-id", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "daemon", | ||
| "sessionId": "session-6", | ||
| "promptId": "" | ||
| } | ||
| }, | ||
| { | ||
| "name": "blank-originator", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "daemon", | ||
| "sessionId": "session-7", | ||
| "promptId": "prompt-7", | ||
| "originatorClientId": " " | ||
| } | ||
| }, | ||
| { | ||
| "name": "extra-field", | ||
| "valid": false, | ||
| "input": { | ||
| "version": 1, | ||
| "ingress": "daemon", | ||
| "sessionId": "session-8", | ||
| "promptId": "prompt-8", | ||
| "trusted": true | ||
| } | ||
| } | ||
| ], | ||
| "metadataCases": [ | ||
| { | ||
| "name": "legacy-metadata-coexists", | ||
| "existing": { | ||
| "progressToken": "progress-1", | ||
| "vendor.example/trace": "legacy-trace" | ||
| }, | ||
| "expectPreserved": ["progressToken", "vendor.example/trace"], | ||
| "expectInvocation": true | ||
| }, | ||
| { | ||
| "name": "denied-transport-removes-only-reserved-key", | ||
| "existing": { | ||
| "progressToken": "progress-2", | ||
| "qwen-code/invocation": { | ||
| "forged": true | ||
| } | ||
| }, | ||
| "expectPreserved": ["progressToken"], | ||
| "expectInvocation": false | ||
| } | ||
| ], | ||
| "consumerBehavior": { | ||
| "missing": "omit-reserved-metadata", | ||
| "malformedTrustedPrivateAcp": "fail-prompt", | ||
| "malformedUntrustedAcp": "strip-and-create-acp-root", | ||
| "unknownDaemonIngress": "normalize-to-daemon" | ||
| } | ||
| } | ||
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.