mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(serve): allow configuring the Conversations working-directory root (#16105)
* feat(serve): allow configuring the Conversations working-directory root * test(serve): register Conversations root E2E in no-AK CI * fix(serve): address Conversations root review feedback * fix(serve): clarify Conversations root configuration errors --------- Co-authored-by: Jiankai Xiao <[email protected]>
This commit is contained in:
1 parent
e4c570a387
commit
de6ccb4e3c
26 files changed
+1349
-30
No files matched your search
@@ -0,0 +1,126 @@
|
||||
# Configurable Conversations working-directory root
|
||||
|
||||
[English](conversations-working-directory-root.md) | [简体中文](conversations-working-directory-root.zh-CN.md)
|
||||
|
||||
## Status and problem
|
||||
|
||||
Local implementation design for [#15298](https://github.com/QwenLM/qwen-code/issues/15298),
|
||||
based on main `9dde2f657d3dfc9183bbe5534efcbaa39564758d`. The issue is accepted
|
||||
for exploration by automated triage; maintainer approval of the interface is
|
||||
still pending.
|
||||
|
||||
Containers need an explicit persistent working-files location independent of
|
||||
the operating-system home. The existing root is
|
||||
`<home>/Documents/Qwen Code/Conversations`. Mounting that exact location or
|
||||
changing process home are valid alternatives. `QWEN_HOME`, `QWEN_RUNTIME_DIR`
|
||||
and `advanced.runtimeOutputDir` do not select this working root.
|
||||
|
||||
## Configuration and lifetime
|
||||
|
||||
Provide `QWEN_CONVERSATIONS_DIR` and an additive `--conversations-dir` option
|
||||
on `qwen serve` and `qwen daemon start`. `ServeOptions.conversationsDir`
|
||||
provides the same launcher configuration to embedded callers. Selection is:
|
||||
|
||||
1. Explicit CLI/API option.
|
||||
2. `QWEN_CONVERSATIONS_DIR` from the launch environment or user-level `.env`.
|
||||
3. The existing home-derived default.
|
||||
|
||||
Use the existing hardcoded project-environment exclusions to reject this key
|
||||
from project `.env`, from `settings.env` at every scope, and from environment
|
||||
reload. Do not add a project setting or a browser/session request field. Resolve
|
||||
the chosen path once at daemon boot and pass it into the existing
|
||||
`ConversationWorkspace`. No later environment reload may redirect that object.
|
||||
|
||||
An explicit value must be a nonempty absolute, fully qualified filesystem path
|
||||
without NUL. Relative paths, unexpanded `~`, and Windows drive-relative/rooted
|
||||
paths that depend on the current drive are rejected with a clear configuration
|
||||
error. Do not trim or silently fall back on invalid values. The higher-priority
|
||||
source wins; a valid CLI option can override an invalid environment value.
|
||||
|
||||
Validate path syntax before listening. Directory creation and filesystem
|
||||
identity acceptance remain lazy for both explicit and default roots. An
|
||||
unused daemon must not create the default Conversations tree. Existing
|
||||
filesystem failures remain errors on first use, without switching roots.
|
||||
|
||||
## Runtime and storage contract
|
||||
|
||||
Standalone and Live continue using one daemon-owned Conversations runtime and
|
||||
its shared ACP child. Only root selection changes. Each session keeps its
|
||||
deterministic `conversation-<sha256(normalized session ID)>` direct child as
|
||||
the tool/shell cwd. ACP relocation already consumes the daemon's chosen root
|
||||
and pinned identity; no new ACP parameter or core storage policy is required.
|
||||
|
||||
Keep root/child ownership, POSIX private permissions, canonical identity,
|
||||
available device/inode checks, direct-child and link/reparse checks, writer
|
||||
leases, and deletion recovery unchanged. Retain documented Windows and
|
||||
inode-verifiability limits. The configured tree is reserved from ordinary
|
||||
workspace registration using the existing checks; this adds no general
|
||||
workspace-root restriction and no OS containment claim.
|
||||
|
||||
| Data | Location and responsibility |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Session working files | Private children of the selected Conversations root |
|
||||
| Shared root configuration | User-authored configuration under the selected root; it is not private to a session |
|
||||
| Transcripts and sidecars | Existing runtime-base storage keyed by the canonical Conversations runtime cwd |
|
||||
| Deletion recovery journals | Existing stable `<home>/.qwen/conversations/deletions` namespace |
|
||||
| Live discovery | Existing stable `<home>/.qwen/live/daemon.json`, plus the daemon-runtime-base copy where applicable |
|
||||
|
||||
This option does not move the last three categories or remove the deployment's
|
||||
need to preserve the relevant home-based recovery state.
|
||||
|
||||
## Launch and restart behavior
|
||||
|
||||
Keep ordinary serve parsing and the serve fast path consistent. Forward the
|
||||
option through `daemon start`'s explicit serve argument list. Environment-only
|
||||
launches keep ordinary process inheritance. Desktop launchers can supply the
|
||||
environment variable; a new desktop setting is out of scope.
|
||||
|
||||
Add an optional, validated `conversationsDir` to the local daemon discovery
|
||||
record and publish the resolved configured path, including the default.
|
||||
`daemon restart` replays this path through `daemon start --conversations-dir`
|
||||
so a changed shell environment cannot silently select a different root.
|
||||
Older records lack this field and retain the previous environment/default
|
||||
resolution behavior. Automatic update relaunch must likewise preserve the
|
||||
selected root. These records store the configured path, not persistent inode
|
||||
attestation; normal filesystem acceptance runs again after restart.
|
||||
|
||||
Intentional reconfiguration uses stop followed by start with the new value;
|
||||
`daemon restart` preserves the recorded selection. Keep the same runtime base
|
||||
and required control state when resuming the same root. Before changing roots,
|
||||
finish pending deletion recovery against the original root and stop all writers
|
||||
using it. Do not remove recovery evidence to force acceptance. If original-root
|
||||
recovery cannot complete, changing the option is not a repair mechanism.
|
||||
|
||||
A new canonical root changes history storage/discovery and shared root config.
|
||||
No files, session records, configuration or discovery state are automatically
|
||||
migrated, and complete old-history visibility is not promised. Existing pending
|
||||
journals continue failing closed on identity mismatches.
|
||||
|
||||
## Affected layers and validation
|
||||
|
||||
- Conversations root resolution/workspace construction and daemon boot wiring.
|
||||
- Serve types, ordinary/fast CLI parsing, daemon start/restart and local discovery.
|
||||
- Shared environment exclusions and both loader regression suites.
|
||||
- User configuration/serve documentation and focused collocated tests.
|
||||
|
||||
The E2E plan lives in `.qwen/e2e-tests/conversations-working-directory-root.md`.
|
||||
Use isolated home, configuration, runtime and work directories. Verify default
|
||||
laziness; env and CLI precedence; invalid input; project injection rejection;
|
||||
standalone shell cwd and files; same-root restart/history; ordinary-workspace
|
||||
isolation; and unchanged link/permission/identity safeguards. Existing shared
|
||||
runtime tests cover Live/standalone consistency. No real model call is required
|
||||
for prompt-less session creation and session-shell tests.
|
||||
|
||||
Run the repository build, typecheck and bundle, focused unit and E2E tests,
|
||||
format/reference checks, two clean self-audit passes and independent review.
|
||||
Record platform or runtime coverage limitations rather than claiming unrun
|
||||
checks. The current environment has no global `qwen`; baseline testing must
|
||||
record that limitation and use an unmodified checkout executable instead.
|
||||
|
||||
## Scope and remaining review decisions
|
||||
|
||||
No migration, configurable lock placement, directory browser/creation APIs,
|
||||
per-session selection, new workspace policy, or UI is included. Public naming
|
||||
and the supported interface remain subject to maintainer review. This design
|
||||
chooses syntax validation at boot, lazy filesystem acceptance and restart
|
||||
preservation as concrete behavior to review.
|
||||
@@ -0,0 +1,102 @@
|
||||
# 可配置的 Conversations 工作目录根路径
|
||||
|
||||
[English](conversations-working-directory-root.md) | [简体中文](conversations-working-directory-root.zh-CN.md)
|
||||
|
||||
## 状态与问题
|
||||
|
||||
这是 [#15298](https://github.com/QwenLM/qwen-code/issues/15298) 的本地实现设计,
|
||||
基于 main `9dde2f657d3dfc9183bbe5534efcbaa39564758d`。自动 triage 建议进入探索阶段;
|
||||
维护者尚未批准具体接口。
|
||||
|
||||
容器部署需要独立于操作系统 home 的持久化工作文件位置。现有根路径是
|
||||
`<home>/Documents/Qwen Code/Conversations`。在该位置挂卷或调整进程 home 都是
|
||||
有效替代方案。`QWEN_HOME`、`QWEN_RUNTIME_DIR` 和 `advanced.runtimeOutputDir`
|
||||
不负责选择这个工作根目录。
|
||||
|
||||
## 配置与生命周期
|
||||
|
||||
提供 `QWEN_CONVERSATIONS_DIR`,并为 `qwen serve` 和 `qwen daemon start` 增加
|
||||
`--conversations-dir` 参数。嵌入调用者通过 `ServeOptions.conversationsDir` 使用
|
||||
同样的启动方配置。选择顺序为:
|
||||
|
||||
1. 显式 CLI/API 参数。
|
||||
2. 启动环境或用户级 `.env` 中的 `QWEN_CONVERSATIONS_DIR`。
|
||||
3. 现有基于 home 的默认路径。
|
||||
|
||||
沿用现有项目环境变量硬编码排除规则,在项目 `.env`、所有作用域的 `settings.env`
|
||||
以及环境重载时拒绝此键。不增加项目设置或浏览器/会话请求字段。在 daemon 启动时
|
||||
解析一次选定路径,传给现有 `ConversationWorkspace`。后续环境重载不能重定向该对象。
|
||||
|
||||
显式值必须是非空、不含 NUL 的完全限定绝对文件路径。拒绝相对路径、未展开的 `~`
|
||||
以及依赖当前驱动器的 Windows 路径,并给出明确配置错误。不裁剪路径字符串,也不在
|
||||
无效时静默回退。高优先级来源胜出;有效 CLI 参数可以覆盖无效环境变量。
|
||||
|
||||
监听前检查路径语法。显式路径和默认路径的目录创建及文件系统身份接受都保持惰性。
|
||||
未使用 Conversations 的 daemon 不应创建默认目录树。首次使用时的既有文件系统
|
||||
失败仍是错误,不得改选其他根目录。
|
||||
|
||||
## Runtime 与存储契约
|
||||
|
||||
Standalone 和 Live 继续使用同一个 daemon 所有的 Conversations runtime 和共享
|
||||
ACP 子进程。仅改变根目录选择。每个会话继续将确定性的
|
||||
`conversation-<sha256(normalized session ID)>` 直接子目录用作工具/shell cwd。
|
||||
ACP 重定位已经使用 daemon 选定的根目录与固定身份,不需要新 ACP 参数或 core 存储策略。
|
||||
|
||||
保留根目录/子目录所有者、POSIX 私有权限、规范身份、可用的 device/inode 检查、
|
||||
直接子目录和 link/reparse 检查、writer lease 及删除恢复机制。保留已记录的 Windows
|
||||
和 inode 可验证性限制。现有检查继续禁止普通工作区注册使用选定目录树;不增加
|
||||
一般工作区根目录限制,也不声称提供操作系统级隔离。
|
||||
|
||||
| 数据 | 位置与职责 |
|
||||
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||
| 会话工作文件 | 选定 Conversations 根目录的私有子目录 |
|
||||
| 共享根配置 | 选定根目录下的用户编写配置,不是会话私有配置 |
|
||||
| Transcript 和 sidecar | 按规范 Conversations runtime cwd 定位的现有 runtime-base 存储 |
|
||||
| 删除恢复日志 | 现有稳定的 `<home>/.qwen/conversations/deletions` 命名空间 |
|
||||
| Live 发现状态 | 现有稳定的 `<home>/.qwen/live/daemon.json`,以及适用时 daemon runtime base 下的副本 |
|
||||
|
||||
本选项不移动后三类数据,也不消除部署保存相关 home 恢复状态的需求。
|
||||
|
||||
## 启动与重启行为
|
||||
|
||||
保持普通 serve 解析和 serve fast path 一致。通过 `daemon start` 显式构造的 serve
|
||||
参数列表传递新参数。仅使用环境变量的启动仍沿用正常进程继承。桌面启动器可以提供
|
||||
环境变量;新增桌面设置不在范围内。
|
||||
|
||||
在本地 daemon 发现记录增加可选且经过验证的 `conversationsDir`,发布解析后的配置
|
||||
路径,包括默认路径。`daemon restart` 通过 `daemon start --conversations-dir`
|
||||
重放该路径,防止变化的 shell 环境静默选择新根目录。旧记录缺少此字段,沿用此前的
|
||||
环境/默认路径解析行为。自动更新重启也必须保留选定根目录。记录保存的是配置路径,
|
||||
不是跨重启 inode 证明;重启后仍执行正常文件系统接受检查。
|
||||
|
||||
有意重配置采用先 stop 再带新值 start;`daemon restart` 保留记录中的选择。继续使用
|
||||
同一根目录时,保持 runtime base 和必要控制状态一致。更换根目录前,应针对原根目录
|
||||
完成未结束的删除恢复,并停止所有使用该根目录的 writer。不得通过删除恢复证据强行
|
||||
接受目录。如果原根目录恢复无法完成,更改此选项不能作为修复机制。
|
||||
|
||||
新的规范根目录会改变历史存储/发现与共享根配置。不自动迁移文件、会话记录、配置或
|
||||
发现状态,也不承诺全部旧历史继续可见。未完成的既有 journal 遇到身份不匹配时继续
|
||||
失败关闭。
|
||||
|
||||
## 涉及层次与验证
|
||||
|
||||
- Conversations 根目录解析、workspace 构造和 daemon 启动接线。
|
||||
- Serve 类型、普通/快速 CLI 解析、daemon start/restart 与本地发现记录。
|
||||
- 共享环境变量排除规则及两条加载路径的回归测试。
|
||||
- 用户配置/serve 文档和相邻单元测试。
|
||||
|
||||
端到端计划位于 `.qwen/e2e-tests/conversations-working-directory-root.md`。使用隔离
|
||||
的 home、配置、runtime 和工作目录。验证默认路径惰性、环境变量与 CLI 优先级、无效
|
||||
输入、项目注入拒绝、standalone shell cwd 与文件、同根目录重启/历史、普通工作区
|
||||
隔离,以及未改变的链接/权限/身份检查。现有共享 runtime 测试覆盖 Live/standalone
|
||||
一致性。不带 prompt 的会话创建和 session-shell 测试无需真实模型调用。
|
||||
|
||||
运行仓库构建、类型检查、bundle、定向单元和端到端测试、格式/引用检查、两轮干净的
|
||||
自审及独立审查。明确记录平台或运行时覆盖限制,不声称完成未执行的检查。当前环境
|
||||
没有全局 `qwen`,基线测试必须记录此限制并改用未修改 checkout 的可执行入口。
|
||||
|
||||
## 范围与后续审阅事项
|
||||
|
||||
不包含迁移、可配置锁位置、目录浏览/创建 API、按会话选择、新工作区策略或 UI。
|
||||
公开名称及受支持接口仍待维护者审阅。本设计选择启动时语法检查、惰性文件系统接受
|
||||
和重启保留配置,作为具体可审阅行为。
|
||||
@@ -15,6 +15,7 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
|
||||
| `--open-with-auth` | boolean | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a selected `--token` / `QWEN_SERVER_TOKEN`, or generates a process-lifetime 256-bit bearer before listen — that generated value is installed on the options token before boot, which makes this flag a third token source that satisfies `--require-auth` on loopback. In a browser-ineligible environment, starts and prints the secret-bearing fragment URL. Not a `ServeOptions` or SDK setting. |
|
||||
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`. Boot refuses to start when no token source resolves, which after the non-loopback generation step means a **loopback** bind: on a non-loopback bind the generated ephemeral token satisfies the flag, so its "no configured secret, no daemon" fail-fast is loopback-only. Loopback token sources are `--token`, `QWEN_SERVER_TOKEN`, and `--open-with-auth`. |
|
||||
| `--workspace <dir>` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. |
|
||||
| `--conversations-dir <dir>` | absolute path | env / `~/Documents/Qwen Code/Conversations` | Fully qualified working-directory root shared by standalone and Live sessions; also accepted by `qwen daemon start`. Overrides `QWEN_CONVERSATIONS_DIR`. Invalid syntax fails before listening; directory creation and filesystem acceptance remain lazy. |
|
||||
| `--memory-project-scope <mode>` | `git-root` / `workspace` | `workspace` | Project-memory partitioning. `workspace` isolates by exact workspace directory; `git-root` is the legacy compatibility scope shared by workspaces at the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. |
|
||||
| `--max-sessions <n>` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. A passed flag beats `serve.maxSessions` (user, system, and system-defaults scopes only), which beats the default; an invalid setting is reported and ignored. |
|
||||
| `--max-total-sessions <n>` | number | `800`, or derived at capacity 25 or less | Daemon-wide active session cap. When omitted, it is 800 at registration capacities above 25 (including the default 256), even with one workspace. At capacities of 25 or less it is derived once from the per-workspace cap and the startup/restored workspace count, and one such workspace is unlimited. `0` / `Infinity` means unlimited. |
|
||||
@@ -75,6 +76,7 @@ their configured bearer or trusted-loopback policy.
|
||||
| `QWEN_SERVE_RATE_LIMIT_WINDOW_MS` | Env fallback for `--rate-limit-window-ms`. |
|
||||
| `QWEN_SERVE_NEW_FILE_MODE` | New-file mode policy for daemon text writes: `owner` (default — NEW files are created `0600`, umask-independent) or `system` (NEW files follow `0o666 & ~umask`). Case-insensitive; the literal `0600` is accepted as an alias for `owner` (no other octal modes are supported), and unrecognized values warn on stderr and keep the `0600` default. Existing files always preserve their mode. See [`qwen-serve.md` — New-file mode for agent text writes](../../users/qwen-serve.md#new-file-mode-for-agent-text-writes). |
|
||||
| `QWEN_SERVE_SESSION_ATTACHMENTS_ROOT` | Overrides where session attachments are stored; reads/removes that miss the configured root fall back to the default runtime temp dir so pre-switch attachments stay readable. Accepts an absolute path, a path relative to the daemon cwd, or `~` / `~/…`. See [`qwen-serve.md` — Session attachment storage](../../users/qwen-serve.md#session-attachment-storage). |
|
||||
| `QWEN_CONVERSATIONS_DIR` | Conversations working-root fallback for `--conversations-dir` / `ServeOptions.conversationsDir`. Accepts a nonempty, fully qualified absolute path without NUL. Read from the launch environment or user-level `.env`; rejected from project environment files and top-level `settings.json` `env` at every scope, and not changed by reload. See [Conversations working directory](../../users/qwen-serve.md#conversations-working-directory). |
|
||||
| `QWEN_CODE_MEMORY_PROJECT_SCOPE` | `workspace` keys project memory by the exact workspace dir; `git-root` selects the legacy shared scope. When unset, the daemon injects `workspace`; unrecognized values warn once and retain the legacy `git-root` behavior. Propagates via the runtime base env, not `childEnvOverrides`; `--memory-project-scope` wins. Each workspace remember/forget/dream lane caps pending tasks at `MAX_PENDING = 16`; N workspaces allow up to 16·N queued tasks with no daemon-wide cap. |
|
||||
|
||||
Blank `QWEN_CODE_MEMORY_PROJECT_SCOPE` values are treated as unset and therefore default to `workspace`; unrecognized non-empty values still warn once and retain the legacy `git-root` behavior.
|
||||
@@ -126,6 +128,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin
|
||||
| `eventRingSize` | Overrides the default per-session ring size. |
|
||||
| `sessionPromptSettledCloseGraceMs` | Grace period after a prompt settles before an otherwise-idle session may be auto-closed. `0` = immediate close. |
|
||||
| `memoryProjectScope` | `runQwenServe` only; precedence is option, launch env, then `workspace`. Direct `createServeApp` callers use `deps.daemonEnv`. |
|
||||
| `conversationsDir` | `runQwenServe` only; precedence is option, `QWEN_CONVERSATIONS_DIR`, then `~/Documents/Qwen Code/Conversations`. Fixed at boot. |
|
||||
| `maxPendingPromptsPerSession` | Pending prompt cap per session; `0` / `Infinity` means unlimited. |
|
||||
| `mcpPoolActive` | Programmatic switch, defaulting from `QWEN_SERVE_NO_MCP_POOL`. |
|
||||
| `externalToolGuard` | Optional `{mode:'required', endpoint, token, timeoutMs?}`. Omission is fully off; required mode performs the provider handshake before listening. |
|
||||
@@ -142,6 +145,10 @@ The daemon constructs each workspace runtime from that workspace's merged settin
|
||||
| `sessionIdleTimeoutMs` | Disconnected-session idle reaping time. |
|
||||
| `rateLimit*` | Per-tier HTTP rate limit switch, thresholds, and window. |
|
||||
|
||||
The selected Conversations path, including the default, is stored in current daemon discovery records and replayed by `daemon restart`, so a changed restart-shell environment does not redirect it. Older records without `conversationsDir` retain environment/default resolution. Automatic update relaunch also preserves the selection. To change it intentionally, stop and start with the new configuration.
|
||||
|
||||
The canonical Conversations root keys the existing runtime-base transcript/sidecar namespace; working files and shared root configuration live under the selected tree. Changing the root does not migrate these data or guarantee old-history visibility. Existing identity, permissions, writer-lease, and recovery checks still apply. See [Conversations working directory](../../users/qwen-serve.md#conversations-working-directory) for storage boundaries and recovery requirements before switching roots.
|
||||
|
||||
## `BridgeOptions` (programmatic bridge embedding)
|
||||
|
||||
`packages/acp-bridge/src/bridgeOptions.ts` defines bridge options. See [`03-acp-bridge.md`](./03-acp-bridge.md) for the full table. Key fields:
|
||||
|
||||
@@ -858,7 +858,8 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe
|
||||
| Variable | Description | Notes |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `QWEN_HOME` | Customizes the global configuration directory (default: `~/.qwen`). Accepts an absolute or relative path (relative paths are resolved from the current working directory). Leading `~` is expanded to the user's home directory. | Stores credentials, settings, memory, skills, and other global state. When set, project-level `.qwen/` directories are unaffected. An empty string is treated as unset. |
|
||||
| `QWEN_RUNTIME_DIR` | Overrides the runtime output directory (conversations, logs, todos). When unset, defaults to the `QWEN_HOME` directory. | Use this to separate ephemeral runtime data from persistent config. Useful when `QWEN_HOME` is on a shared/slow filesystem. |
|
||||
| `QWEN_RUNTIME_DIR` | Overrides the runtime output directory (session records, logs, todos), taking precedence over `advanced.runtimeOutputDir`. When neither is configured, runtime data uses `QWEN_HOME` (or `~/.qwen`). | Use this to separate runtime data from configuration. Useful when `QWEN_HOME` is on a shared/slow filesystem. This does not select the Conversations working-directory root; persisting this directory alone does not preserve session working files. |
|
||||
| `QWEN_CONVERSATIONS_DIR` | Selects the shared Conversations working-directory root used by `qwen serve` for standalone and Live sessions. Defaults to `~/Documents/Qwen Code/Conversations` when unset. An explicit value must be a nonempty, fully qualified absolute path; invalid syntax fails startup. | The `--conversations-dir` flag on `qwen serve` or `qwen daemon start`, or `ServeOptions.conversationsDir`, takes precedence. Set in the launch environment or a user-level `.env`; project environment files, top-level `settings.json` `env` at every scope, and reloads cannot change it. Fixed for the daemon lifetime. See [Conversations working directory](../qwen-serve.md#conversations-working-directory) for lazy creation, storage boundaries, and restart/migration behavior. |
|
||||
| `QWEN_USAGE_STATISTICS_ENABLED` | Set to `true` or `1` to enable usage statistics. Any other value is treated as disabling them. | Overrides the `privacy.usageStatisticsEnabled` setting. Defaults to enabled when neither is configured. |
|
||||
| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. |
|
||||
| `QWEN_TELEMETRY_TARGET` | Sets an informational label for the telemetry destination (`local` or `gcp`). Does not control routing; use `QWEN_TELEMETRY_OTLP_ENDPOINT` or `QWEN_TELEMETRY_OUTFILE` to configure where data is sent. | Overrides the `telemetry.target` setting. |
|
||||
|
||||
@@ -154,7 +154,9 @@ qwen daemon token --port 4170 --remove # delete only the local sidecar
|
||||
|
||||
Prefer `QWEN_SERVER_TOKEN` over `--token`, which is visible in the shell process list. `--save-token` requires an explicitly supplied token and refuses to spawn without one. The sidecar is `daemon/daemon-<port>-<pid>.token` under the same runtime directory as the discovery record; it is `0600` on POSIX and is never included in the discovery record or daemon log. `qwen daemon stop` removes it, while `qwen daemon restart` carries it to the replacement instance. `qwen daemon token --remove` deletes the local copy but does not change or revoke the running daemon's credential; stop and start the daemon with a new token to rotate it. Ordinary `qwen serve`, including `--open-with-auth`, does not write this sidecar.
|
||||
|
||||
`qwen daemon restart` stops one registered daemon through `daemon stop` and starts it again through `daemon start --wait` on its recorded port and hostname, with its recorded startup workspaces, Web Shell mode, and `--require-auth` setting; `--web`/`--no-web` and `--require-auth`/`--no-require-auth` override the record. Persisted workspace registrations are restored by the new daemon itself, not replayed as `--workspace`. Every other `qwen serve` option (for example `--profile`, `--channel`, `--allow-origin`, or rate limits) resets to its default, so relaunch a daemon configured with those through `qwen serve`. Before stopping anything, restart refuses an HTTPS daemon, a startup workspace that no longer exists, a run from inside a shell the daemon hosts (such as its Web Shell terminal), and a bearer daemon without `--token` or `QWEN_SERVER_TOKEN`; a running bearer daemon must accept the token, so restart never rotates the credential. A token kept only in a workspace `.env` or settings file is not read by `qwen daemon`. `--force` is forwarded to `daemon stop` only when you pass it. If the recorded port is taken, the daemon starts on the next free port and restart exits non-zero.
|
||||
`qwen daemon restart` stops one registered daemon through `daemon stop` and starts it again through `daemon start --wait` on its recorded port and hostname, with its recorded startup workspaces, Conversations working-directory root, Web Shell mode, and `--require-auth` setting; `--web`/`--no-web` and `--require-auth`/`--no-require-auth` override the record. Persisted workspace registrations are restored by the new daemon itself, not replayed as `--workspace`. Every other `qwen serve` option (for example `--profile`, `--channel`, `--allow-origin`, or rate limits) resets to its default, so relaunch a daemon configured with those through `qwen serve`. Before stopping anything, restart refuses an HTTPS daemon, a startup workspace that no longer exists, a run from inside a shell the daemon hosts (such as its Web Shell terminal), and a bearer daemon without `--token` or `QWEN_SERVER_TOKEN`; a running bearer daemon must accept the token, so restart never rotates the credential. A token kept only in a workspace `.env` or settings file is not read by `qwen daemon`. `--force` is forwarded to `daemon stop` only when you pass it. If the recorded port is taken, the daemon starts on the next free port and restart exits non-zero.
|
||||
|
||||
The Conversations root recorded by a current daemon is preserved by `daemon restart`, even if `QWEN_CONVERSATIONS_DIR` in the restarting shell has changed. Older discovery records without this field use the launch environment or default again; they cannot guarantee the previous selection. To choose a different root, stop the daemon and start it with the new value. See [Conversations working directory](#conversations-working-directory) for persistence and recovery requirements.
|
||||
|
||||
### Workspace registration capacity
|
||||
|
||||
@@ -584,6 +586,7 @@ Qwen Live runs through the Web Shell on every platform: the browser tab is the m
|
||||
| `--max-total-sessions <n>` | `800` or derived | Optional non-negative integer daemon-wide cap on fresh session creation across all registered workspace runtimes. It applies to new child sessions, session restore, and branch/fork-created sessions; attaching to an existing live session does not consume a slot. Set to `0` for unlimited. Defaults to 800 when registration capacity is above 25 (including the default 256). At capacities of 25 or less, one startup or restored workspace is unlimited; several derive the total from the per-workspace limit and that count. Later dynamic registration does not recompute it. |
|
||||
| `--max-pending-prompts-per-session <n>` | `5` | Per-session cap on prompts accepted by `POST /session/:id/prompt` but not yet settled, including queued prompts and the active prompt. The bridge rejects overflow synchronously with `503`, `Retry-After: 5`, and `code: "prompt_queue_full"` before returning a `promptId`. Set to `0` to disable. `branchSession` serializes on the same FIFO but does not count against this prompt cap. |
|
||||
| `--workspace <path>` | `process.cwd()` | Absolute workspace directory registered by this daemon. Repeat the flag to host multiple workspaces in one process; the first is primary and remains the default when a request omits `cwd`. Relative values are rejected. Session requests whose canonical `cwd` is not registered return `400 workspace_mismatch`. |
|
||||
| `--conversations-dir <path>` | — | Absolute Conversations working-directory root for standalone and Live sessions. Overrides `QWEN_CONVERSATIONS_DIR`; when neither is set, uses `~/Documents/Qwen Code/Conversations`. Also accepted by `qwen daemon start`. See [Conversations working directory](#conversations-working-directory). |
|
||||
| `--memory-project-scope <mode>` | `workspace` | Project-memory partitioning mode. `workspace` (default) keys memory by the exact registered workspace directory so each daemon workspace gets its own isolated memory; `git-root` is the legacy compatibility mode shared by workspaces resolved to the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE` when provided; a blank env value is treated as unset, while an unrecognized non-empty value is ignored with a one-time warning and retains the legacy `git-root` behavior. The new default does not migrate existing git-root project memory — use an explicit `git-root` scope to read those entries during migration. |
|
||||
| `--channel <name\|all>` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to a registered workspace; a multi-workspace daemon runs one worker per owning workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. |
|
||||
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
|
||||
@@ -878,6 +881,45 @@ Scope and limits:
|
||||
- Binary uploads (`POST /file/upload`) always create at `0600` regardless of this setting.
|
||||
- The daemon reads the variable at workspace-filesystem construction; restart the daemon after changing it.
|
||||
|
||||
### Conversations working directory
|
||||
|
||||
Set the working-directory root for standalone and Live sessions independently of the operating-system home:
|
||||
|
||||
```bash
|
||||
qwen serve --conversations-dir /persistent/qwen-conversations
|
||||
|
||||
# Or configure the launch environment:
|
||||
QWEN_CONVERSATIONS_DIR=/persistent/qwen-conversations qwen serve
|
||||
|
||||
# The same option is available for a detached daemon:
|
||||
qwen daemon start --conversations-dir /persistent/qwen-conversations
|
||||
```
|
||||
|
||||
The explicit flag takes precedence over `QWEN_CONVERSATIONS_DIR`, which can come from the launch environment or a user-level `.env` file. Embedded launchers can select the same root with `ServeOptions.conversationsDir`, which also takes precedence over the environment. When neither is configured, the root remains `~/Documents/Qwen Code/Conversations`. Mounting a persistent volume at that default path or changing the process home are also valid deployment choices.
|
||||
|
||||
An explicit value must be a nonempty, fully qualified absolute path without NUL characters. Relative paths, an unexpanded `~`, and Windows paths that depend on the current drive are rejected. Values are not trimmed, and invalid configuration fails startup instead of falling back. A valid explicit flag or API option overrides an invalid environment value. Path syntax is checked before listening; directory creation and filesystem checks remain lazy, so an unused Conversations root is not created at startup. A filesystem failure on first use does not select another root.
|
||||
|
||||
The selection is fixed for the daemon's lifetime. Project `.env` and project `.qwen/.env` files, top-level `settings.json` `env` at every scope, environment reloads, and browser/session requests cannot redirect it. There is no corresponding project setting or per-session root option.
|
||||
|
||||
Standalone and Live keep sharing the existing Conversations runtime. Each session uses its deterministic private `conversation-<sha256(normalized session ID)>` direct child as its tool and shell working directory. Existing ownership, private POSIX permissions, canonical identity, direct-child, link/reparse, writer-lease, and recovery checks still apply, including their platform-specific limits. Windows does not gain POSIX owner/mode or ACL guarantees, and filesystems without verifiable inode identity still cannot detect every same-path replacement. The selected Conversations tree remains reserved from ordinary workspace registration; other workspaces retain their existing rules.
|
||||
|
||||
The working root and runtime storage have separate responsibilities:
|
||||
|
||||
| Data | Location |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Session working files | Private session children under the selected Conversations root. |
|
||||
| Shared root configuration | User-authored configuration under the selected root, shared across its sessions. |
|
||||
| Transcripts and sidecars | Existing daemon runtime-base storage, keyed by the canonical Conversations runtime cwd. |
|
||||
| Deletion recovery journals | The stable `<home>/.qwen/conversations/deletions` namespace. |
|
||||
| Live discovery | The stable `<home>/.qwen/live/daemon.json` locator and, where applicable, its daemon-runtime-base copy. |
|
||||
| Local daemon discovery | `daemon/daemon-<port>.json` under the daemon runtime base; records the selected Conversations path for restart. |
|
||||
|
||||
`QWEN_HOME` selects the global Qwen configuration directory. `QWEN_RUNTIME_DIR` and `advanced.runtimeOutputDir` select runtime-output storage through their existing precedence rules. None selects the Conversations working-directory root. Persisting runtime storage alone does not persist files created in the private session directories; configuring the working root alone also does not preserve runtime storage or home-based recovery and discovery state.
|
||||
|
||||
For a restart using the same root, retain the same canonical path, runtime base, and required control state. `qwen daemon restart` preserves the root from a current discovery record, and automatic update relaunch preserves the selected root too. Normal filesystem identity acceptance, permissions, writer leases, and recovery checks still run; this is not a guarantee of recovery after every container or host restart. See [Conversations writer locks and recovery](./conversations-recovery.md).
|
||||
|
||||
To change roots intentionally, complete pending deletion recovery against the original root, stop all writers using it, then stop and start the daemon with the new configuration. A different canonical root selects a different history storage/discovery namespace and shared root configuration. No working files, session records, configuration, recovery journals, or discovery state are automatically migrated, and old history is not guaranteed to remain visible. Do not remove recovery evidence to force acceptance: if recovery against the original root cannot complete, changing this option is not a repair procedure.
|
||||
|
||||
### Session attachment storage
|
||||
|
||||
Session attachments (files and images uploaded by Web Shell through `POST /session/:id/attachments`) are stored by default under the workspace's runtime temp dir: `<runtimeBaseDir>/tmp/<projectHash>/attachments`, keyed per session as `session-<sessionId>`. Operators who want attachments to persist outside the runtime temp dir (e.g. on a dedicated volume) can override the root:
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
DEFAULT_CLI_BIN,
|
||||
spawnDaemon,
|
||||
type SpawnedDaemon,
|
||||
} from './_daemon-harness.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
const token = 'conversations-root-e2e-token';
|
||||
const sandboxed = Boolean(
|
||||
process.env['QWEN_SANDBOX'] &&
|
||||
process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false',
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of cleanup.splice(0).reverse()) await dispose();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
vi.stubEnv('QWEN_CONVERSATIONS_DIR', undefined);
|
||||
vi.stubEnv('QWEN_CODE_CLI', undefined);
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(tmpdir(), 'qwen-conversations-root-e2e-')),
|
||||
);
|
||||
cleanup.push(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const home = path.join(root, 'home');
|
||||
const config = path.join(root, 'config');
|
||||
const runtime = path.join(root, 'runtime');
|
||||
const workspace = path.join(root, 'workspace');
|
||||
const selected = path.join(root, 'chosen-root');
|
||||
await Promise.all(
|
||||
[home, config, runtime, workspace].map((dir) =>
|
||||
fs.mkdir(dir, { mode: 0o700 }),
|
||||
),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(config, 'settings.json'),
|
||||
JSON.stringify({
|
||||
security: { auth: { selectedType: 'openai' } },
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: 'fake-model',
|
||||
baseUrl: 'http://127.0.0.1:9/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const env: Record<string, string> = {
|
||||
HOME: home,
|
||||
QWEN_HOME: config,
|
||||
QWEN_RUNTIME_DIR: runtime,
|
||||
QWEN_CLI_ENTRY: DEFAULT_CLI_BIN,
|
||||
QWEN_SERVE_NO_PERSISTENT_REGISTRATION: '1',
|
||||
QWEN_CODE_MODELS_DEV: 'off',
|
||||
OPENAI_API_KEY: 'fake-key',
|
||||
OPENAI_BASE_URL: 'http://127.0.0.1:9/v1',
|
||||
OPENAI_MODEL: 'fake-model',
|
||||
QWEN_MODEL: 'fake-model',
|
||||
};
|
||||
const start = async (extraArgs: string[] = []) => {
|
||||
const daemon = await spawnDaemon({
|
||||
workspaceCwd: workspace,
|
||||
token,
|
||||
env,
|
||||
bootTimeoutMs: 60_000,
|
||||
extraArgs: ['--no-web', '--enable-session-shell', ...extraArgs],
|
||||
});
|
||||
cleanup.push(() => daemon.dispose());
|
||||
return daemon;
|
||||
};
|
||||
const command = (args: string[]) =>
|
||||
execFileAsync(process.execPath, [DEFAULT_CLI_BIN, ...args], {
|
||||
cwd: workspace,
|
||||
env: { ...process.env, ...env },
|
||||
timeout: 90_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return {
|
||||
root,
|
||||
config,
|
||||
runtime,
|
||||
workspace,
|
||||
selected,
|
||||
defaultRoot: path.join(home, 'Documents', 'Qwen Code', 'Conversations'),
|
||||
env,
|
||||
start,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
function post(
|
||||
daemon: Pick<SpawnedDaemon, 'base' | 'token'>,
|
||||
route: string,
|
||||
body: unknown,
|
||||
clientId?: string,
|
||||
) {
|
||||
return fetch(`${daemon.base}${route}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${daemon.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(clientId ? { 'X-Qwen-Client-Id': clientId } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
}
|
||||
|
||||
async function shell(
|
||||
daemon: SpawnedDaemon,
|
||||
session: { sessionId: string; clientId?: string },
|
||||
command: string,
|
||||
) {
|
||||
const response = await post(
|
||||
daemon,
|
||||
`/session/${session.sessionId}/shell`,
|
||||
{ command },
|
||||
session.clientId,
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
expect(body.exitCode, body.output).toBe(0);
|
||||
return (body.output as string).trim();
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32' || sandboxed)(
|
||||
'Conversations working-directory root',
|
||||
() => {
|
||||
it('keeps the omitted default lazy', async () => {
|
||||
const f = await fixture();
|
||||
const daemon = await f.start();
|
||||
const health = await fetch(`${daemon.base}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
await expect(fs.lstat(f.defaultRoot)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the environment root for real shell files and same-root restore', async () => {
|
||||
const f = await fixture();
|
||||
f.env['QWEN_CONVERSATIONS_DIR'] = f.selected;
|
||||
let daemon = await f.start();
|
||||
await expect(fs.lstat(f.selected)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
const session = await daemon.client.createStandaloneSession();
|
||||
const expectedChild = path.join(
|
||||
f.selected,
|
||||
`conversation-${createHash('sha256').update(session.sessionId).digest('hex')}`,
|
||||
);
|
||||
expect(session.projectlessOutputDirectory).toBe(expectedChild);
|
||||
expect(await shell(daemon, session, 'node -p "process.cwd()"')).toBe(
|
||||
expectedChild,
|
||||
);
|
||||
await shell(daemon, session, 'printf root-probe > root-probe.txt');
|
||||
for (const cwd of [f.selected, expectedChild]) {
|
||||
const reserved = await post(daemon, '/workspaces', { cwd });
|
||||
const body = await reserved.json();
|
||||
expect(reserved.status, JSON.stringify(body)).toBe(409);
|
||||
expect(body.code).toBe('conversation_workspace_reserved');
|
||||
}
|
||||
const ordinary = path.join(f.root, 'ordinary');
|
||||
await fs.mkdir(ordinary);
|
||||
const registered = await post(daemon, '/workspaces', { cwd: ordinary });
|
||||
expect(registered.status, await registered.text()).toBe(201);
|
||||
await daemon.dispose();
|
||||
|
||||
const storage = new Storage(f.selected, f.runtime);
|
||||
await expect(
|
||||
fs.stat(
|
||||
path.join(
|
||||
storage.getProjectDir(),
|
||||
'chats',
|
||||
`${session.sessionId}.jsonl`,
|
||||
),
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
await expect(fs.lstat(f.defaultRoot)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
daemon = await f.start();
|
||||
const listed = await daemon.client.listStandaloneSessions();
|
||||
expect(listed.some((item) => item.sessionId === session.sessionId)).toBe(
|
||||
true,
|
||||
);
|
||||
const restored = await daemon.client.loadStandaloneSession(
|
||||
session.sessionId,
|
||||
);
|
||||
expect(restored.projectlessOutputDirectory).toBe(expectedChild);
|
||||
expect(await shell(daemon, restored, 'cat root-probe.txt')).toBe(
|
||||
'root-probe',
|
||||
);
|
||||
expect(await shell(daemon, restored, 'node -p "process.cwd()"')).toBe(
|
||||
expectedChild,
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
it('lets the CLI override an invalid environment root', async () => {
|
||||
const f = await fixture();
|
||||
f.env['QWEN_CONVERSATIONS_DIR'] = 'relative-env-root';
|
||||
const daemon = await f.start(['--conversations-dir', f.selected]);
|
||||
const session = await daemon.client.createStandaloneSession();
|
||||
expect(path.dirname(session.projectlessOutputDirectory)).toBe(f.selected);
|
||||
await expect(fs.lstat(f.defaultRoot)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it('accepts user env configuration while rejecting project env and settings injection', async () => {
|
||||
const f = await fixture();
|
||||
const injected = path.join(f.root, 'project-injected');
|
||||
await fs.writeFile(
|
||||
path.join(f.config, '.env'),
|
||||
`QWEN_CONVERSATIONS_DIR=${f.selected}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.workspace, '.env'),
|
||||
`QWEN_CONVERSATIONS_DIR=${injected}\n`,
|
||||
);
|
||||
await fs.mkdir(path.join(f.workspace, '.qwen'));
|
||||
await fs.writeFile(
|
||||
path.join(f.workspace, '.qwen', 'settings.json'),
|
||||
JSON.stringify({ env: { QWEN_CONVERSATIONS_DIR: injected } }),
|
||||
);
|
||||
const daemon = await f.start();
|
||||
const session = await daemon.client.createStandaloneSession();
|
||||
expect(path.dirname(session.projectlessOutputDirectory)).toBe(f.selected);
|
||||
await expect(fs.lstat(injected)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await expect(fs.lstat(f.defaultRoot)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it.each([
|
||||
{ source: 'environment', value: 'relative-root' },
|
||||
{ source: 'CLI', value: '' },
|
||||
{ source: 'CLI', value: '~/unexpanded-root' },
|
||||
])(
|
||||
'rejects $source root "$value" before listening',
|
||||
async ({ source, value }) => {
|
||||
const f = await fixture();
|
||||
if (source === 'environment') f.env['QWEN_CONVERSATIONS_DIR'] = value;
|
||||
try {
|
||||
await f.command([
|
||||
'serve',
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
'--no-web',
|
||||
...(source === 'CLI' ? ['--conversations-dir', value] : []),
|
||||
]);
|
||||
throw new Error('Invalid root unexpectedly started a daemon');
|
||||
} catch (error) {
|
||||
const result = error as Error & {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
killed?: boolean;
|
||||
};
|
||||
expect(result.killed).not.toBe(true);
|
||||
expect(result.code).not.toBe(0);
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
expect(output).toMatch(
|
||||
/conversations.*(absolute|fully qualified|nonempty|non-empty)/i,
|
||||
);
|
||||
expect(output).not.toMatch(/listening on/);
|
||||
}
|
||||
await expect(fs.lstat(f.defaultRoot)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('retains the selected root through daemon start and restart with a changed environment', async () => {
|
||||
const f = await fixture();
|
||||
delete f.env['QWEN_SERVE_NO_PERSISTENT_REGISTRATION'];
|
||||
f.env['QWEN_SERVER_TOKEN'] = token;
|
||||
f.env['QWEN_CONVERSATIONS_DIR'] = path.join(f.root, 'ignored-env');
|
||||
cleanup.push(async () => {
|
||||
await f.command(['daemon', 'stop', '--all', '--force']);
|
||||
});
|
||||
const started = await f.command([
|
||||
'daemon',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
'--workspace',
|
||||
f.workspace,
|
||||
'--conversations-dir',
|
||||
f.selected,
|
||||
'--no-web',
|
||||
'--wait',
|
||||
'--json',
|
||||
]);
|
||||
const summary = JSON.parse(started.stdout.trim()) as {
|
||||
port: number;
|
||||
pid: number;
|
||||
url: string;
|
||||
};
|
||||
const recordPath = path.join(
|
||||
f.runtime,
|
||||
'daemon',
|
||||
`daemon-${summary.port}.json`,
|
||||
);
|
||||
const first = JSON.parse(await fs.readFile(recordPath, 'utf8')) as {
|
||||
conversationsDir: string;
|
||||
pid: number;
|
||||
};
|
||||
expect(first.conversationsDir).toBe(f.selected);
|
||||
f.env['QWEN_CONVERSATIONS_DIR'] = path.join(f.root, 'changed-env');
|
||||
await f.command(['daemon', 'restart', '--port', String(summary.port)]);
|
||||
const second = JSON.parse(await fs.readFile(recordPath, 'utf8')) as {
|
||||
conversationsDir: string;
|
||||
pid: number;
|
||||
};
|
||||
expect(second.pid).not.toBe(first.pid);
|
||||
expect(second.conversationsDir).toBe(f.selected);
|
||||
const sessionId = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const response = await post(
|
||||
{ base: summary.url, token },
|
||||
'/standalone/sessions',
|
||||
{ sessionId },
|
||||
);
|
||||
const session = await response.json();
|
||||
expect(response.status, JSON.stringify(session)).toBe(200);
|
||||
expect(path.dirname(session.projectlessOutputDirectory)).toBe(f.selected);
|
||||
await expect(
|
||||
fs.lstat(f.env['QWEN_CONVERSATIONS_DIR']),
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
}, 180_000);
|
||||
},
|
||||
);
|
||||
+1
-1
@@ -99,7 +99,7 @@
|
||||
"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:hosted:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --config ./vitest.hosted.config.ts",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./skill-hooks-resume.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/agent-loop-parity.test.ts ./cli/acp-agent-loop-parity.test.ts ./cli/hosted-harness-process.test.ts ./cli/daemon-invocation-context.test.ts ./cli/headless-workflow-skill.test.ts ./cli/workflow-ancestor-discovery.test.ts ./cli/daemon-workflow-ancestor-trust.test.ts ./cli/workflow-intent.test.ts ./cli/workflow-mode.test.ts ./cli/acp-workflow-mode.test.ts ./cli/daemon-workflow-intent.test.ts ./cli/daemon-workflow-mode.test.ts ./cli/list_directory.test.ts ./cli/tool-hook-context.test.ts ./cli/tool-hook-updated-input.test.ts ./cli/mods-prompt-submit.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-session-cd.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/qwen-serve-standalone-concurrency.test.ts ./helpers/hosted-sse-observer.test.ts ./interactive/workflow-completion.test.ts ./interactive/workflow-intent.test.ts ./interactive/workflow-mode.test.ts ./interactive/mods-tool-middleware.test.ts ./interactive/statusline-parity.test.ts ./interactive/tui-editor-parity.test.ts ./interactive/tui-input-ownership.test.ts ./interactive/ink-agent-loop-parity.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts ./sdk-typescript/workflow-intent.test.ts ./sdk-typescript/workflow-mode.test.ts",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./skill-hooks-resume.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/agent-loop-parity.test.ts ./cli/acp-agent-loop-parity.test.ts ./cli/hosted-harness-process.test.ts ./cli/daemon-invocation-context.test.ts ./cli/headless-workflow-skill.test.ts ./cli/workflow-ancestor-discovery.test.ts ./cli/daemon-workflow-ancestor-trust.test.ts ./cli/workflow-intent.test.ts ./cli/workflow-mode.test.ts ./cli/acp-workflow-mode.test.ts ./cli/daemon-workflow-intent.test.ts ./cli/daemon-workflow-mode.test.ts ./cli/list_directory.test.ts ./cli/tool-hook-context.test.ts ./cli/tool-hook-updated-input.test.ts ./cli/mods-prompt-submit.test.ts ./cli/qwen-serve-conversations-root.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-session-cd.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/qwen-serve-standalone-concurrency.test.ts ./helpers/hosted-sse-observer.test.ts ./interactive/workflow-completion.test.ts ./interactive/workflow-intent.test.ts ./interactive/workflow-mode.test.ts ./interactive/mods-tool-middleware.test.ts ./interactive/statusline-parity.test.ts ./interactive/tui-editor-parity.test.ts ./interactive/tui-input-ownership.test.ts ./interactive/ink-agent-loop-parity.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts ./sdk-typescript/workflow-intent.test.ts ./sdk-typescript/workflow-mode.test.ts",
|
||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 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.forks.maxForks 2 sdk-typescript",
|
||||
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Argv } from 'yargs';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
|
||||
const sanitizeTerminalText = vi.hoisted(() =>
|
||||
vi.fn((value: string) => value.replace(/\p{Cc}/gu, '')),
|
||||
@@ -16,6 +17,13 @@ const sanitizeTerminalText = vi.hoisted(() =>
|
||||
|
||||
vi.mock('../ui/utils/textUtils.js', () => ({ sanitizeTerminalText }));
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:child_process')>()),
|
||||
spawn: vi.fn(() => {
|
||||
throw new Error('unexpected daemon spawn');
|
||||
}),
|
||||
}));
|
||||
|
||||
// A record only names a process inside the namespace it was written in, so
|
||||
// the decision table is pinned against a fixed local namespace here.
|
||||
vi.mock('../serve/daemon-discovery.js', async (importOriginal) => {
|
||||
@@ -107,6 +115,44 @@ describe('qwen daemon command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects repeated Conversations roots before spawning a daemon', async () => {
|
||||
const tempDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-start-root-'),
|
||||
);
|
||||
const exitCode = process.exitCode;
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
vi.stubEnv('QWEN_HOME', tempDir);
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', tempDir);
|
||||
vi.mocked(spawn).mockClear();
|
||||
try {
|
||||
await yargs([])
|
||||
.exitProcess(false)
|
||||
.fail(false)
|
||||
.command(daemonCommand)
|
||||
.parseAsync([
|
||||
'daemon',
|
||||
'start',
|
||||
'--workspace',
|
||||
tempDir,
|
||||
'--conversations-dir',
|
||||
path.join(tempDir, 'root-a'),
|
||||
'--conversations-dir',
|
||||
path.join(tempDir, 'root-b'),
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(stderr.mock.calls.map(([text]) => String(text)).join('')).toBe(
|
||||
'qwen daemon: pass --conversations-dir once.\n',
|
||||
);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.exitCode = exitCode;
|
||||
stderr.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
await fsp.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
const cli = '/opt/qwen/index.js';
|
||||
const plan = (
|
||||
@@ -178,6 +224,38 @@ describe('qwen daemon command', () => {
|
||||
expect(result.stopArgs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves the recorded Conversations root over a changed restart environment', () => {
|
||||
const conversationsDir = path.resolve('persistent', 'conversations root');
|
||||
const result = plan(
|
||||
recorded({ conversationsDir }),
|
||||
{},
|
||||
{
|
||||
QWEN_CONVERSATIONS_DIR: path.resolve('different-root'),
|
||||
},
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
const optionIndex = result.startArgs!.indexOf('--conversations-dir');
|
||||
expect(optionIndex).toBeGreaterThan(-1);
|
||||
expect(result.startArgs![optionIndex + 1]).toBe(conversationsDir);
|
||||
expect(result.env?.['QWEN_CONVERSATIONS_DIR']).toBe(
|
||||
path.resolve('different-root'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps environment-based selection for older records without a Conversations root', () => {
|
||||
const conversationsDir = path.resolve('legacy-launch-root');
|
||||
const result = plan(
|
||||
recorded(),
|
||||
{},
|
||||
{
|
||||
QWEN_CONVERSATIONS_DIR: conversationsDir,
|
||||
},
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.startArgs).not.toContain('--conversations-dir');
|
||||
expect(result.env?.['QWEN_CONVERSATIONS_DIR']).toBe(conversationsDir);
|
||||
});
|
||||
|
||||
it('names the defaults it assumes for an older single-workspace record', () => {
|
||||
const none = plan(entry(4170).record);
|
||||
expect(none.error).toBeUndefined();
|
||||
@@ -624,6 +702,22 @@ describe('qwen daemon command', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards the Conversations root as one argument when starting a daemon', () => {
|
||||
const conversationsDir = path.resolve('persistent', 'conversations root');
|
||||
const { args } = buildDaemonStartArgs({
|
||||
entry: '/opt/qwen/index.js',
|
||||
port: 4170,
|
||||
hostname: '127.0.0.1',
|
||||
workspaces: ['/repo'],
|
||||
conversationsDir,
|
||||
web: true,
|
||||
requireAuth: false,
|
||||
});
|
||||
const optionIndex = args.indexOf('--conversations-dir');
|
||||
expect(optionIndex).toBeGreaterThan(-1);
|
||||
expect(args[optionIndex + 1]).toBe(conversationsDir);
|
||||
});
|
||||
|
||||
it('formats a start summary with the bound port', () => {
|
||||
const summary = formatDaemonStartSummary(entry(4170).record);
|
||||
expect(summary).toContain('started pid');
|
||||
|
||||
@@ -89,6 +89,7 @@ interface StartArgs {
|
||||
port?: number;
|
||||
hostname: string;
|
||||
workspace?: string | string[];
|
||||
'conversations-dir'?: string | string[];
|
||||
web: boolean;
|
||||
'require-auth': boolean;
|
||||
token?: string;
|
||||
@@ -566,6 +567,7 @@ export function buildDaemonStartArgs(options: {
|
||||
port: number;
|
||||
hostname: string;
|
||||
workspaces: readonly string[];
|
||||
conversationsDir?: string;
|
||||
web: boolean;
|
||||
requireAuth: boolean;
|
||||
}): { command: string; args: string[] } {
|
||||
@@ -579,6 +581,9 @@ export function buildDaemonStartArgs(options: {
|
||||
];
|
||||
if (!options.web) childArgs.push('--no-web');
|
||||
if (options.requireAuth) childArgs.push('--require-auth');
|
||||
if (options.conversationsDir !== undefined) {
|
||||
childArgs.push('--conversations-dir', options.conversationsDir);
|
||||
}
|
||||
for (const workspace of options.workspaces) {
|
||||
childArgs.push('--workspace', workspace);
|
||||
}
|
||||
@@ -717,6 +722,12 @@ async function printStartLogTail(file: string): Promise<void> {
|
||||
|
||||
async function handleStart(argv: StartArgs): Promise<void> {
|
||||
ignoreBrokenPipe();
|
||||
const conversationsDir = argv['conversations-dir'];
|
||||
if (Array.isArray(conversationsDir)) {
|
||||
writeStderrLine('qwen daemon: pass --conversations-dir once.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const entry = resolveCliEntry();
|
||||
if (!entry) {
|
||||
writeStderrLine(
|
||||
@@ -802,6 +813,7 @@ async function handleStart(argv: StartArgs): Promise<void> {
|
||||
port,
|
||||
hostname,
|
||||
workspaces,
|
||||
conversationsDir,
|
||||
web: argv.web,
|
||||
requireAuth: argv['require-auth'],
|
||||
});
|
||||
@@ -1450,6 +1462,9 @@ export function buildDaemonRestartPlan(
|
||||
record.hostname,
|
||||
'--wait',
|
||||
];
|
||||
if (record.conversationsDir !== undefined) {
|
||||
startArgs.push('--conversations-dir', record.conversationsDir);
|
||||
}
|
||||
for (const workspace of workspaces) {
|
||||
startArgs.push('--workspace', workspace);
|
||||
}
|
||||
@@ -1710,6 +1725,12 @@ const startCommand: CommandModule<unknown, StartArgs> = {
|
||||
describe:
|
||||
'Workspace path; repeat for multiple; defaults to the current directory',
|
||||
})
|
||||
.option('conversations-dir', {
|
||||
type: 'string',
|
||||
requiresArg: true,
|
||||
describe:
|
||||
'Absolute Conversations working-directory root (overrides QWEN_CONVERSATIONS_DIR)',
|
||||
})
|
||||
.option('web', {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
|
||||
@@ -63,6 +63,39 @@ function buildParser(): Argv {
|
||||
}
|
||||
|
||||
describe('serve command args', () => {
|
||||
it('accepts a Conversations root without changing the supplied path', () => {
|
||||
const root = '/persistent/Qwen Conversations ';
|
||||
expect(
|
||||
buildParser().parseSync(['--conversations-dir', root])[
|
||||
'conversations-dir'
|
||||
],
|
||||
).toBe(root);
|
||||
expect(buildParser().parseSync([])['conversations-dir']).toBeUndefined();
|
||||
expect(() => buildParser().parseSync('--conversations-dir')).toThrow(
|
||||
/Not enough arguments/u,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--conversations-dir', '/srv/a', '--conversations-dir', '/srv/b'],
|
||||
['--conversations-dir=/srv/a', '--conversations-dir=/srv/b'],
|
||||
])(
|
||||
'rejects repeated Conversations roots in %j before dispatch',
|
||||
(...args) => {
|
||||
const handler = vi.fn();
|
||||
const parser = yargs([])
|
||||
.exitProcess(false)
|
||||
.fail(false)
|
||||
.locale('en')
|
||||
.command({ ...serveCommand, handler });
|
||||
|
||||
expect(() => parser.parseSync(['serve', ...args])).toThrow(
|
||||
'pass --conversations-dir once.',
|
||||
);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('parses and isolates the Managed Web gateway deployment options', () => {
|
||||
const parsed = buildParser().parseSync(
|
||||
'--managed-web-config /etc/qwen-web/gateway.json --hostname 0.0.0.0 --port 4171 --allow-origin https://managed.example',
|
||||
@@ -83,6 +116,7 @@ describe('serve command args', () => {
|
||||
for (const flag of [
|
||||
'--managed',
|
||||
'--workspace=/run/secrets',
|
||||
'--conversations-dir=/persistent/conversations',
|
||||
'--profile=hosted-harness',
|
||||
'--enable-session-shell',
|
||||
'--channel=telegram',
|
||||
@@ -617,6 +651,23 @@ describe('serve rate limit env parsing', () => {
|
||||
expect(options).not.toHaveProperty('maxJournalBytes');
|
||||
});
|
||||
|
||||
it('forwards the explicit Conversations root to runQwenServe', async () => {
|
||||
process.env['QWEN_CONVERSATIONS_DIR'] = '/environment/conversations';
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: false,
|
||||
});
|
||||
await startServeHandlerWithArgs(
|
||||
'--conversations-dir "/persistent/Qwen Conversations" --no-web',
|
||||
);
|
||||
expect(mockRunQwenServe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationsDir: '/persistent/Qwen Conversations',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards pinned journal caps to runQwenServe', async () => {
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
|
||||
@@ -254,6 +254,7 @@ interface ServeArgs {
|
||||
'max-journal-events'?: number;
|
||||
'max-journal-bytes'?: number;
|
||||
workspace?: string | string[];
|
||||
'conversations-dir'?: string;
|
||||
'memory-project-scope'?: MemoryProjectScope;
|
||||
'require-auth': boolean;
|
||||
'enable-session-shell': boolean;
|
||||
@@ -400,6 +401,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
'Defaults to process.cwd() when omitted. ' +
|
||||
'Repeat to register isolated workspace runtimes; the first is primary.',
|
||||
})
|
||||
.option('conversations-dir', {
|
||||
type: 'string',
|
||||
requiresArg: true,
|
||||
description:
|
||||
'Absolute working-directory root shared by standalone and Live Conversations. Overrides QWEN_CONVERSATIONS_DIR; defaults to ~/Documents/Qwen Code/Conversations.',
|
||||
})
|
||||
.option('memory-project-scope', {
|
||||
type: 'string',
|
||||
choices: MEMORY_PROJECT_SCOPES,
|
||||
@@ -527,6 +534,9 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
'Allow unencrypted Agent Host HTTP connections outside loopback (trusted demo networks only).',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (Array.isArray(argv['conversations-dir'])) {
|
||||
throw new Error('pass --conversations-dir once.');
|
||||
}
|
||||
if (argv['local-control'] === true && argv['web'] === false) {
|
||||
throw new Error('Local Control requires the Web Shell.');
|
||||
}
|
||||
@@ -881,7 +891,8 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
argv.managed ||
|
||||
argv.profile !== 'default' ||
|
||||
!argv.web ||
|
||||
argv.workspace !== undefined
|
||||
argv.workspace !== undefined ||
|
||||
argv['conversations-dir'] !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
'--managed-web-config cannot be combined with daemon execution or Managed local mode.',
|
||||
@@ -916,6 +927,7 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
argv.profile !== 'default' ||
|
||||
!argv.web ||
|
||||
argv.workspace !== undefined ||
|
||||
argv['conversations-dir'] !== undefined ||
|
||||
argv.token !== undefined ||
|
||||
argv['tls-cert'] ||
|
||||
argv['tls-key'] ||
|
||||
@@ -1183,6 +1195,9 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
? { maxJournalBytes: argv['max-journal-bytes'] }
|
||||
: {}),
|
||||
workspace: argv.workspace,
|
||||
...(argv['conversations-dir'] !== undefined
|
||||
? { conversationsDir: argv['conversations-dir'] }
|
||||
: {}),
|
||||
...(argv['memory-project-scope'] !== undefined
|
||||
? { memoryProjectScope: argv['memory-project-scope'] }
|
||||
: {}),
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
hasLoadedEnvironmentValues,
|
||||
isFileSourcedEnvKey,
|
||||
loadEnvironment,
|
||||
preResolveHomeEnvOverrides,
|
||||
readProjectEnvFiles,
|
||||
reloadEnvironment,
|
||||
resetEnvironmentTrackingForTesting,
|
||||
resetHomeEnvBootstrapForTesting,
|
||||
SETTINGS_DIRECTORY_NAME,
|
||||
} from './environment.js';
|
||||
import {
|
||||
@@ -121,6 +123,8 @@ const TRACKED_ENV = [
|
||||
'QWEN_CODE_PENDING_COMPILE_CACHE',
|
||||
'QWEN_CODE_TRUSTED_FOLDERS_PATH',
|
||||
'QWEN_RUNTIME_DIR',
|
||||
'QWEN_CONVERSATIONS_DIR',
|
||||
'qwen_conversations_dir',
|
||||
'QWEN_SERVE_MAX_WORKSPACES',
|
||||
'QWEN_SERVER_TOKEN',
|
||||
'qwen_server_token',
|
||||
@@ -776,6 +780,99 @@ describe('daemon registration capacity environment', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Conversations root environment', () => {
|
||||
beforeEach(() => {
|
||||
resetHomeEnvBootstrapForTesting();
|
||||
resetEnvironmentTrackingForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHomeEnvBootstrapForTesting();
|
||||
resetEnvironmentTrackingForTesting();
|
||||
});
|
||||
|
||||
it.each(['.env', '.qwen/.env'])(
|
||||
'rejects root selection from project %s and settings.env',
|
||||
(file) => {
|
||||
const workspace = makeWorkspace();
|
||||
const envPath = path.join(workspace, file);
|
||||
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
envPath,
|
||||
'QWEN_CONVERSATIONS_DIR=/project/root\nqwen_conversations_dir=/project/lowercase\nRUNTIME_DOTENV=loaded\n',
|
||||
);
|
||||
const settings = testSettings({
|
||||
env: {
|
||||
QWEN_CONVERSATIONS_DIR: '/settings/root',
|
||||
qwen_conversations_dir: '/settings/lowercase',
|
||||
RUNTIME_SETTINGS: 'loaded',
|
||||
},
|
||||
});
|
||||
loadEnvironment(settings, workspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBeUndefined();
|
||||
expect(process.env['qwen_conversations_dir']).toBeUndefined();
|
||||
expect(process.env['RUNTIME_DOTENV']).toBe('loaded');
|
||||
expect(process.env['RUNTIME_SETTINGS']).toBe('loaded');
|
||||
const snapshot = buildRuntimeEnvironment(settings, workspace, {});
|
||||
expect(snapshot.effectiveEnv['QWEN_CONVERSATIONS_DIR']).toBeUndefined();
|
||||
expect(snapshot.effectiveEnv['qwen_conversations_dir']).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', '/persistent/Qwen Conversations '])(
|
||||
'preserves a user .env root %j despite a project .env',
|
||||
(root) => {
|
||||
const workspace = makeWorkspace();
|
||||
const qwenHome = path.join(os.homedir(), '.qwen');
|
||||
fs.mkdirSync(qwenHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(qwenHome, '.env'),
|
||||
`QWEN_CONVERSATIONS_DIR=${JSON.stringify(root)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(workspace, '.env'),
|
||||
'QWEN_CONVERSATIONS_DIR=/project/root\nRUNTIME_DOTENV=loaded\n',
|
||||
);
|
||||
preResolveHomeEnvOverrides();
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe(root);
|
||||
loadEnvironment(testSettings({}), workspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe(root);
|
||||
expect(process.env['RUNTIME_DOTENV']).toBe('loaded');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not replace an explicit empty launch value with user .env', () => {
|
||||
const workspace = makeWorkspace();
|
||||
process.env['QWEN_CONVERSATIONS_DIR'] = '';
|
||||
fs.writeFileSync(
|
||||
path.join(os.homedir(), '.env'),
|
||||
'QWEN_CONVERSATIONS_DIR=/home/root\n',
|
||||
);
|
||||
preResolveHomeEnvOverrides();
|
||||
loadEnvironment(testSettings({}), workspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the initial root when user .env is reloaded', () => {
|
||||
const workspace = makeWorkspace();
|
||||
const envPath = path.join(os.homedir(), '.env');
|
||||
fs.writeFileSync(envPath, 'QWEN_CONVERSATIONS_DIR=/initial/root\n');
|
||||
loadEnvironment(testSettings({}), workspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe('/initial/root');
|
||||
fs.writeFileSync(envPath, 'QWEN_CONVERSATIONS_DIR=/changed/root\n');
|
||||
const settings = testSettings({
|
||||
env: { QWEN_CONVERSATIONS_DIR: '/settings/root' },
|
||||
});
|
||||
reloadEnvironment(settings, workspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe('/initial/root');
|
||||
expect(
|
||||
buildRuntimeEnvironment(settings, workspace, {
|
||||
QWEN_CONVERSATIONS_DIR: '/initial/root',
|
||||
}).effectiveEnv['QWEN_CONVERSATIONS_DIR'],
|
||||
).toBe('/initial/root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRuntimeEnvironment', () => {
|
||||
it.each([false, true])(
|
||||
'stops at a symlinked home (home env: %s)',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isHardcodedProjectEnvExclusion,
|
||||
isLoaderEnvKey,
|
||||
isPrivateProvenanceEnvKey,
|
||||
keepsEmptyHomeEnvValue,
|
||||
PRIVATE_RELAUNCH_ENV_PROVENANCE,
|
||||
PROJECT_ENV_HARDCODED_EXCLUSIONS,
|
||||
reportRejectedLoaderKeys,
|
||||
@@ -248,7 +249,11 @@ function readHomeEnvInto(file: string): void {
|
||||
try {
|
||||
const parsed = dotenv.parse(fs.readFileSync(file, 'utf-8'));
|
||||
for (const key of HOME_ENV_BOOTSTRAP_KEYS) {
|
||||
if (parsed[key] && !Object.hasOwn(process.env, key)) {
|
||||
if (
|
||||
(parsed[key] ||
|
||||
(keepsEmptyHomeEnvValue(key) && parsed[key] !== undefined)) &&
|
||||
!Object.hasOwn(process.env, key)
|
||||
) {
|
||||
process.env[key] = parsed[key];
|
||||
}
|
||||
}
|
||||
@@ -818,7 +823,8 @@ export function loadEnvironment(
|
||||
|
||||
const existingValue = process.env[key];
|
||||
const isEffectivelyUnset =
|
||||
!Object.hasOwn(process.env, key) || existingValue === '';
|
||||
!Object.hasOwn(process.env, key) ||
|
||||
(existingValue === '' && !keepsEmptyHomeEnvValue(key));
|
||||
if (isEffectivelyUnset) {
|
||||
process.env[key] = envFile.parsedEnv[key];
|
||||
dotEnvSourcedKeys.add(key);
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ENV_ACP_REPEATED_TOOL_FAILURE_GUARD =
|
||||
export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [
|
||||
'QWEN_HOME',
|
||||
'QWEN_RUNTIME_DIR',
|
||||
// The shared Conversations root is selected by the operator at daemon boot.
|
||||
'QWEN_CONVERSATIONS_DIR',
|
||||
// Project reloads must not replace or relabel an operator container requirement.
|
||||
'QWEN_AGENT_EXECUTION_BACKEND',
|
||||
'QWEN_CODE_MCP_APPROVALS_PATH',
|
||||
@@ -377,10 +379,17 @@ export function isPrivateProvenanceEnvKey(key: string): boolean {
|
||||
export const HOME_ENV_BOOTSTRAP_KEYS = [
|
||||
'QWEN_HOME',
|
||||
'QWEN_RUNTIME_DIR',
|
||||
'QWEN_CONVERSATIONS_DIR',
|
||||
'QWEN_CODE_MCP_APPROVALS_PATH',
|
||||
'QWEN_CODE_TRUSTED_FOLDERS_PATH',
|
||||
] as const;
|
||||
|
||||
// An empty Conversations root is an explicit invalid selection. Preserve it
|
||||
// through environment loading so serve rejects it instead of using a fallback.
|
||||
export function keepsEmptyHomeEnvValue(key: string): boolean {
|
||||
return key.toUpperCase() === 'QWEN_CONVERSATIONS_DIR';
|
||||
}
|
||||
|
||||
// Loader-affecting variables inherited from the launching shell. A daemon or
|
||||
// ACP child needs them only for its own boot (e.g. the dev harness tsx
|
||||
// loader); left in process.env they propagate into session subprocesses whose
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as os from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
@@ -31,6 +31,11 @@ import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js';
|
||||
const plantOnExpectedInspect = vi.hoisted(() => ({ armed: false }));
|
||||
const enoentOnInspect = vi.hoisted(() => ({ armed: false }));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return { ...actual, platform: vi.fn(actual.platform) };
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
'../../utils/conversation-directory-identity.js',
|
||||
async (importOriginal) => {
|
||||
@@ -71,6 +76,7 @@ const cleanup: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(os.platform).mockReset();
|
||||
await Promise.all(
|
||||
cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
@@ -78,13 +84,78 @@ afterEach(async () => {
|
||||
|
||||
async function tempHome(): Promise<string> {
|
||||
const home = await mkdtemp(
|
||||
join(realpathSync.native(tmpdir()), 'qwen-live-home-'),
|
||||
join(realpathSync.native(os.tmpdir()), 'qwen-live-home-'),
|
||||
);
|
||||
cleanup.push(home);
|
||||
return home;
|
||||
}
|
||||
|
||||
describe('Live conversation workspace root', () => {
|
||||
it('uses an explicit root lazily and reuses its deterministic child after restart', async () => {
|
||||
const home = await tempHome();
|
||||
const rootPath = join(home, 'persistent files ');
|
||||
const workspace = new ConversationWorkspace({ homeDir: home, rootPath });
|
||||
expect(workspace.rootPath).toBe(rootPath);
|
||||
await expect(lstat(rootPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
const sessionId = '22345678-1234-4234-9234-123456789ABC';
|
||||
const child = await workspace.materializeConversationDirectory(sessionId);
|
||||
expect(dirname(child)).toBe(realpathSync.native(rootPath));
|
||||
await writeFile(join(child, 'persistent.txt'), 'kept');
|
||||
const restarted = new ConversationWorkspace({ rootPath });
|
||||
expect(
|
||||
await restarted.materializeConversationDirectory(sessionId.toLowerCase()),
|
||||
).toBe(child);
|
||||
expect(
|
||||
await restarted.ensureStandaloneDirectory(sessionId.toLowerCase()),
|
||||
).toMatchObject({
|
||||
status: 'ready',
|
||||
identity: { canonicalPath: child },
|
||||
});
|
||||
expect((await lstat(join(child, 'persistent.txt'))).isFile()).toBe(true);
|
||||
await expect(lstat(getConversationRootPath(home))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['', ' ', 'relative/root', '~/conversations', '/root\0invalid'])(
|
||||
'rejects invalid explicit root %j without falling back',
|
||||
(rootPath) => {
|
||||
expect(() => new ConversationWorkspace({ rootPath })).toThrow(
|
||||
/QWEN_CONVERSATIONS_DIR.*fully qualified absolute path/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['C:relative', '\\rooted', '/rooted', '\\\\server'])(
|
||||
'rejects Windows root %j that depends on the current drive or lacks a share',
|
||||
(rootPath) => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
expect(() => new ConversationWorkspace({ rootPath })).toThrow(
|
||||
/fully qualified absolute path/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps filesystem acceptance lazy and rejects an explicit symlink root', async () => {
|
||||
const home = await tempHome();
|
||||
const rootPath = join(home, 'configured');
|
||||
const target = join(home, 'target');
|
||||
await mkdir(target, { mode: 0o700 });
|
||||
await symlink(
|
||||
target,
|
||||
rootPath,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const workspace = new ConversationWorkspace({ homeDir: home, rootPath });
|
||||
await expect(workspace.getRoot()).rejects.toThrow(
|
||||
`Live conversation root ${JSON.stringify(rootPath)} must be a non-symlink directory; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`,
|
||||
);
|
||||
await expect(lstat(getConversationRootPath(home))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
|
||||
it('lazily creates the injected default root with a private canonical identity', async () => {
|
||||
const home = await tempHome();
|
||||
const workspace = new ConversationWorkspace({ homeDir: home });
|
||||
@@ -143,11 +214,14 @@ describe('Live conversation workspace root', () => {
|
||||
const permissiveRoot = getConversationRootPath(permissiveHome);
|
||||
await mkdir(permissiveRoot, { recursive: true, mode: 0o700 });
|
||||
await chmod(permissiveRoot, 0o755);
|
||||
await expect(
|
||||
new ConversationWorkspace({ homeDir: permissiveHome }).getRoot(),
|
||||
).rejects.toThrow(/only to its owner/);
|
||||
await expect
|
||||
.soft(new ConversationWorkspace({ rootPath: permissiveRoot }).getRoot())
|
||||
.rejects.toThrow(
|
||||
`Live conversation root ${JSON.stringify(permissiveRoot)} must be accessible only to its owner; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`,
|
||||
);
|
||||
|
||||
const ownerHome = await tempHome();
|
||||
const ownerRoot = getConversationRootPath(ownerHome);
|
||||
const getuid = process.getuid;
|
||||
if (!getuid) return;
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
@@ -159,9 +233,11 @@ describe('Live conversation workspace root', () => {
|
||||
value: () => getuid() + 1,
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
new ConversationWorkspace({ homeDir: ownerHome }).getRoot(),
|
||||
).rejects.toThrow(/owned by the daemon user/);
|
||||
await expect
|
||||
.soft(new ConversationWorkspace({ rootPath: ownerRoot }).getRoot())
|
||||
.rejects.toThrow(
|
||||
`Live conversation root ${JSON.stringify(ownerRoot)} must be owned by the daemon user; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`,
|
||||
);
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(process, 'getuid', originalDescriptor);
|
||||
@@ -194,6 +270,28 @@ describe('Live conversation workspace root', () => {
|
||||
await expect(workspace.revalidate()).rejects.toThrow(/identity changed/);
|
||||
});
|
||||
|
||||
it('identifies an invalid root during revalidation without changing standalone errors', async () => {
|
||||
const home = await tempHome();
|
||||
const rootPath = join(home, 'configured');
|
||||
const workspace = new ConversationWorkspace({ rootPath });
|
||||
const root = await workspace.getRoot();
|
||||
await rename(rootPath, `${rootPath}-old`);
|
||||
await writeFile(rootPath, 'not a directory');
|
||||
const message = `Live conversation root ${JSON.stringify(rootPath)} must be a non-symlink directory; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`;
|
||||
|
||||
await expect(revalidateConversationRoot(root)).rejects.toThrow(message);
|
||||
await expect(assertExactConversationRoot(root, rootPath)).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
await expect(
|
||||
workspace.inspectStandaloneDirectory('standalone'),
|
||||
).rejects.toMatchObject({
|
||||
name: 'ConversationDirectoryIdentityError',
|
||||
scope: 'root',
|
||||
reason: 'not_directory',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves Live filesystem errors while standalone keeps root scope', async (ctx) => {
|
||||
const liveHome = await tempHome();
|
||||
const liveWorkspace = new ConversationWorkspace({ homeDir: liveHome });
|
||||
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
type ConversationRootIdentity,
|
||||
} from '../../utils/conversation-directory-identity.js';
|
||||
import { normalizeSessionIdForLookup } from '../../config/session-id.js';
|
||||
import { isFullyQualifiedPath } from '../../config/storage-paths-lite.js';
|
||||
|
||||
export type { ConversationRootIdentity } from '../../utils/conversation-directory-identity.js';
|
||||
|
||||
export interface ConversationWorkspaceOptions {
|
||||
homeDir?: string;
|
||||
rootPath?: string;
|
||||
}
|
||||
|
||||
export type StandaloneDirectoryInspection =
|
||||
@@ -58,6 +60,7 @@ export type StandaloneDeletionPathsInspection =
|
||||
|
||||
function liveIdentityError(
|
||||
error: unknown,
|
||||
rootPath: string,
|
||||
exactRoot = false,
|
||||
creatingRoot = false,
|
||||
): never {
|
||||
@@ -69,15 +72,15 @@ function liveIdentityError(
|
||||
switch (error.reason) {
|
||||
case 'not_directory':
|
||||
throw new Error(
|
||||
'Live conversation root must be a non-symlink directory',
|
||||
`Live conversation root ${JSON.stringify(rootPath)} must be a non-symlink directory; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`,
|
||||
);
|
||||
case 'wrong_owner':
|
||||
throw new Error(
|
||||
'Live conversation root must be owned by the daemon user',
|
||||
`Live conversation root ${JSON.stringify(rootPath)} must be owned by the daemon user; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`,
|
||||
);
|
||||
case 'wrong_mode':
|
||||
throw new Error(
|
||||
'Live conversation root must be accessible only to its owner',
|
||||
`Live conversation root ${JSON.stringify(rootPath)} must be accessible only to its owner; check --conversations-dir / QWEN_CONVERSATIONS_DIR.`,
|
||||
);
|
||||
case 'canonical_path_changed':
|
||||
throw new Error('Live conversation root canonical path changed');
|
||||
@@ -128,7 +131,7 @@ export async function revalidateConversationRoot(
|
||||
try {
|
||||
return await revalidateConversationRootIdentity(root);
|
||||
} catch (error) {
|
||||
liveIdentityError(error);
|
||||
liveIdentityError(error, root.configuredRoot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +142,7 @@ export async function assertExactConversationRoot(
|
||||
try {
|
||||
return await assertExactConversationRootIdentity(root, candidate);
|
||||
} catch (error) {
|
||||
liveIdentityError(error, true);
|
||||
liveIdentityError(error, root.configuredRoot, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +151,20 @@ export class ConversationWorkspace {
|
||||
private rootPromise?: Promise<ConversationRootIdentity>;
|
||||
|
||||
constructor(options: ConversationWorkspaceOptions = {}) {
|
||||
this.rootPath = getConversationRootPath(options.homeDir);
|
||||
if (options.rootPath === undefined) {
|
||||
this.rootPath = getConversationRootPath(options.homeDir);
|
||||
} else {
|
||||
if (
|
||||
typeof options.rootPath !== 'string' ||
|
||||
!isFullyQualifiedPath(options.rootPath) ||
|
||||
options.rootPath.includes('\0')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Invalid Conversations directory (--conversations-dir / QWEN_CONVERSATIONS_DIR): expected a nonempty, fully qualified absolute path without NUL.',
|
||||
);
|
||||
}
|
||||
this.rootPath = resolve(options.rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
private getRootIdentity(): Promise<ConversationRootIdentity> {
|
||||
@@ -166,7 +182,7 @@ export class ConversationWorkspace {
|
||||
try {
|
||||
return await this.getRootIdentity();
|
||||
} catch (error) {
|
||||
liveIdentityError(error, false, true);
|
||||
liveIdentityError(error, this.rootPath, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +221,7 @@ export class ConversationWorkspace {
|
||||
)
|
||||
).identity.canonicalPath;
|
||||
} catch (error) {
|
||||
liveIdentityError(error);
|
||||
liveIdentityError(error, this.rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +244,7 @@ export class ConversationWorkspace {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
liveIdentityError(error);
|
||||
liveIdentityError(error, this.rootPath);
|
||||
}
|
||||
if (!identity) return false;
|
||||
try {
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('daemon discovery records', () => {
|
||||
it('validates the restart fields while keeping them optional', () => {
|
||||
const restartable = record({
|
||||
startupWorkspaces: ['/tmp/project'],
|
||||
conversationsDir: path.join(dir, 'Conversations'),
|
||||
web: false,
|
||||
requireAuth: true,
|
||||
});
|
||||
@@ -136,6 +137,24 @@ describe('daemon discovery records', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['', '.', 'relative/root', '~/Conversations', '/root\0suffix', 42])(
|
||||
'rejects an invalid Conversations root in a discovery record: %j',
|
||||
(conversationsDir) => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'daemon-4170.json'),
|
||||
JSON.stringify({ ...record(), conversationsDir }),
|
||||
);
|
||||
const { entries, invalid } = readDaemonDiscoveries(dir);
|
||||
expect(entries).toEqual([]);
|
||||
expect(invalid).toEqual([
|
||||
{
|
||||
path: path.join(dir, 'daemon-4170.json'),
|
||||
reason: 'invalid record',
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('removes a record only for the expected process', () => {
|
||||
const written = writeDaemonDiscovery(record(), dir);
|
||||
expect(
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
getGlobalQwenDirLite,
|
||||
isFullyQualifiedPath,
|
||||
resolveConfigPathLite,
|
||||
} from '../config/storage-paths-lite.js';
|
||||
|
||||
@@ -43,6 +44,8 @@ export interface DaemonDiscoveryRecord {
|
||||
* Absent on records written before this field existed.
|
||||
*/
|
||||
startupWorkspaces?: string[];
|
||||
/** Resolved configured Conversations root, retained across daemon restart. */
|
||||
conversationsDir?: string;
|
||||
/** Whether the Web Shell was enabled at startup. */
|
||||
web?: boolean;
|
||||
/** Whether `--require-auth` was set at startup. */
|
||||
@@ -232,6 +235,10 @@ function isValidRecord(value: unknown): value is DaemonDiscoveryRecord {
|
||||
record['startupWorkspaces'].every(
|
||||
(workspace) => typeof workspace === 'string' && workspace.length > 0,
|
||||
))) &&
|
||||
(record['conversationsDir'] === undefined ||
|
||||
(typeof record['conversationsDir'] === 'string' &&
|
||||
!record['conversationsDir'].includes('\0') &&
|
||||
isFullyQualifiedPath(record['conversationsDir']))) &&
|
||||
(record['web'] === undefined || typeof record['web'] === 'boolean') &&
|
||||
(record['requireAuth'] === undefined ||
|
||||
typeof record['requireAuth'] === 'boolean') &&
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isHardcodedProjectEnvExclusion,
|
||||
isLoaderEnvKey,
|
||||
isPrivateProvenanceEnvKey,
|
||||
keepsEmptyHomeEnvValue,
|
||||
reportRejectedLoaderKeys,
|
||||
} from '../config/shared-env-keys.js';
|
||||
import {
|
||||
@@ -115,7 +116,11 @@ function readHomeEnvIntoFastPath(file: string): void {
|
||||
try {
|
||||
const parsed = dotenv.parse(fs.readFileSync(file, 'utf8'));
|
||||
for (const key of HOME_ENV_BOOTSTRAP_KEYS) {
|
||||
if (parsed[key] && !Object.hasOwn(process.env, key)) {
|
||||
if (
|
||||
(parsed[key] ||
|
||||
(keepsEmptyHomeEnvValue(key) && parsed[key] !== undefined)) &&
|
||||
!Object.hasOwn(process.env, key)
|
||||
) {
|
||||
process.env[key] = parsed[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const originalQwenHome = process.env['QWEN_HOME'];
|
||||
const originalHome = process.env['HOME'];
|
||||
const originalUserProfile = process.env['USERPROFILE'];
|
||||
const originalQwenRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const originalConversationsDir = process.env['QWEN_CONVERSATIONS_DIR'];
|
||||
const originalMcpApprovalsPath = process.env['QWEN_CODE_MCP_APPROVALS_PATH'];
|
||||
const originalSystemSettingsPath =
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
@@ -367,6 +368,11 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalQwenRuntimeDir;
|
||||
}
|
||||
if (originalConversationsDir === undefined) {
|
||||
delete process.env['QWEN_CONVERSATIONS_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_CONVERSATIONS_DIR'] = originalConversationsDir;
|
||||
}
|
||||
if (originalMcpApprovalsPath === undefined) {
|
||||
delete process.env['QWEN_CODE_MCP_APPROVALS_PATH'];
|
||||
} else {
|
||||
@@ -531,6 +537,27 @@ describe('CLI entry import boundary', () => {
|
||||
});
|
||||
|
||||
describe('serve fast path argument parsing', () => {
|
||||
it.each([
|
||||
['--conversations-dir', '/persistent/Qwen Conversations '],
|
||||
['--conversations-dir=/persistent/Qwen Conversations '],
|
||||
])('preserves the Conversations root in %j', (...args) => {
|
||||
expect(parseServeFastPathArgs(['serve', ...args])).toMatchObject({
|
||||
kind: 'serve',
|
||||
options: { conversationsDir: '/persistent/Qwen Conversations ' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--conversations-dir'],
|
||||
['--conversations-dir='],
|
||||
['--conversations-dir', ''],
|
||||
['--conversations-dir=/one', '--conversations-dir=/two'],
|
||||
])('defers incomplete or repeated Conversations options %j', (...args) => {
|
||||
expect(parseServeFastPathArgs(['serve', ...args])).toEqual({
|
||||
kind: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the common daemon startup flags without loading the full CLI parser', () => {
|
||||
const parsed = parseServeFastPathArgs([
|
||||
'serve',
|
||||
@@ -701,6 +728,10 @@ describe('serve fast path argument parsing', () => {
|
||||
['max-journal-events', ['--max-journal-events', '10000']],
|
||||
['max-journal-bytes', ['--max-journal-bytes', '8388608']],
|
||||
['workspace', ['--workspace', process.cwd()]],
|
||||
[
|
||||
'conversations-dir',
|
||||
['--conversations-dir', '/persistent/conversations'],
|
||||
],
|
||||
['memory-project-scope', ['--memory-project-scope', 'workspace']],
|
||||
['require-auth', ['--require-auth']],
|
||||
['enable-session-shell', ['--enable-session-shell']],
|
||||
@@ -1127,6 +1158,69 @@ describe('serve fast path argument parsing', () => {
|
||||
});
|
||||
|
||||
describe('serve fast path environment bootstrap', () => {
|
||||
it.each(['', '/persistent/Qwen Conversations '])(
|
||||
'preserves a user .env Conversations root %j despite a project .env',
|
||||
async (root) => {
|
||||
const qwenHome = useTempQwenHome();
|
||||
delete process.env['QWEN_CONVERSATIONS_DIR'];
|
||||
tempWorkspace = realpathSync(
|
||||
mkdtempSync(join(os.tmpdir(), 'qws-fast-conversations-')),
|
||||
);
|
||||
writeFileSync(
|
||||
join(qwenHome, '.env'),
|
||||
`QWEN_CONVERSATIONS_DIR=${JSON.stringify(root)}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(tempWorkspace, '.env'),
|
||||
'QWEN_CONVERSATIONS_DIR=/project/root\nFAST_PATH_REFERENCED_TOKEN=loaded\n',
|
||||
);
|
||||
delete process.env['FAST_PATH_REFERENCED_TOKEN'];
|
||||
preResolveServeFastPathHomeEnvOverrides();
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe(root);
|
||||
await bootstrapServeFastPathEnvironment(tempWorkspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe(root);
|
||||
expect(process.env['FAST_PATH_REFERENCED_TOKEN']).toBe('loaded');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps an empty launch root instead of replacing it from home .env', async () => {
|
||||
const qwenHome = useTempQwenHome();
|
||||
process.env['QWEN_CONVERSATIONS_DIR'] = '';
|
||||
tempWorkspace = realpathSync(
|
||||
mkdtempSync(join(os.tmpdir(), 'qws-fast-conversations-empty-')),
|
||||
);
|
||||
writeFileSync(
|
||||
join(qwenHome, '.env'),
|
||||
'QWEN_CONVERSATIONS_DIR=/home/root\n',
|
||||
);
|
||||
await bootstrapServeFastPathEnvironment(tempWorkspace);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBe('');
|
||||
});
|
||||
|
||||
it.each(['.env', '.qwen/.env'])(
|
||||
'rejects a Conversations root from project %s and settings.env',
|
||||
(file) => {
|
||||
useTempQwenHome();
|
||||
delete process.env['QWEN_CONVERSATIONS_DIR'];
|
||||
tempWorkspace = realpathSync(
|
||||
mkdtempSync(join(os.tmpdir(), 'qws-fast-conversations-project-')),
|
||||
);
|
||||
const envPath = join(tempWorkspace, file);
|
||||
mkdirSync(dirname(envPath), { recursive: true });
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'QWEN_CONVERSATIONS_DIR=/project/root\nFAST_PATH_REFERENCED_TOKEN=loaded\n',
|
||||
);
|
||||
delete process.env['FAST_PATH_REFERENCED_TOKEN'];
|
||||
loadServeFastPathEnvironment(
|
||||
{ env: { QWEN_CONVERSATIONS_DIR: '/settings/root' } },
|
||||
tempWorkspace,
|
||||
);
|
||||
expect(process.env['QWEN_CONVERSATIONS_DIR']).toBeUndefined();
|
||||
expect(process.env['FAST_PATH_REFERENCED_TOKEN']).toBe('loaded');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the lite settings directory name in sync with core QWEN_DIR', () => {
|
||||
expect(SETTINGS_DIRECTORY_NAME).toBe(QWEN_DIR);
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@ const STRING_OPTION_BY_FLAG = new Map<string, keyof ServeOptions>([
|
||||
['hostname', 'hostname'],
|
||||
['token', 'token'],
|
||||
['workspace', 'workspace'],
|
||||
['conversations-dir', 'conversationsDir'],
|
||||
['memory-project-scope', 'memoryProjectScope'],
|
||||
['tls-cert', 'tlsCert'],
|
||||
['tls-key', 'tlsKey'],
|
||||
@@ -409,8 +410,8 @@ export function parseServeFastPathArgs(
|
||||
if (!read) return { kind: 'fallback' };
|
||||
i = read.nextIndex;
|
||||
if (
|
||||
stringTarget === 'workspace' &&
|
||||
(options.workspace !== undefined || read.value === '')
|
||||
(stringTarget === 'workspace' || stringTarget === 'conversationsDir') &&
|
||||
(options[stringTarget] !== undefined || read.value === '')
|
||||
) {
|
||||
return { kind: 'fallback' };
|
||||
}
|
||||
|
||||
@@ -4956,6 +4956,144 @@ describe('validatePolicyConfig (#4335 boot validation)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runQwenServe Conversations directory configuration', () => {
|
||||
let temporary: string;
|
||||
let workspace: string;
|
||||
let handle: RunHandle | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
temporary = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-conversations-root-')),
|
||||
);
|
||||
workspace = path.join(temporary, 'project');
|
||||
fs.mkdirSync(workspace);
|
||||
vi.stubEnv('HOME', temporary);
|
||||
vi.stubEnv('USERPROFILE', temporary);
|
||||
vi.stubEnv('QWEN_HOME', path.join(temporary, '.qwen'));
|
||||
vi.stubEnv('QWEN_CONVERSATIONS_DIR', undefined);
|
||||
resetServeFastPathHomeEnvBootstrapForTesting();
|
||||
settingsRuntime.resetHomeEnvBootstrapForTesting();
|
||||
vi.mocked(createDaemonUpdateRestarter).mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.close();
|
||||
handle = undefined;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
resetServeFastPathHomeEnvBootstrapForTesting();
|
||||
settingsRuntime.resetHomeEnvBootstrapForTesting();
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['default', 'environment', 'option'] as const)(
|
||||
'pins the %s root before asynchronous boot, without creating it, and preserves it for restart',
|
||||
async (source) => {
|
||||
const expectedRoot =
|
||||
source === 'default'
|
||||
? path.join(temporary, 'Documents', 'Qwen Code', 'Conversations')
|
||||
: path.join(temporary, `${source} conversations`);
|
||||
const laterRoot = path.join(temporary, 'later-root');
|
||||
vi.stubEnv(
|
||||
'QWEN_CONVERSATIONS_DIR',
|
||||
source === 'option'
|
||||
? 'invalid-relative-environment-root'
|
||||
: source === 'environment'
|
||||
? expectedRoot
|
||||
: undefined,
|
||||
);
|
||||
let selectedWorkspace: ConversationWorkspace | undefined;
|
||||
const originalCreateServeApp = serverModule.createServeApp;
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation((...args) => {
|
||||
selectedWorkspace = args[2]?.liveConversationWorkspace;
|
||||
return originalCreateServeApp(...args);
|
||||
});
|
||||
handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: 'localhost',
|
||||
mode: 'http-bridge',
|
||||
workspace,
|
||||
serveWebShell: false,
|
||||
...(source === 'option' ? { conversationsDir: expectedRoot } : {}),
|
||||
},
|
||||
{
|
||||
bridge: makeRuntimeBridge(),
|
||||
daemonLogBaseDir: path.join(temporary, 'debug'),
|
||||
updateRestartArgv: ['serve'],
|
||||
bindHostnameLookup: async () => {
|
||||
vi.stubEnv('QWEN_CONVERSATIONS_DIR', laterRoot);
|
||||
return { address: '127.0.0.1', family: 4 };
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(selectedWorkspace?.rootPath).toBe(expectedRoot);
|
||||
expect(fs.existsSync(expectedRoot)).toBe(false);
|
||||
expect(fs.existsSync(laterRoot)).toBe(false);
|
||||
expect(
|
||||
readDaemonDiscoveries().entries.find(
|
||||
(entry) => entry.record.port === Number(new URL(handle!.url).port),
|
||||
)?.record,
|
||||
).toMatchObject({ conversationsDir: expectedRoot });
|
||||
expect(createDaemonUpdateRestarter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
QWEN_CONVERSATIONS_DIR: expectedRoot,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect((await fetch(`${handle.url}/health`)).status).toBe(200);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['option', ''],
|
||||
['option', 'relative-root'],
|
||||
['option', null],
|
||||
['environment', ''],
|
||||
['environment', 'relative-root'],
|
||||
] as const)(
|
||||
'rejects invalid %s root %j before listening',
|
||||
async (source, root) => {
|
||||
if (source === 'environment') vi.stubEnv('QWEN_CONVERSATIONS_DIR', root!);
|
||||
const listen = vi.spyOn(net.Server.prototype, 'listen');
|
||||
await expect(
|
||||
runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace,
|
||||
...(source === 'option'
|
||||
? { conversationsDir: root as string }
|
||||
: {}),
|
||||
},
|
||||
{ bridge: makeRuntimeBridge() },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Conversations directory.*fully qualified absolute path/,
|
||||
);
|
||||
expect(listen).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('reserves the environment-selected root from ordinary workspace registration', async () => {
|
||||
const root = path.join(temporary, 'configured-root');
|
||||
fs.mkdirSync(root, { mode: 0o700 });
|
||||
vi.stubEnv('QWEN_CONVERSATIONS_DIR', root);
|
||||
const listen = vi.spyOn(net.Server.prototype, 'listen');
|
||||
await expect(
|
||||
runQwenServe({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: root,
|
||||
}),
|
||||
).rejects.toThrow(/reserved for Conversations/);
|
||||
expect(listen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test: verify daemon logger is initialized and written to
|
||||
* during `runQwenServe` boot + shutdown. Uses a fake bridge to avoid
|
||||
|
||||
@@ -201,6 +201,7 @@ import {
|
||||
} from './managed-scratch-workspace.js';
|
||||
import { ConversationRuntimeOwnershipError } from './conversations/conversation-runtime-errors.js';
|
||||
import { ConversationWorkspace } from './conversations/conversation-workspace.js';
|
||||
import { readEnvironmentVariable } from '../config/storage-paths-lite.js';
|
||||
import { LIVE_HOST_PROTOCOL_VERSION } from './live/types.js';
|
||||
import { ServeAppLifecycleController } from './serve-app-lifecycle.js';
|
||||
import {
|
||||
@@ -3524,6 +3525,14 @@ async function runQwenServeImpl(
|
||||
hostedCsiSessionStoreUrl,
|
||||
});
|
||||
const baseEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
const liveConversationWorkspace =
|
||||
deps.liveConversationWorkspace ??
|
||||
new ConversationWorkspace({
|
||||
rootPath:
|
||||
optsIn.conversationsDir !== undefined
|
||||
? optsIn.conversationsDir
|
||||
: readEnvironmentVariable(baseEnv, 'QWEN_CONVERSATIONS_DIR'),
|
||||
});
|
||||
const launchMemoryProjectScopeValue =
|
||||
baseEnv['QWEN_CODE_MEMORY_PROJECT_SCOPE'];
|
||||
const launchMemoryProjectScope = launchMemoryProjectScopeValue?.trim()
|
||||
@@ -3960,8 +3969,6 @@ async function runQwenServeImpl(
|
||||
// Resolve the bound workspace list. The first explicit workspace remains the
|
||||
// primary workspace for legacy APIs; later workspaces are isolated secondary
|
||||
// runtimes.
|
||||
const liveConversationWorkspace =
|
||||
deps.liveConversationWorkspace ?? new ConversationWorkspace();
|
||||
const isReservedConversationWorkspace = (candidate: string): boolean => {
|
||||
const resolvedCandidate = path.resolve(candidate);
|
||||
const resolvedRoot = path.resolve(liveConversationWorkspace.rootPath);
|
||||
@@ -4837,7 +4844,10 @@ async function runQwenServeImpl(
|
||||
const restartForUpdate = deps.updateRestartArgv
|
||||
? createDaemonUpdateRestarter({
|
||||
argv: deps.updateRestartArgv,
|
||||
env: daemonRuntimeBaseEnv,
|
||||
env: {
|
||||
...daemonRuntimeBaseEnv,
|
||||
QWEN_CONVERSATIONS_DIR: liveConversationWorkspace.rootPath,
|
||||
},
|
||||
token,
|
||||
externalToolGuardToken: optsIn.externalToolGuard?.token,
|
||||
managedRuntimeBrokerUrl: opts.managedRuntimeBrokerUrl,
|
||||
@@ -10907,6 +10917,7 @@ async function runQwenServeImpl(
|
||||
.filter((workspace) => !workspace.removable)
|
||||
.map((workspace) => workspace.cwd),
|
||||
primaryWorkspace: boundWorkspace,
|
||||
conversationsDir: liveConversationWorkspace.rootPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
auth: token ? 'bearer' : 'none',
|
||||
...(generatedToken ? { tokenGenerated: true } : {}),
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface ChannelWebhookConfigSource {
|
||||
export interface ServeOptions {
|
||||
hostname: string;
|
||||
port: number;
|
||||
/** Launcher-owned Conversations working root, fixed until daemon restart. */
|
||||
conversationsDir?: string;
|
||||
/** Deployment boundary for provider selection and exposed surfaces. */
|
||||
profile?: ServeProfile;
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,7 @@ delete process.env['SANDBOX_SET_UID_GID'];
|
||||
// ambient runtime root instead of its own temp dir. Deleting rather than
|
||||
// pinning: tests that want the variable set it in-body.
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_CONVERSATIONS_DIR'];
|
||||
|
||||
import { EXTRACT_NOOP_SKIP_TURNS_ENV } from '@qwen-code/qwen-code-core/memory/extract-cadence.js';
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ describe('no-AK integration CI wiring', () => {
|
||||
'./cli/tool-hook-context.test.ts',
|
||||
'./cli/tool-hook-updated-input.test.ts',
|
||||
'./cli/mods-prompt-submit.test.ts',
|
||||
'./cli/qwen-serve-conversations-root.test.ts',
|
||||
'./cli/qwen-serve-routes.test.ts',
|
||||
'./cli/qwen-serve-session-cd.test.ts',
|
||||
'./cli/qwen-serve-streaming.test.ts',
|
||||
|
||||
Reference in new issue
Block a user