mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): honor --bare and --safe-mode in the cross-session messaging gate (#9845)
* feat(core): accept cross-session messages behind an inbound gate Step two of QwenLM/qwen-code#8724, rebuilt on current main now that the registry from step one has landed. A session can be reached by another session on the same machine, and every message that arrives is gated before the model can act on it. Off by default behind `agents.crossSessionMessaging`. Transport is one UNIX domain socket per session, NDJSON over the wire, one frame per line. The socket directory is 0700 and the socket 0600, and that is the whole access-control story: Node cannot read SO_PEERCRED without a native addon, so a frame's claimed `from` is not authenticated. Everything downstream assumes that. The gate is why the transport and the policy land together. With an explicit `agents.crossSessionInbound` the user decides; unset, the policy follows approval-mode parity — a message auto-delivers only when acting on it cannot do more than the sender could already have done itself. Anything unreadable holds. Held messages are settled rather than stranded: the buffer is bounded, shutdown expires the rest, and every terminal outcome goes back to the sender as a control frame. Content reaches the model inside a <cross_session_message> envelope with the delimiter defanged in the body, so a peer cannot close the envelope early and forge one attributed to the user. The envelope carries a fixed notice that a peer holds none of the user's authority, and the auto-mode classifier gains the matching rule. `/peers` lists and releases held messages; without it, holding would be indistinguishable from dropping. Landed only after four independent reviews, whose non-obvious findings are worth naming because they were all live defects: - A receiver in AUTO_EDIT auto-accepted peer messages and nothing reviewed what they caused. The old rationale — "every consequential action still faces its own gate" — is true of AUTO but false of AUTO_EDIT, where edit confirmations are approved outright and the classifier does not run. A peer could get a file written with no prompt, no classifier and no user. Receiver policy now turns on whether the mode reviews actions at all, not on whether it is YOLO. - `server.unref()` does not cover accepted connections, so any peer that connected and lingered pinned the session open forever. - `fs.mkdir(recursive)` and `fs.chmod` both follow a symlink, so another user could pre-create the world-writable `/tmp` fallback directory and redirect our 0700 chmod onto a directory of ours. - A full listen backlog surfaces as EAGAIN on Linux, not EBUSY; the busy case was being read as dead on the primary platform. - The envelope was escapable without markup: `escapeAttribute` handled `&<>"` but not newlines, so a crafted `fromName` could emit free-standing lines inside the opening tag. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(i18n): translate the /peers description in strict-parity locales `Test (ubuntu-latest, Node 22.x)` has failed every run on this branch with FAIL src/i18n/mustTranslateKeys.test.ts > does not fall back to English for any built-in command description in strict-parity locale { code: 'zh-TW' | 'zh' } AssertionError: expected [ 'peers' ] to deeply equal [] `peersCommand` set `description` to a literal English string rather than routing it through `t()`, so both strict-parity Chinese locales fell back to English and the parity test — which exists to catch exactly this — reported the `peers` path. Four merges of current main were attempted against this check. None could fix it: the untranslated description is introduced by this branch, so no state of main contains the missing keys. Use the `get description() { return t(...) }` form every other built-in command uses, and add the key to `en.js`, `zh.js` and `zh-TW.js`. All three locale files remain in sync at 1817 keys. Verified both directions in a clean worktree install: with the change, `mustTranslateKeys.test.ts` is 20/20; stashing it reproduces the CI failure exactly, `expected [ 'peers' ] to deeply equal []`, twice. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VgTjRF91xANQh6SY9YGyCf * fix(ipc): close Critical review findings in the cross-session inbox - Dedup held msgIds case-insensitively, matching /peers resolution - Print unique dash-stripped handles and resolve them in /peers - Bound outbound sends: in-flight cap plus an absolute deadline (socket.setTimeout is an idle timer a dribbling peer resets) - Defang whitespace-split and quote-glued envelope delimiter tokens - Seed onHeldChange subscribers with the current held snapshot - Skip POSIX-only socket-path tests on win32 like the sibling suites * fix(cli): gate held-message announcements on the held set growing Once the hold buffer is full, every further peer frame evicts the oldest entry while arriving under a fresh id; announcing each of those appends a history item and re-renders per frame — reintroducing one layer up the unbounded growth the hold buffer's ceiling exists to prevent. * fix(ipc): close round-6 Critical findings in the cross-session inbox * fix(ipc): close round-7 Critical findings in the cross-session inbox - defang envelope delimiters split by render-invisible separators the \s class misses, sharing flattenPeerLabel's strip class so the two cannot drift (R5-1) - restore admission-failed queue submissions deferred until idle, the same recovery the /btw path uses, so a restored peer envelope cannot leak raw into the racing turn's mid-turn steer drain (R7-1) - cap the accepted-message backlog symmetric to the held cap: the pre-wiring buffer and the post-wiring queue refuse once full, with an honest 'expired' receipt instead of unbounded socket-speed growth (R7-11) * fix(ipc): close round-8 Critical findings in the cross-session inbox - Refuse any msgId canonicalizing to `all` at the wire boundary and fold the /peers bulk keyword case, so a peer-chosen id can never alias the bulk action or trap a per-message decision (R8-1, R1-11, R1-27). - Re-hold approved messages whose delivery fails instead of silently dropping them: decide() returns 'failed' and parks the entry back at its position, reevaluate() re-holds failed releases, and /peers reports the failure honestly (R8-2, R1-18). - Drain accepted peer envelopes on a preprocessing-free path: the queue marks them peer, the drain submits them with the Teammate send type (which returns before slash/shell/@ handling), renders the one-line projection, and restores failed admissions as peer entries (R8-3). - Close the envelope-defang class structurally: escape every `<` in peer content so no forged delimiter survives regardless of wedged invisibles or homoglyph spellings; the open-enumeration regex is deleted (R5-1). Every guard is witnessed by new focused tests and verified with mutation probes (each probe fails its witnesses when removed, passes restored). * fix(ipc): close peer messaging lifecycle races * feat(core): address local peer sessions from agent tools * fix(core): make peer addresses unambiguous * fix(core): reserve peer routing namespace * fix(ipc): complete peer delivery feedback * fix(ipc): harden peer session routing * fix(cli): honor effective peer messaging gate * fix(cli): report effective peer messaging state * test(cli): teach the cross-session test doubles the new gate accessor The interactive UI gates the peer inbox on `config.isCrossSessionMessagingEnabled()` instead of the merged `agents.crossSessionMessaging` setting, so every config double that exercises `startInteractiveUI` must expose that accessor. The shared `sessionRegistryConfigStub` in `llm.test.tsx` builds most of those doubles, and the four `startInteractiveUI.test.tsx` cases that assert the inbox binds need the enabled config rather than the default. Fixes the 37 failing cli tests (`Tests 37 failed | 28548 passed`) in the `Test (ubuntu-latest, Node 22.x)` lane; both files now pass 101/101. * refactor(core): drop peer-receipts, superseded by peer-send on main The send-side receipt correlation this module implemented shipped on main as packages/core/src/ipc/peer-send.ts (#10158, step 3 of #8724), in a strictly richer form: it canonicalizes message ids, models seven delivery statuses with explicit legal-transition sets, bounds tracked sends at MAX_TRACKED_SENDS, and reports the settled receipt with its age. This copy had no production import site -- only its own test imported it -- so keeping it could only let it drift from the shipped version. Removing both files leaves the branch as just the cross-session messaging gate change it now describes. No behavior change. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): gate every peer-inbox reader on --bare and --safe-mode The only reader was isCrossSessionMessagingEnabled(settings.merged), which cannot see the two session-level suppressions. A session started with --bare or --safe-mode bound a UDS inbox and published its ipcPath into the live-session registry, advertising itself as addressable from the modes that exist to close cross-process surfaces. On the daemon path it was worse than visible. sendToPeer reports `disabled` only when the caller's own record lacks an ipcPath, so publishing one is also what let a suppressed hosted session send -- while inbound was already refused. An interactive TUI under the same flags bound nothing and sent nothing; a hosted session did both. Answer the question once, in the file whose doc comment already claims to be the one place it is answered: crossSessionMessagingSuppression(config) reads the two accessors Config already carries, and isCrossSessionMessagingActive(settings, config) narrows the setting with it. All four readers now ask that -- the interactive bind, the interactive failure banner, /peers, and registerHostedSession. /peers also stops hardcoding "--bare or --safe-mode" and names the cause that actually fired, with the environment channel that reaches each, so a session suppressed only by QWEN_CODE_SAFE_MODE recognises why. Drops the ConfigParameters field and accessor an earlier shape of this branch added. Config already stores both flags and loadCliConfig already passes them in, so the parameter duplicated two booleans that were on the object handed to every read site. Its fail-closed default was also what made the hosted reader look unsafe to wire, which is why that reader was deferred rather than fixed; reading the accessors removes both the parameter and the reason. Each new case was mutation-verified and each mutation reverted: ignoring config at the ACP gate, at the interactive bind, or at the banner branch turns the corresponding cases red; deleting the /peers message arm turns both of its cases red; dropping the getBareMode clause turns the bare-mode cases red in three suites. Refs #8724 Co-authored-by: Qwen-Coder <[email protected]> * docs: record that --bare and --safe-mode suppress cross-session messaging Five places stated the settings-only rule as a contract and would be wrong after the gate change: the agents.crossSessionMessaging row, the two --safe-mode inventories, the hosted-session registration paragraph, and the SDK's inbox note. The row now carries the same "Ignored in --bare and --safe-mode" clause its sibling general.outputStyle already has, and both inventories list cross-session messaging alongside the other surfaces safe mode closes. The dated design record docs/design/2026-09-02-peer-inbox-reachability.md is left as written -- it describes the state at its own date, and rewriting it would falsify the record rather than update it. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): name both causes when a flag and the setting both turn messaging off `/peers` answered with the startup suppression whenever one was in force, so a session whose own `agents.crossSessionMessaging` was also off was told the setting cannot matter, and was sent to a restart that leaves messaging off with the surviving cause still unnamed. The scope remedy now leads and the flag follows as a second cause when both fire; the single-cause text is byte-identical to before. Also qualify the SDK's universal reachability claim for the two flags in the three copies that carry it (package README, the `endpoint.ts` file JSDoc and the hand-maintained `docs/developers/sdk-typescript.md` mirror, whose inbox bullet was still the pre-PR twin of the one this branch amended), and record at the gate that an *inherited* `QWEN_CODE_SAFE_MODE` — unlike `QWEN_CODE_SIMPLE`, which `spawnChannel.ts` scrubs from spawned `qwen --acp` children — suppresses every session a daemon or editor hosts. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmujyzhfka1 --------- Co-authored-by: qqqys <[email protected]> Co-authored-by: Claude Opus 5 (1M context) <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: yiliang114 <[email protected]>
This commit is contained in:
16 files changed
+527
-75
No files matched your search
@@ -196,10 +196,11 @@ Startup selection does not save shared defaults or impose a lifetime pin. Defini
|
||||
`@qwen-code/sdk/peer` lets a program that is not a Qwen Code session join the
|
||||
sessions running as the same user on the same machine — a voice front-end, a
|
||||
relay, a build watcher. The program shows up in `qwen sessions ps`, and in the
|
||||
`list_agents` of every session that has `agents.crossSessionMessaging` turned
|
||||
on — which is also what lets those sessions message it by name with
|
||||
`send_message`. It can message them back. It runs on Node only and needs
|
||||
nothing beyond Node itself.
|
||||
`list_agents` of every session that takes part in cross-session messaging — its
|
||||
`agents.crossSessionMessaging` setting is on, and it was not started with
|
||||
`--bare` or `--safe-mode` — which is also what lets those sessions message it
|
||||
by name with `send_message`. It can message them back. It runs on Node only and
|
||||
needs nothing beyond Node itself.
|
||||
|
||||
```typescript
|
||||
import { PeerEndpoint } from '@qwen-code/sdk/peer';
|
||||
@@ -245,9 +246,11 @@ await endpoint.send({
|
||||
Things to know:
|
||||
|
||||
- A session has an inbox only while its `agents.crossSessionMessaging` setting
|
||||
is on, which it is by default. A session that turned it off does not appear
|
||||
in `list()`, and its own `list_agents` and `send_message` cannot see or
|
||||
reach the program either. `qwen sessions ps` lists the program regardless.
|
||||
is on, which it is by default, and it was not started with `--bare` or
|
||||
`--safe-mode` — those turn messaging off whatever the setting says. A session
|
||||
that turned it off does not appear in `list()`, and its own `list_agents` and
|
||||
`send_message` cannot see or reach the program either. `qwen sessions ps`
|
||||
lists the program regardless.
|
||||
- A message is delivered without review in exactly two cases: the send
|
||||
presents a controller token (`controller: true`), or its `fromMode` names the
|
||||
receiving session's own review class. `fromMode` is a claim nothing
|
||||
|
||||
@@ -428,13 +428,13 @@ See [Memory](../features/memory) for details on how auto-memory works and how to
|
||||
|
||||
#### agents
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
|
||||
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
|
||||
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |
|
||||
| `agents.crossSessionMessaging` | boolean | Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of `agents.crossSessionInbound`, and its model can address them from `send_message`. Two senders are delivered without review unless `agents.crossSessionInbound` is `hold` or `refuse`: processes this session starts, which inherit its child token, and a same-user process that claims this session's own review class, which nothing authenticates. Set to `false` to keep this session invisible and unreachable. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen an operator-set `false`. | `true` |
|
||||
| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` |
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
|
||||
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
|
||||
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |
|
||||
| `agents.crossSessionMessaging` | boolean | Let Qwen Code sessions on this machine send each other messages over a per-session local socket. On by default: this session is discoverable by the others, takes peer messages under the review rules of `agents.crossSessionInbound`, and its model can address them from `send_message`. Two senders are delivered without review unless `agents.crossSessionInbound` is `hold` or `refuse`: processes this session starts, which inherit its child token, and a same-user process that claims this session's own review class, which nothing authenticates. Set to `false` to keep this session invisible and unreachable. Requires restart. Ignored in `--bare` and `--safe-mode`, which keep this session out of messaging whatever it says. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen an operator-set `false`. | `true` |
|
||||
| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` |
|
||||
|
||||
#### permissions
|
||||
|
||||
|
||||
@@ -1070,9 +1070,10 @@ waiting out an expiry. Where a held message should surface for those
|
||||
sessions is not settled yet.
|
||||
|
||||
A session registers unless its own settings turn
|
||||
`agents.crossSessionMessaging` off. Turned off, it stays invisible,
|
||||
because the only reason to list a session nobody can message would be to
|
||||
advertise an address that never answers.
|
||||
`agents.crossSessionMessaging` off, or it was started with `--bare` or
|
||||
`--safe-mode` — those turn messaging off whatever the setting says. Turned
|
||||
off, it stays invisible, because the only reason to list a session nobody
|
||||
can message would be to advertise an address that never answers.
|
||||
|
||||
### Programs that are not Qwen Code sessions
|
||||
|
||||
|
||||
@@ -278,26 +278,26 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
|
||||
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--system-prompt` | Override the main session system prompt for this run | `qwen -p "query" --system-prompt "You are a terse reviewer."` |
|
||||
| `--append-system-prompt` | Append extra instructions to the main session system prompt for this run | `qwen -p "query" --append-system-prompt "Focus on concrete findings."` |
|
||||
| `--output-style` | Output style for this run (`Concise`, `Proactive`, `Explanatory`, `Learning`, a custom style's name, or `default` for none); overrides `general.outputStyle` | `qwen -p "query" --output-style Concise` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--safe-mode` | Disable all customizations — context files, hooks, extensions, skills, MCP servers, custom subagents (only built-in subagents load), permission rules, settings-sourced approval mode overrides, memory features, and sandbox settings — to isolate problems; the CLI flags `--yolo` and `--approval-mode` still take effect. See [Troubleshooting](../support/troubleshooting). Also settable via `QWEN_CODE_SAFE_MODE=true`. | `qwen -p "query" --safe-mode` |
|
||||
| `--model`, `-m` | Model to use for this run | `qwen -p "query" --model qwen3-coder-plus` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode (`plan`, `default`, `auto-edit`, `auto`, `yolo`) | `qwen -p "query" --approval-mode auto-edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
| `--max-session-turns` | Cap the number of user/model/tool turns in the run | `qwen -p "..." --max-session-turns 30` |
|
||||
| `--max-wall-time` | Wall-clock budget; accepts `90` (s), `30s`, `5m`, `1h`, `1.5h` | `qwen -p "..." --max-wall-time 10m` |
|
||||
| `--max-tool-calls` | Cumulative tool-call budget for the run | `qwen -p "..." --max-tool-calls 50` |
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--system-prompt` | Override the main session system prompt for this run | `qwen -p "query" --system-prompt "You are a terse reviewer."` |
|
||||
| `--append-system-prompt` | Append extra instructions to the main session system prompt for this run | `qwen -p "query" --append-system-prompt "Focus on concrete findings."` |
|
||||
| `--output-style` | Output style for this run (`Concise`, `Proactive`, `Explanatory`, `Learning`, a custom style's name, or `default` for none); overrides `general.outputStyle` | `qwen -p "query" --output-style Concise` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--safe-mode` | Disable all customizations — context files, hooks, extensions, skills, MCP servers, custom subagents (only built-in subagents load), permission rules, settings-sourced approval mode overrides, memory features, cross-session messaging, and sandbox settings — to isolate problems; the CLI flags `--yolo` and `--approval-mode` still take effect. See [Troubleshooting](../support/troubleshooting). Also settable via `QWEN_CODE_SAFE_MODE=true`. | `qwen -p "query" --safe-mode` |
|
||||
| `--model`, `-m` | Model to use for this run | `qwen -p "query" --model qwen3-coder-plus` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode (`plan`, `default`, `auto-edit`, `auto`, `yolo`) | `qwen -p "query" --approval-mode auto-edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
| `--max-session-turns` | Cap the number of user/model/tool turns in the run | `qwen -p "..." --max-session-turns 30` |
|
||||
| `--max-wall-time` | Wall-clock budget; accepts `90` (s), `30s`, `5m`, `1h`, `1.5h` | `qwen -p "..." --max-wall-time 10m` |
|
||||
| `--max-tool-calls` | Cumulative tool-call budget for the run | `qwen -p "..." --max-tool-calls 50` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
||||
|
||||
|
||||
@@ -56,9 +56,10 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (e.g., Alibaba Cloud Model Studio API key or Google Cloud Vertex AI). You can still view your total token usage using the `/stats` command.
|
||||
|
||||
- **Q: A customization (extension, hook, skill, MCP server, or subagent) seems to be breaking Qwen Code. How do I isolate it?**
|
||||
- A: Start Qwen Code with the `--safe-mode` flag to disable all customizations — context files, hooks, extensions, skills, MCP servers, custom subagents (only built-in subagents load), permission rules, settings-sourced approval mode overrides, memory features, and sandbox settings — for the session. Note: the CLI flags `--yolo` and `--approval-mode` still take effect in safe mode. If the problem disappears in safe mode, re-enable your customizations one at a time to find the culprit.
|
||||
- A: Start Qwen Code with the `--safe-mode` flag to disable all customizations — context files, hooks, extensions, skills, MCP servers, custom subagents (only built-in subagents load), permission rules, settings-sourced approval mode overrides, memory features, cross-session messaging, and sandbox settings — for the session. Note: the CLI flags `--yolo` and `--approval-mode` still take effect in safe mode. If the problem disappears in safe mode, re-enable your customizations one at a time to find the culprit.
|
||||
- Example: `qwen --safe-mode`
|
||||
- Alternative: set the environment variable `QWEN_CODE_SAFE_MODE=true` if the CLI cannot accept flags.
|
||||
- Note: unlike the flag, an exported `QWEN_CODE_SAFE_MODE` is inherited by child processes. A `qwen serve` daemon or an editor started from that shell hosts every one of its sessions in safe mode, so those sessions are suppressed too: they write no session record, and other sessions can neither list nor message them. Unset the variable for the daemon rather than leaving it exported in the shell that starts it.
|
||||
- Note: "MCP servers" here means servers configured in `settings.json` / project `.mcp.json` — local, ambient state that safe mode is meant to isolate against. MCP servers you explicitly supply for the current invocation (an embedding ACP client's `session/new` `mcpServers`, or `--mcp-config`) are not local/ambient state and are still honored under safe mode.
|
||||
|
||||
## Common error messages and solutions
|
||||
|
||||
@@ -5026,6 +5026,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
hasHooksForEvent: vi.fn().mockReturnValue(false),
|
||||
isWorkflowsEnabled: vi.fn().mockReturnValue(false),
|
||||
setWorkflowsEnabled: vi.fn(),
|
||||
isSafeMode: vi.fn().mockReturnValue(false),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
@@ -5540,6 +5541,35 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
// Both suppressions, on the path that hosts daemon sessions. The setting
|
||||
// is on here, so this is the case the setting reader alone cannot see:
|
||||
// an interactive TUI under the same flag binds nothing, while a hosted
|
||||
// session that bound an inbox would publish its `ipcPath` — and having
|
||||
// one is what makes a session addressable to every peer and allowed to
|
||||
// send. Varying only the setting (the cases above) cannot tell the two
|
||||
// gates apart.
|
||||
it.each([
|
||||
['safe mode', 'isSafeMode'],
|
||||
['bare mode', 'getBareMode'],
|
||||
] as const)(
|
||||
'registers nothing when the hosted session runs in %s',
|
||||
async (_label, flag) => {
|
||||
const innerConfig = await setupSessionMocks(`hosted-${flag}`);
|
||||
innerConfig[flag].mockReturnValue(true);
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingOn());
|
||||
const { agent, agentPromise } =
|
||||
await bootInitializedAcpAgent(messagingOn());
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
|
||||
expect(mockPeerMessagingStart).not.toHaveBeenCalled();
|
||||
expect(mockRegisterSession).not.toHaveBeenCalled();
|
||||
expect(innerConfig.updateSessionRegistryIpcPath).not.toHaveBeenCalled();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
},
|
||||
);
|
||||
|
||||
it("removes a session's record when the session goes", async () => {
|
||||
const innerConfig = await setupSessionMocks('hosted-gone');
|
||||
vi.mocked(loadSettings).mockReturnValue(messagingOn());
|
||||
|
||||
@@ -362,7 +362,7 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { QWEN_CODE_SERVE_ENV } from '../config/acp-channel-fallback.js';
|
||||
import { isSshWorkspaceExtMethodAllowed } from './ssh-workspace-guards.js';
|
||||
import { PeerMessaging } from '../peerMessaging/peer-messaging.js';
|
||||
import { isCrossSessionMessagingEnabled } from '../peerMessaging/enabled.js';
|
||||
import { isCrossSessionMessagingActive } from '../peerMessaging/enabled.js';
|
||||
import { startNonInteractiveOpenAILogHousekeeping } from '../services/housekeeping/scheduler.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import {
|
||||
@@ -5009,7 +5009,7 @@ class QwenAgent implements Agent {
|
||||
/**
|
||||
* Give a newly published session a registry record of its own.
|
||||
*
|
||||
* Only when that session's own settings turn messaging on. A record
|
||||
* Only when that session itself takes part in messaging. A record
|
||||
* with no inbox behind it would put a name in every peer's listing
|
||||
* that can be addressed and never answered, which is worse than not
|
||||
* appearing at all — the interactive UI registers unconditionally
|
||||
@@ -5021,11 +5021,15 @@ class QwenAgent implements Agent {
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): void {
|
||||
// Each session's own settings decide: one process can host sessions
|
||||
// from more than one workspace, and a record exists to be addressed,
|
||||
// so it is written only when that session's settings turn messaging
|
||||
// on. The process's startup settings answer for nobody else.
|
||||
if (!isCrossSessionMessagingEnabled(settings.merged)) return;
|
||||
// Each session decides for itself: one process can host sessions from
|
||||
// more than one workspace, and a record exists to be addressed, so it
|
||||
// is written only when that session takes part. Read off this session's
|
||||
// own `config`, never a process-startup one. The suppressions count
|
||||
// here exactly as they do in the interactive UI: without them a hosted
|
||||
// safe-mode or bare session binds an inbox and publishes its `ipcPath`,
|
||||
// advertising itself as reachable from the mode that exists to close
|
||||
// that surface — and, having an `ipcPath`, is also allowed to send.
|
||||
if (!isCrossSessionMessagingActive(settings.merged, config)) return;
|
||||
// Bound by the first session that needs it rather than at startup: an
|
||||
// ACP process with no session has nothing to advertise and nobody to
|
||||
// receive for, and this is also the first moment the agent exists.
|
||||
|
||||
@@ -36,6 +36,10 @@ import type { Settings } from './settings.js';
|
||||
import * as ServerConfig from '@qwen-code/qwen-code-core';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resetMcpApprovalsForTesting } from './mcpApprovals.js';
|
||||
import {
|
||||
isCrossSessionMessagingActive,
|
||||
isCrossSessionMessagingEnabled,
|
||||
} from '../peerMessaging/enabled.js';
|
||||
|
||||
const sshWorkspaceProbe = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../serve/ssh-workspace-store.js', () => ({
|
||||
@@ -1930,6 +1934,54 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getAgentsSettings().maxParallelAgents).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps cross-session messaging on when no suppression is in force', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
// An unset key is on, so the off case has to say `false` explicitly.
|
||||
const unset = await loadCliConfig({}, argv);
|
||||
const enabled = await loadCliConfig(
|
||||
{ agents: { crossSessionMessaging: true } },
|
||||
argv,
|
||||
);
|
||||
const disabled = await loadCliConfig(
|
||||
{ agents: { crossSessionMessaging: false } },
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(isCrossSessionMessagingActive({}, unset)).toBe(true);
|
||||
expect(
|
||||
isCrossSessionMessagingActive(
|
||||
{ agents: { crossSessionMessaging: true } },
|
||||
enabled,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossSessionMessagingActive(
|
||||
{ agents: { crossSessionMessaging: false } },
|
||||
disabled,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Both suppressions rather than one: a gate that answered only for
|
||||
// `--safe-mode` would leave `--bare` binding an inbox and publishing its
|
||||
// socket path, in the mode documented as skipping implicit startup work.
|
||||
it.each(['--bare', '--safe-mode'])(
|
||||
'suppresses cross-session messaging in %s mode whatever the setting says',
|
||||
async (flag) => {
|
||||
process.argv = ['node', 'script.js', flag];
|
||||
const argv = await parseArguments();
|
||||
const settings = { agents: { crossSessionMessaging: true } };
|
||||
|
||||
const config = await loadCliConfig(settings, argv);
|
||||
|
||||
// The setting still says on: what turns messaging off is the session,
|
||||
// which only the Config the flag already reaches can see.
|
||||
expect(isCrossSessionMessagingEnabled(settings)).toBe(true);
|
||||
expect(isCrossSessionMessagingActive(settings, config)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes agents.maxParallelAgentsByModel from settings to core config', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
crossSessionMessagingOffScope,
|
||||
crossSessionMessagingSuppression,
|
||||
isCrossSessionMessagingActive,
|
||||
isCrossSessionMessagingEnabled,
|
||||
isCrossSessionMessagingOptedIn,
|
||||
} from './enabled.js';
|
||||
@@ -50,6 +52,67 @@ describe('isCrossSessionMessagingEnabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('crossSessionMessagingSuppression', () => {
|
||||
it('names the session-level suppression in force', () => {
|
||||
expect(crossSessionMessagingSuppression({ isSafeMode: () => true })).toBe(
|
||||
'safe-mode',
|
||||
);
|
||||
expect(crossSessionMessagingSuppression({ getBareMode: () => true })).toBe(
|
||||
'bare',
|
||||
);
|
||||
// Both on: safe mode is the stronger claim about what this session may
|
||||
// touch, so it is the more useful one to name.
|
||||
expect(
|
||||
crossSessionMessagingSuppression({
|
||||
isSafeMode: () => true,
|
||||
getBareMode: () => true,
|
||||
}),
|
||||
).toBe('safe-mode');
|
||||
});
|
||||
|
||||
it('is undefined when neither flag is set, or no config can answer', () => {
|
||||
expect(
|
||||
crossSessionMessagingSuppression({
|
||||
isSafeMode: () => false,
|
||||
getBareMode: () => false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
// `CommandContext['services']['config']` is `Config | null`, and a test
|
||||
// double may model neither flag. Neither absence is a suppression: they
|
||||
// are what a reader with no runtime to consult sees.
|
||||
expect(crossSessionMessagingSuppression(null)).toBeUndefined();
|
||||
expect(crossSessionMessagingSuppression(undefined)).toBeUndefined();
|
||||
expect(crossSessionMessagingSuppression({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCrossSessionMessagingActive', () => {
|
||||
const on = { agents: { crossSessionMessaging: true } };
|
||||
const off = { agents: { crossSessionMessaging: false } };
|
||||
const unrestricted = { isSafeMode: () => false, getBareMode: () => false };
|
||||
|
||||
it('is on when the setting is on and no suppression is in force', () => {
|
||||
expect(isCrossSessionMessagingActive(on, unrestricted)).toBe(true);
|
||||
// An unset key is on, so it stays on with nothing suppressing it.
|
||||
expect(isCrossSessionMessagingActive({}, unrestricted)).toBe(true);
|
||||
});
|
||||
|
||||
// Each suppression on its own: a gate answering for only one of them
|
||||
// would leave the other binding an inbox and publishing its socket path.
|
||||
it('is off under either suppression whatever the setting says', () => {
|
||||
expect(isCrossSessionMessagingActive(on, { isSafeMode: () => true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCrossSessionMessagingActive(on, { getBareMode: () => true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('is off when the setting is off even with no suppression', () => {
|
||||
expect(isCrossSessionMessagingActive(off, unrestricted)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const file = (crossSessionMessaging: unknown) => ({
|
||||
settings: { agents: { crossSessionMessaging } },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
/**
|
||||
* Whether this session takes part in cross-session messaging.
|
||||
* What the `agents.crossSessionMessaging` setting says — not the whole
|
||||
* answer to whether this session takes part, which is
|
||||
* {@link isCrossSessionMessagingActive}.
|
||||
*
|
||||
* On unless the user turned it off. The schema defaults the setting to
|
||||
* `true`, but merged settings carry only what some scope actually wrote,
|
||||
@@ -16,9 +18,6 @@ import { isDeepStrictEqual } from 'node:util';
|
||||
* boolean fails closed: a value the reader does not recognize must not
|
||||
* open a socket, which is also the rank `WORKSPACE_TIGHTEN_ONLY_SETTINGS`
|
||||
* gives it.
|
||||
*
|
||||
* The one place this question is answered, so the interactive UI, the
|
||||
* ACP agent and `/peers` cannot drift on what "on" means.
|
||||
*/
|
||||
export function isCrossSessionMessagingEnabled(
|
||||
settings: { agents?: { crossSessionMessaging?: unknown } } | undefined,
|
||||
@@ -27,6 +26,73 @@ export function isCrossSessionMessagingEnabled(
|
||||
return value === undefined || value === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two session-level suppressions, read off the `Config` that already
|
||||
* carries them. Both calls are optional because a reader's config may be
|
||||
* absent (`CommandContext['services']['config']` is `Config | null`) or a
|
||||
* test double that models neither flag; a config that cannot answer is
|
||||
* treated as not suppressing, which is what such a reader sees on `main`.
|
||||
*/
|
||||
export interface CrossSessionRuntime {
|
||||
getBareMode?(): boolean;
|
||||
isSafeMode?(): boolean;
|
||||
}
|
||||
|
||||
/** Which startup suppression turned messaging off for this session. */
|
||||
export type CrossSessionMessagingSuppression = 'bare' | 'safe-mode';
|
||||
|
||||
/**
|
||||
* Which session-level suppression is in force, if any.
|
||||
*
|
||||
* A peer inbox is exactly the kind of surface `--safe-mode`
|
||||
* (`QWEN_CODE_SAFE_MODE`) exists to close: a socket another local process
|
||||
* can write to, plus a registry record telling every other session where
|
||||
* to find it. `--bare` (`QWEN_CODE_SIMPLE`) reaches the same verdict for a
|
||||
* different reason — it is the minimal-startup mode, and binding a socket
|
||||
* and writing a record is neither minimal nor free. Neither flag is visible
|
||||
* to the setting, so the setting alone cannot answer the question.
|
||||
*
|
||||
* The two channels are not symmetric across a process boundary, though the
|
||||
* paragraph above reads as if they were: `spawnChannel.ts` scrubs
|
||||
* `QWEN_CODE_SIMPLE` out of the environment of a `qwen --acp` child it
|
||||
* spawns (so a daemon's own bare mode does not leak into the sessions it
|
||||
* hosts), leaves `QWEN_CODE_SAFE_MODE` alone, and nothing in `packages/`
|
||||
* writes that variable. An *inherited* `QWEN_CODE_SAFE_MODE` therefore
|
||||
* suppresses messaging in every session a daemon or editor hosts, and those
|
||||
* sessions register no record at all — silently, because the explanation
|
||||
* `/peers` gives is not reachable from a driven session.
|
||||
*
|
||||
* Safe mode is reported first when both are on: it is the stronger claim
|
||||
* about what this session may touch, and naming it is the more useful
|
||||
* answer.
|
||||
*/
|
||||
export function crossSessionMessagingSuppression(
|
||||
config: CrossSessionRuntime | null | undefined,
|
||||
): CrossSessionMessagingSuppression | undefined {
|
||||
if (config?.isSafeMode?.() === true) return 'safe-mode';
|
||||
if (config?.getBareMode?.() === true) return 'bare';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this session takes part in cross-session messaging: the setting's
|
||||
* own answer, narrowed by the session-level suppressions.
|
||||
*
|
||||
* The one place this question is answered, so the interactive UI, the ACP
|
||||
* agent's hosted-session registration and `/peers` cannot drift on what
|
||||
* "on" means — including on the flags, which the setting reader cannot see
|
||||
* and which are why a session can be off with the setting on.
|
||||
*/
|
||||
export function isCrossSessionMessagingActive(
|
||||
settings: { agents?: { crossSessionMessaging?: unknown } } | undefined,
|
||||
config: CrossSessionRuntime | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
isCrossSessionMessagingEnabled(settings) &&
|
||||
crossSessionMessagingSuppression(config) === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** One settings file, as the scope readers below see it. */
|
||||
interface CrossSessionScopeFile {
|
||||
settings: { agents?: { crossSessionMessaging?: unknown } };
|
||||
|
||||
@@ -135,11 +135,20 @@ function makeContext(
|
||||
peerMessaging: Fake | null,
|
||||
crossSessionMessaging?: unknown,
|
||||
scopes: Record<string, unknown> = {},
|
||||
suppression: { isSafeMode?: boolean; getBareMode?: boolean } = {},
|
||||
): CommandContext {
|
||||
return {
|
||||
services: {
|
||||
peerMessaging,
|
||||
settings: { merged: { agents: { crossSessionMessaging } }, ...scopes },
|
||||
// The gate reads the two session-level suppressions off the Config,
|
||||
// so the double models them rather than stubbing the answer: a case
|
||||
// where the flags and the setting disagree stays expressible, and the
|
||||
// message can be checked against the cause it actually derived.
|
||||
config: {
|
||||
isSafeMode: () => suppression.isSafeMode === true,
|
||||
getBareMode: () => suppression.getBareMode === true,
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
}
|
||||
@@ -457,6 +466,93 @@ describe('/peers', () => {
|
||||
expect(result.content).not.toContain('Remove that entry');
|
||||
});
|
||||
|
||||
// Arm-specific assertions on purpose. Every branch of
|
||||
// `describeMessagingOff` also begins with "Cross-session messaging is
|
||||
// off", so asserting only that shared prefix stays green with the
|
||||
// suppression arm deleted — and `/peers` then tells a safe-mode user
|
||||
// whose setting is already `true` to go remove that entry, a remedy that
|
||||
// changes nothing. Each case also pins the environment channel, since
|
||||
// both suppressions can be reached without the flag.
|
||||
it('names safe mode as the reason instead of a settings remedy', async () => {
|
||||
const result = await peersCommand.action!(
|
||||
makeContext(null, true, {}, { isSafeMode: true }),
|
||||
'',
|
||||
);
|
||||
if (!result || result.type !== 'message') {
|
||||
throw new Error('expected a message result');
|
||||
}
|
||||
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('in safe mode');
|
||||
expect(result.content).toContain('QWEN_CODE_SAFE_MODE');
|
||||
expect(result.content).not.toContain('Remove that entry');
|
||||
expect(result.content).not.toContain('failed to bind');
|
||||
});
|
||||
|
||||
it('names bare mode as the reason instead of a settings remedy', async () => {
|
||||
const result = await peersCommand.action!(
|
||||
makeContext(null, true, {}, { getBareMode: true }),
|
||||
'',
|
||||
);
|
||||
if (!result || result.type !== 'message') {
|
||||
throw new Error('expected a message result');
|
||||
}
|
||||
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('in bare mode');
|
||||
expect(result.content).toContain('QWEN_CODE_SIMPLE');
|
||||
expect(result.content).not.toContain('Remove that entry');
|
||||
expect(result.content).not.toContain('failed to bind');
|
||||
});
|
||||
|
||||
// The both-causes state, which the two cases above cannot express: they
|
||||
// build their context with the setting on. Naming only the flag here tells
|
||||
// a user whose own settings entry is also off that the setting cannot
|
||||
// matter, and sends them to a restart that leaves messaging off.
|
||||
async function runBothCauses(suppression: {
|
||||
isSafeMode?: boolean;
|
||||
getBareMode?: boolean;
|
||||
}): Promise<{ messageType: string; content: string }> {
|
||||
const result = await peersCommand.action!(
|
||||
makeContext(
|
||||
null,
|
||||
false,
|
||||
{ user: { settings: { agents: { crossSessionMessaging: false } } } },
|
||||
suppression,
|
||||
),
|
||||
'',
|
||||
);
|
||||
if (!result || result.type !== 'message') {
|
||||
throw new Error('expected a message result');
|
||||
}
|
||||
return { messageType: result.messageType, content: result.content };
|
||||
}
|
||||
|
||||
it('names the settings remedy and safe mode when both turn messaging off', async () => {
|
||||
const result = await runBothCauses({ isSafeMode: true });
|
||||
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('in safe mode');
|
||||
expect(result.content).toContain('QWEN_CODE_SAFE_MODE');
|
||||
expect(result.content).toContain('Remove that entry');
|
||||
// The single-cause claim is false in this state: the setting is one of
|
||||
// the two reasons messaging is off, so it is not something the flag
|
||||
// alone overrides.
|
||||
expect(result.content).not.toContain('cannot turn it back on');
|
||||
expect(result.content).not.toContain('failed to bind');
|
||||
});
|
||||
|
||||
it('names the settings remedy and bare mode when both turn messaging off', async () => {
|
||||
const result = await runBothCauses({ getBareMode: true });
|
||||
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('in bare mode');
|
||||
expect(result.content).toContain('QWEN_CODE_SIMPLE');
|
||||
expect(result.content).toContain('Remove that entry');
|
||||
expect(result.content).not.toContain('cannot turn it back on');
|
||||
expect(result.content).not.toContain('failed to bind');
|
||||
});
|
||||
|
||||
it('repeats the bind failure and what to change when the inbox could not bind', async () => {
|
||||
inboxFailure.current = {
|
||||
cause: 'foreign_owner',
|
||||
|
||||
@@ -29,6 +29,9 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import {
|
||||
crossSessionMessagingOffScope,
|
||||
type CrossSessionMessagingOffScope,
|
||||
crossSessionMessagingSuppression,
|
||||
type CrossSessionMessagingSuppression,
|
||||
isCrossSessionMessagingActive,
|
||||
isCrossSessionMessagingEnabled,
|
||||
} from '../../peerMessaging/enabled.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
@@ -313,14 +316,33 @@ export const peersCommand: SlashCommand = {
|
||||
// absent when the session failed to register or the socket failed to
|
||||
// bind (path too long, unwritable runtime dir).
|
||||
const settings = context.services.settings;
|
||||
if (!isCrossSessionMessagingEnabled(settings?.merged)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: describeMessagingOff(
|
||||
settings ? crossSessionMessagingOffScope(settings) : undefined,
|
||||
),
|
||||
};
|
||||
const runtime = context.services.config;
|
||||
// The setting is not the whole gate: `--bare` and `--safe-mode` turn
|
||||
// messaging off for the session whatever it says, and the same
|
||||
// predicate is what interactive startup binds the inbox on. Reading
|
||||
// the setting alone would call a deliberately off session "on, no
|
||||
// inbox" and hand it a bind failure to explain.
|
||||
if (!isCrossSessionMessagingActive(settings?.merged, runtime)) {
|
||||
// Name every cause that actually fired instead of guessing: a
|
||||
// suppression is a startup flag no settings edit can undo, while the
|
||||
// setting being off has a scope-shaped remedy. When both are in
|
||||
// force, naming only the flag sends the user to a restart that
|
||||
// leaves messaging off and never names the cause that outlives it.
|
||||
const suppression = crossSessionMessagingSuppression(runtime);
|
||||
const offScope = settings
|
||||
? crossSessionMessagingOffScope(settings)
|
||||
: undefined;
|
||||
const off = describeMessagingOff(offScope);
|
||||
let content: string;
|
||||
if (!suppression) {
|
||||
content = off;
|
||||
} else if (isCrossSessionMessagingEnabled(settings?.merged)) {
|
||||
content = describeMessagingSuppressed(suppression);
|
||||
} else {
|
||||
// The setting leads: it is the cause a settings edit can fix.
|
||||
content = `${off} ${describeSuppressionAlso(suppression)}`;
|
||||
}
|
||||
return { type: 'message', messageType: 'info', content };
|
||||
}
|
||||
const failure = getLastPeerInboxFailure();
|
||||
// A platform with no inbox transport is not a fault in this session,
|
||||
@@ -511,3 +533,53 @@ function describeMessagingOff(
|
||||
return 'Cross-session messaging is off because of the "agents.crossSessionMessaging" setting (only true, or leaving it unset, turns it on). Remove that entry, then restart.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user-visible name of each suppression, with both channels that reach
|
||||
* it: the flag and the environment variable are named together because a
|
||||
* user who only set the latter would otherwise not recognize the cause.
|
||||
*/
|
||||
const SUPPRESSION_PROSE = {
|
||||
'safe-mode': {
|
||||
mode: 'safe mode',
|
||||
channels: '--safe-mode, or QWEN_CODE_SAFE_MODE',
|
||||
effect: 'which closes the surfaces other processes can reach',
|
||||
},
|
||||
bare: {
|
||||
mode: 'bare mode',
|
||||
channels: '--bare, or QWEN_CODE_SIMPLE',
|
||||
effect: 'which skips implicit startup work',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Why a session whose setting is on still has no inbox: a startup flag
|
||||
* closed the surface, and no settings edit can undo it.
|
||||
*
|
||||
* Kept apart from {@link describeMessagingOff} rather than folded into its
|
||||
* scope union: that one answers "which settings file turned it off", and a
|
||||
* startup flag is not a file.
|
||||
*/
|
||||
function describeMessagingSuppressed(
|
||||
suppression: CrossSessionMessagingSuppression,
|
||||
): string {
|
||||
const { mode, channels, effect } = SUPPRESSION_PROSE[suppression];
|
||||
return `Cross-session messaging is off: this session runs in ${mode} (${channels}), ${effect}. The "agents.crossSessionMessaging" setting cannot turn it back on; restart without ${mode}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same suppression as a *second* cause, for a session whose setting is
|
||||
* off too — a follow-on sentence, not another {@link describeMessagingOff}
|
||||
* lead-in.
|
||||
*
|
||||
* Its remedy has to differ from the single-cause one: "restart without the
|
||||
* flag" is precisely the restart that leaves this user with messaging still
|
||||
* off, because their own settings entry is the cause that survives it. So
|
||||
* the sentence says both fixes are needed instead of naming one.
|
||||
*/
|
||||
function describeSuppressionAlso(
|
||||
suppression: CrossSessionMessagingSuppression,
|
||||
): string {
|
||||
const { mode, channels, effect } = SUPPRESSION_PROSE[suppression];
|
||||
return `This session also runs in ${mode} (${channels}), ${effect}, so fixing the setting alone will not bring messaging back: restart without ${mode} as well.`;
|
||||
}
|
||||
@@ -150,7 +150,12 @@ type TestConfig = Config & {
|
||||
updateSessionRegistryIpcPath: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeConfig(): TestConfig {
|
||||
// The gate reads the two session-level suppressions off the Config, so the
|
||||
// double models them. Neither is in force unless a case says so: the
|
||||
// settings argument these suites pass is what the gate otherwise defers to.
|
||||
function makeConfig(
|
||||
suppression: { isSafeMode?: boolean; getBareMode?: boolean } = {},
|
||||
): TestConfig {
|
||||
const trackSessionRegistration = vi.fn((registration: Promise<unknown>) => {
|
||||
void registration.catch(() => undefined);
|
||||
});
|
||||
@@ -161,6 +166,8 @@ function makeConfig(): TestConfig {
|
||||
getChatRecordingService: () => undefined,
|
||||
isTelemetryInitializationDeferred: () => false,
|
||||
getApprovalMode: () => 'default',
|
||||
isSafeMode: () => suppression.isSafeMode === true,
|
||||
getBareMode: () => suppression.getBareMode === true,
|
||||
trackSessionRegistration,
|
||||
whenSessionRegistered: vi.fn().mockResolvedValue(true),
|
||||
updateSessionRegistryIpcPath: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -608,4 +615,51 @@ describe('startInteractiveUI cross-session messaging', () => {
|
||||
|
||||
expect(peerMessagingStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Both suppressions rather than one: a gate answering only for safe mode
|
||||
// would leave `--bare` binding an inbox and publishing its socket path, in
|
||||
// the mode documented as skipping implicit startup work.
|
||||
it.each([
|
||||
['safe mode', { isSafeMode: true }],
|
||||
['bare mode', { getBareMode: true }],
|
||||
])(
|
||||
'does not bind when the session runs in %s whatever the setting says',
|
||||
async (_label, suppression) => {
|
||||
const config = makeConfig(suppression);
|
||||
|
||||
await start(config, enabledSettings);
|
||||
|
||||
expect(config.whenSessionRegistered).not.toHaveBeenCalled();
|
||||
expect(peerMessagingStart).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not explain a bind failure for an inbox a suppressed session never asked for', async () => {
|
||||
// The null a suppressed session publishes is deliberate, not a failure.
|
||||
// Gating this branch on the setting alone would hand the user a cause
|
||||
// that never happened — the same invented-cause defect `/peers` had,
|
||||
// one file over. A leftover failure from an earlier bind is what makes
|
||||
// that reachable rather than theoretical.
|
||||
lastPeerInboxFailure.value = {
|
||||
cause: 'permission',
|
||||
socketPath: '/run/user/1000/qwen-socks/1.sock',
|
||||
detail: 'EACCES',
|
||||
hint: 'Choose a directory you own.',
|
||||
attempts: 3,
|
||||
};
|
||||
const config = makeConfig({ isSafeMode: true });
|
||||
|
||||
await start(config, enabledSettings);
|
||||
await vi.waitFor(() =>
|
||||
expect(config.trackSessionRegistration).toHaveBeenCalled(),
|
||||
);
|
||||
const appTree = inkRender.mock.calls[0]?.[0] as ReactElement;
|
||||
const mounted = renderDom(appTree);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(observedPeerInboxFailure.value).toBeNull();
|
||||
} finally {
|
||||
mounted.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from '../peerMessaging/PeerMessagingContext.js';
|
||||
import { inboundPolicyScope } from '../peerMessaging/inbound-policy-scope.js';
|
||||
import {
|
||||
isCrossSessionMessagingEnabled,
|
||||
isCrossSessionMessagingActive,
|
||||
isCrossSessionMessagingOptedIn,
|
||||
} from '../peerMessaging/enabled.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
@@ -221,11 +221,13 @@ export async function startInteractiveUI(
|
||||
void peerMessagingReady.then((messaging) => {
|
||||
if (!alive) return;
|
||||
setPeerMessaging(messaging);
|
||||
// A null inbox with the feature on is a bind that failed; the
|
||||
// cause is what the user needs, not the null.
|
||||
// A null inbox for a session that takes part is a bind that failed;
|
||||
// the cause is what the user needs, not the null. A session that
|
||||
// does not take part — the setting off, or `--bare`/`--safe-mode` —
|
||||
// publishes null on purpose and has no failure to explain.
|
||||
if (
|
||||
messaging === null &&
|
||||
isCrossSessionMessagingEnabled(settings.merged)
|
||||
isCrossSessionMessagingActive(settings.merged, config)
|
||||
) {
|
||||
const failure = getLastPeerInboxFailure();
|
||||
// A different question from the helper above: not "is messaging
|
||||
@@ -478,7 +480,11 @@ export async function startInteractiveUI(
|
||||
// registry record, and `patchSessionRecord` no-ops when there is no record
|
||||
// yet, so binding any earlier would publish the socket path into nothing.
|
||||
// Not awaited — startup must never block on binding a socket.
|
||||
if (!isCrossSessionMessagingEnabled(settings.merged)) {
|
||||
// Off when the setting says so, or when the session was started with it
|
||||
// suppressed: `--bare` and `--safe-mode` turn messaging off whatever the
|
||||
// setting says, and the same predicate answers for `/peers` and for the
|
||||
// ACP agent's hosted sessions.
|
||||
if (!isCrossSessionMessagingActive(settings.merged, config)) {
|
||||
publishPeerMessaging(null);
|
||||
} else {
|
||||
let exiting = false;
|
||||
|
||||
@@ -206,10 +206,11 @@ entry there.
|
||||
`@qwen-code/sdk/peer` lets a program that is not a Qwen Code session join the
|
||||
sessions running as the same user on the same machine — a voice front-end, a
|
||||
relay, a build watcher. The program shows up in `qwen sessions ps`, and in the
|
||||
`list_agents` of every session that has `agents.crossSessionMessaging` turned
|
||||
on — which is also what lets those sessions message it by name with
|
||||
`send_message`. It can message them back. It runs on Node only and needs
|
||||
nothing beyond Node itself.
|
||||
`list_agents` of every session that takes part in cross-session messaging — its
|
||||
`agents.crossSessionMessaging` setting is on, and it was not started with
|
||||
`--bare` or `--safe-mode` — which is also what lets those sessions message it
|
||||
by name with `send_message`. It can message them back. It runs on Node only and
|
||||
needs nothing beyond Node itself.
|
||||
|
||||
```typescript
|
||||
import { PeerEndpoint } from '@qwen-code/sdk/peer';
|
||||
@@ -255,9 +256,11 @@ await endpoint.send({
|
||||
Things to know:
|
||||
|
||||
- A session has an inbox only while its `agents.crossSessionMessaging` setting
|
||||
is on, which it is by default. A session that turned it off does not appear
|
||||
in `list()`, and its own `list_agents` and `send_message` cannot see or
|
||||
reach the program either. `qwen sessions ps` lists the program regardless.
|
||||
is on, which it is by default, and it was not started with `--bare` or
|
||||
`--safe-mode` — those turn messaging off whatever the setting says. A session
|
||||
that turned it off does not appear in `list()`, and its own `list_agents` and
|
||||
`send_message` cannot see or reach the program either. `qwen sessions ps`
|
||||
lists the program regardless.
|
||||
- A message is delivered without review in exactly two cases: the send
|
||||
presents a controller token (`controller: true`), or its `fromMode` names the
|
||||
receiving session's own review class. `fromMode` is a claim nothing
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*
|
||||
* Starting an endpoint binds an inbox and publishes a session record, so
|
||||
* the program shows up in `qwen sessions ps`, and in the `list_agents` of
|
||||
* every session that has `agents.crossSessionMessaging` on — which is also
|
||||
* what lets those sessions address it by name from `send_message`. It can
|
||||
* address sessions the same way. It keeps a small ledger of what it sent,
|
||||
* so the receipts that come back read as state changes rather than as a
|
||||
* stream of unrelated notices.
|
||||
* every session that takes part in cross-session messaging — its
|
||||
* `agents.crossSessionMessaging` setting is on, and it was not started with
|
||||
* `--bare` or `--safe-mode` — which is also what lets those sessions
|
||||
* address it by name from `send_message`. It can address sessions the same
|
||||
* way. It keeps a small ledger of what it sent, so the receipts that come
|
||||
* back read as state changes rather than as a stream of unrelated notices.
|
||||
*
|
||||
* What the endpoint is trusted to do is not decided here. A message it
|
||||
* sends is held for the receiving session's user to review unless the send
|
||||
|
||||
Reference in new issue
Block a user