Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e1644ba
feat(serve): allow relocating session attachment storage via env var
Aug 26, 2026
da506f7
fix(serve): keep attachment root resolver off fast path
Aug 26, 2026
97e64bc
fix(serve): harden session attachment fallback against degraded roots
Aug 26, 2026
789b808
Merge remote-tracking branch 'origin/main' into feat/session-attachme…
Aug 26, 2026
3b522e4
fix(serve): address round-2 review findings on session attachment sto…
Aug 26, 2026
3c4072a
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 27, 2026
539f0d7
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 27, 2026
6f42945
Merge remote-tracking branch 'origin/main' into feat/session-attachme…
qwen-code-dev-bot Aug 27, 2026
0f3e4a3
fix(cli): stop restore-probe tests depending on ambient host git stat…
qwen-code-dev-bot Aug 27, 2026
35298df
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 28, 2026
c0eedf8
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 28, 2026
027a2a5
Merge branch 'main' into feat/session-attachments-root-env
qwen-code-dev-bot Aug 28, 2026
6610d34
fix(acp): stabilize attachment fallback handling
Aug 28, 2026
5657260
Merge remote-tracking branch 'origin/main' into feat/session-attachme…
wenshao Aug 29, 2026
6589015
fix(acp): skip attachments being removed during copy
Aug 31, 2026
4106ef3
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 31, 2026
702c665
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions docs/developers/daemon/17-configuration.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,21 @@ 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.

### 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:

| Env var | Values | Default | What it does |
| ------------------------------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVE_SESSION_ATTACHMENTS_ROOT` | path | unset | Stores session attachments under this directory instead of the default runtime temp dir. Accepts an absolute path, a path relative to the daemon's cwd, or `~` / `~/…` expanded against the home directory. |

Scope and limits:

- **One-way migration.** When the env is set, new attachments are written only under the configured root. Reads and removes that miss the configured root fall back to the default runtime temp dir, so attachments uploaded **before** the switch remain readable and removable. The reverse direction — removing the env after attachments were written to the configured root — makes those attachments unreachable; keep the variable stable for a given workspace.
- **Per-session layout.** Files live under `<root>/<projectHash>/attachments/session-<sessionId>/` in both locations, where `<projectHash>` is the same workspace hash used by the default runtime temp dir; the fallback lookup uses the same session layout in the default dir. Two workspaces pointing at the same configured root stay isolated from each other.
- **Archive cleanup.** When a session is archived, its attachment directory is removed from both the configured root and the default fallback dir.
- The daemon reads the variable at startup; restart the daemon after changing it. The directory must be writable by the daemon process.

## Multi-session & multi-workspace deployment

Pass `--workspace` more than once to register several non-overlapping workspaces in one `qwen serve` process. The first path is primary. Each registered workspace owns an isolated runtime boundary, while the daemon-wide listener, authentication policy, and total-session limit are shared. Production attempts to preheat the primary ACP child for compatibility and retries on first use after failure; trusted secondaries start their own child on demand, and untrusted secondaries do not start ACP. Requests may select a registered workspace by canonical `cwd`; requests that omit `cwd` use the primary workspace. Use one daemon per user or security principal; workspace trust is an execution gate, not an ACL.
Expand Down
80 changes: 80 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13710,6 +13710,86 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('reads attachments from the fallback root when the primary misses', async () => {
const mainRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-main-'),
);
const fallbackRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-fallback-'),
);
const bridge = makeBridge({
sessionAttachmentsRoot: mainRoot,
sessionAttachmentsFallbackRoot: fallbackRoot,
channelFactory: async () => makeChannel({}).channel,
});
try {
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sessionDir = `session-${encodeURIComponent(session.sessionId)}`;
await fsp.mkdir(path.join(fallbackRoot, sessionDir), {
recursive: true,
});
await fsp.writeFile(
path.join(fallbackRoot, sessionDir, 'notes.txt'),
'legacy attachment',
);

const read = await bridge.readSessionAttachment(
session.sessionId,
'notes.txt',
{ clientId: session.clientId },
);
expect(read?.data.toString()).toBe('legacy attachment');
expect(read?.mimeType).toBe('text/plain');
} finally {
await bridge.shutdown();
await fsp.rm(mainRoot, { recursive: true, force: true });
await fsp.rm(fallbackRoot, { recursive: true, force: true });
}
});

it('deleteSessionAttachments clears both roots for a non-live session', async () => {
const mainRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-main-'),
);
const fallbackRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-fallback-'),
);
const bridge = makeBridge({
sessionAttachmentsRoot: mainRoot,
sessionAttachmentsFallbackRoot: fallbackRoot,
channelFactory: async () => makeChannel({}).channel,
});
try {
const sessionId = 'sess:unknown';
const sessionDir = `session-${encodeURIComponent(sessionId)}`;
await fsp.mkdir(path.join(mainRoot, sessionDir), { recursive: true });
await fsp.writeFile(
path.join(mainRoot, sessionDir, 'current.txt'),
'current',
);
await fsp.mkdir(path.join(fallbackRoot, sessionDir), {
recursive: true,
});
await fsp.writeFile(
path.join(fallbackRoot, sessionDir, 'notes.txt'),
'legacy',
);

await bridge.deleteSessionAttachments(sessionId);

await expect(
fsp.readdir(path.join(mainRoot, sessionDir)),
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(
fsp.readdir(path.join(fallbackRoot, sessionDir)),
).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await bridge.shutdown();
await fsp.rm(mainRoot, { recursive: true, force: true });
await fsp.rm(fallbackRoot, { recursive: true, force: true });
}
});

it('resolves text and binary file attachment references for ACP', async () => {
const prompts: PromptRequest[] = [];
const bridge = makeBridge({
Expand Down
8 changes: 7 additions & 1 deletion packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6035,6 +6035,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
attachments: new SessionAttachmentStore(
opts.sessionAttachmentsRoot,
sessionId,
opts.sessionAttachmentsFallbackRoot,
),
recordingDegraded: false,
closing: false,
Expand Down Expand Up @@ -9556,6 +9557,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const branchAttachments = new SessionAttachmentStore(
opts.sessionAttachmentsRoot,
result.newSessionId,
opts.sessionAttachmentsFallbackRoot,
);
try {
await branchAttachments.copyFrom(entry.attachments);
Expand Down Expand Up @@ -11220,7 +11222,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
async deleteSessionAttachments(sessionId, options) {
const store =
byId.get(sessionId)?.attachments ??
new SessionAttachmentStore(opts.sessionAttachmentsRoot, sessionId);
new SessionAttachmentStore(
opts.sessionAttachmentsRoot,
sessionId,
opts.sessionAttachmentsFallbackRoot,
);
await store.delete(options);
},

Expand Down
8 changes: 8 additions & 0 deletions packages/acp-bridge/src/bridgeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ export interface BridgeOptions {
* root. Direct embedded callers may omit it for process-local storage.
*/
sessionAttachmentsRoot?: string;
/**
* Fallback root for reading session attachments stored before
* `sessionAttachmentsRoot` was reconfigured (e.g. the previous default
* directory). Writes always go to `sessionAttachmentsRoot`; reads and
* removes that miss there consult this root so existing attachments
* survive a root switch.
*/
sessionAttachmentsFallbackRoot?: string;
/**
* `single` shares one session per workspace across HTTP
* clients (live-collaboration default); `thread` gives each `spawnOrAttach`
Expand Down
Loading
Loading