From 80a65571c0d9d287c445e8065895a840e4effeff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:34:00 +0800 Subject: [PATCH 1/7] fix(desktop): recover a session the engine no longer accepts The desktop window could strand on the engine's 401 denial page. The health probe counts a 401 as a live engine, so no host restart ever followed, and the plain text body carried no way back into the application. A browser document navigation now receives a minimal HTML page whose script reports the denial to the shell through the `dshDesktopBoot` bridge. The shell keeps the engine URL the host reported readiness with and navigates the window back to it, which re-mints the cookie from the launch token only the shell holds. Every other request keeps the plain text denial, and no denial response carries a token. No other CI lane compiles the Rust shell, so a new `windows-rust-check` job does, and it joins the PR verdict. --- .github/workflows/ci.yml | 47 +++++++++++++-- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src-tauri/src/lib.rs | 58 ++++++++++++++++++- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/browser-auth.ts | 31 +++++++++- .../tests/browser-auth.host.spec.ts | 21 +++++++ 10 files changed, 159 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03fd105cce40..fe17270ffd65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -668,6 +668,44 @@ jobs: packages/workflow/tool-ralph/tests/integration.spec.ts packages/subprocess/subprocess-local/tests/process-exit.spec.ts + # Every other lane in this workflow runs Node, so a regression in the Rust + # shell would otherwise first surface in the release build. The check needs + # neither a pnpm install nor a bundled runtime: the frontend the build + # embeds is checked in under src-tauri/frontend, and the sidecar the bundle + # table names is resolved by the packer rather than by this compile. + windows-rust-check: + if: github.event_name == 'pull_request' + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') + || 'windows-2025' }} + name: windows rust / shell check + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + # A cold check compiles the whole Tauri dependency tree. The in-house pool + # keeps its tool caches on the VM, and saving from it would poison the + # hosted cache namespace with self-hosted entries — the same guard the + # pnpm store restore carries. + - uses: actions/cache@v4 + if: vars.DSH_CI_FAILOVER_WINDOWS != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: | + ~/.cargo/registry + ~/.cargo/git + apps/desktop/src-tauri/target + key: ${{ runner.os }}-cargo-check-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-check- + - uses: dtolnay/rust-toolchain@stable + - name: Check the desktop shell + shell: pwsh + run: cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + windows-observational: if: github.event_name == 'pull_request' continue-on-error: true @@ -716,9 +754,10 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`. Native Windows build and process checks are required; Wine and - # the deferred Python runtime targets live in ci-master.yml and do not - # participate in this PR verdict. `needs` cannot cross workflow files. + # `needs`. Native Windows build and process checks are required, and so is + # the Rust check that compiles the desktop shell; Wine and the deferred + # Python runtime targets live in ci-master.yml and do not participate in + # this PR verdict. `needs` cannot cross workflow files. # An explicit status function runs the verdict after failed/skipped needs # without keeping a cancelled workflow alive for an obsolete verdict. all-checks-passed: @@ -738,7 +777,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests] + needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests, windows-rust-check] if: ${{ !cancelled() && github.event_name == 'pull_request' }} steps: - name: Fail if any needed job did not succeed diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index b2f61bb11780..becedfc0da5b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: d2042dd3edd64a898e33deea906f6a9d93f8c010 -README.zh.md: 3ee8dab7fa509877ac079d4e780b723fc4d21df8 +README.md: e14123f1613caff1c72d85130cfb069542bfba3d +README.zh.md: 86942b235c579dd39569a245889140347613b7af diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d2042dd3edd6..e14123f1613c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,6 +10,8 @@ The Host is a Node sidecar launched without a child-process IPC channel, so it r Boot proceeds in a fixed order. The main window opens on the packaged loader page. The host reader waits for a `ready` event carrying the engine URL, or for a `fatal` event carrying the cause. On readiness the shell publishes the boot payload, the engine's stream origin, to a window slot and to the current document, waits until the engine port accepts TCP connections, then navigates the window to the engine URL. The engine serves the document with its injection table already rendered, so the carrier never replays it. Every document in the window also receives an initialization script that installs the `dshDesktopBoot` bridge and its readiness gate, and the page-load hook publishes the held payload again into any document that loads later. The Web client awaits `dshDesktopBoot.ready()` before it starts and reports its own boot failures through the `alpha-boot-failed` window event, which the shell presents outside the Web page. +A session the engine no longer accepts is repaired by navigating rather than by restarting anything. A browser navigation without a valid cookie answers 401 with a minimal HTML page whose script calls `dshDesktopBoot.unauthorized(location.href)`; the shell listens for that `alpha-unauthorized` event, reads the engine URL it recorded when the host reported readiness, and navigates the window back to it. That URL carries the launch token of the host that is still serving, so the navigation re-mints the cookie and the application reloads. + A host that exits before reporting readiness, an engine port that still refuses connections after readiness, or a readiness event carrying an unreadable URL keeps the loader visible and opens one native dialog naming the cause. The shell exits once that dialog is dismissed, because nothing recovers a dead engine. A white window with no content is not a reachable state: the loader stays until the engine answers or the failure is reported. Window close requests send `{"type":"shutdown"}` on the host's stdin and wait up to ten seconds before killing the process. The single-instance guard focuses the existing window instead of starting a second process, so two Alpha processes never race on one profile. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 3ee8dab7fa50..86942b235c57 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,6 +10,8 @@ Host 是不带子进程 IPC 通道的 Node sidecar,通过 stdout 和 stdin 上 启动按固定顺序进行。主窗口先打开内置加载页。Host 读取线程等待携带引擎地址的 `ready` 事件,或携带原因的 `fatal` 事件。就绪后,壳把启动载荷(引擎的 stream 来源)写入窗口槽位和当前文档,等待引擎端口接受 TCP 连接,然后把窗口导航到引擎地址。引擎提供的文档已经渲染好自身的注入表,因此载体不再重放它。窗口内每个文档还会收到一个初始化脚本,安装 `dshDesktopBoot` 桥及其就绪门;页面加载钩子会把已持有的载荷再次发布给之后加载的文档。Web 客户端先等待 `dshDesktopBoot.ready()` 再启动,并通过 `alpha-boot-failed` 窗口事件上报自己的启动失败,由壳在 Web 页面之外展示。 +引擎不再接受的会话通过重新导航来修复,而不是重启任何东西。没有有效 cookie 的浏览器导航会得到一份 401 的极简 HTML 页面,其脚本调用 `dshDesktopBoot.unauthorized(location.href)`;壳监听这个 `alpha-unauthorized` 事件,读取 host 上报就绪时记录的引擎地址,再把窗口导航回去。该地址携带仍在服务的 host 的启动令牌,因此这次导航会重新签发 cookie,应用随之重新加载。 + Host 在报告就绪前退出、就绪后引擎端口仍拒绝连接、或就绪事件携带无法解析的地址时,加载页保持可见,并弹出一个说明原因的原生对话框。对话框被确认后壳退出,因为引擎已死无可恢复。没有内容的白色窗口不是可达状态:要么等到引擎应答,要么收到失败报告。 关闭窗口请求会向 Host 的 stdin 发送 `{"type":"shutdown"}`,最多等待十秒后才结束进程。单实例守卫会聚焦已有窗口而不是启动第二个进程,两个 Alpha 进程因此不会争抢同一个 profile。 diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c2326811f8f9..c563e38adedb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -17,6 +17,9 @@ //! document in this window receives an initialization script that installs the //! `dshDesktopBoot` bridge and the gate itself. //! +//! The same bridge reports a session the engine no longer accepts, so the shell navigates the +//! window back to the engine and mints a fresh one from the launch token only it holds. +//! //! Once the first engine is up, a watchdog supervises the engine for the rest of the session. //! It watches the host process, probes the engine's request loop, and replaces the host when //! either stops answering, so a machine that was suspended or had its lid closed never leaves @@ -69,6 +72,8 @@ const MAX_WAKE_PROBES: u32 = 3; const MAX_HOST_RESTARTS: u32 = 4; /// Window event through which the Web client reports a failed boot to the shell. const BOOT_FAILED_EVENT: &str = "alpha-boot-failed"; +/// Window event the engine's denial page reports a lost session through. +const UNAUTHORIZED_EVENT: &str = "alpha-unauthorized"; /// Window event the loader page observes for startup failures. const FATAL_EVENT: &str = "alpha-fatal"; @@ -84,7 +89,8 @@ const LOADER_READ_TIMEOUT_MS: u64 = 500; /// The shell resolves the readiness promise once it holds the engine payload, either by /// carrying the payload into the document before navigation or by resolving the deferred /// after the document loaded. The client reports its own failures back through a window -/// event so the shell can present them outside the Web page. +/// event so the shell can present them outside the Web page, and reports a session the +/// engine denies so the shell can navigate the window back to the engine. const BOOT_BRIDGE: &str = r#"(() => { const resolved = Promise.withResolvers(); const global = globalThis; @@ -102,6 +108,13 @@ const BOOT_BRIDGE: &str = r#"(() => { .catch(() => {}); } }, + unauthorized: (href) => { + const api = global.__TAURI__; + if (api !== undefined && api.event !== undefined && typeof api.event.emit === "function") { + void Promise.resolve(api.event.emit("alpha-unauthorized", { href: String(href) })) + .catch(() => {}); + } + }, saveMarkdown: (filename, content) => { const api = global.__TAURI__; if (api === undefined || api.core === undefined || typeof api.core.invoke !== "function") { @@ -138,6 +151,13 @@ struct EngineLaunch { #[derive(Default, Clone)] struct Supervision(Arc); +/// Engine URL the shell last navigated to. +/// +/// The URL carries the launch token of the host that is still serving, so it is the one +/// address that re-establishes a session the engine no longer accepts. +#[derive(Default)] +struct EngineUrl(Mutex>); + /// Boot payload handed to the Web client; written by the host reader before navigation. type BootPayload = Arc>>; @@ -166,6 +186,12 @@ struct BootFailurePayload { message: Option, } +/// One window event the engine's denial page reports when its session is gone. +#[derive(Deserialize)] +struct UnauthorizedPayload { + href: Option, +} + /// Save one Markdown transcript through a native save dialog. /// /// The dialog runs on a blocking worker because the native file dialog spins its own @@ -213,6 +239,7 @@ pub fn run() { .setup(|app| { app.manage(HostState::default()); app.manage(Supervision::default()); + app.manage(EngineUrl::default()); let handle = app.handle().clone(); match resolve_launch(&handle) { Ok((node, runtime, project)) => { @@ -467,6 +494,28 @@ fn build_window( let reporter = failure_handle.clone(); failure_handle.run_on_main_thread(move || report_fatal(&reporter, &message)).ok(); }); + let unauthorized_handle = handle.clone(); + window.listen(UNAUTHORIZED_EVENT, move |event: Event| { + // The engine denies a browser navigation that carries no session cookie, and only + // the shell holds the launch token that establishes one. The host that answered + // readiness last is still serving, so navigating to it again mints a fresh session. + let denied = serde_json::from_str::(event.payload()) + .ok() + .and_then(|reported| reported.href) + .unwrap_or_default(); + let held = unauthorized_handle + .try_state::() + .and_then(|state| state.0.lock().ok().and_then(|slot| slot.clone())); + let Some(target) = held else { return }; + eprintln!("alpha: engine denied {denied}; reopening {target}"); + let reporter = unauthorized_handle.clone(); + let _ = unauthorized_handle.run_on_main_thread(move || { + let Some(window) = reporter.get_webview_window("main") else { return }; + if let Err(error) = window.navigate(target) { + report_fatal(&reporter, &format!("Alpha could not reopen its engine: {error}")); + } + }); + }); Ok(window) } @@ -484,6 +533,13 @@ fn start_engine( ) -> Result { let reports = spawn_host(handle, launch)?; let url = await_ready(&reports)?; + // A document the engine denies must be able to return to the host that answered, so the + // shell keeps the URL that carries this host's launch token. + if let Some(state) = handle.try_state::() { + if let Ok(mut slot) = state.0.lock() { + *slot = Some(url.clone()); + } + } let held = payload_object(&url); let target = url.clone(); let navigator = window.clone(); diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 5c7dbbec7f57..8107ea53f2da 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: f1ac47c98416aeed0aaf3baf26cb7498dd48854f -README.zh.md: 7f3d9d1dc29c178290745e48f609694d6068208b +README.md: c50e2343cc381c10b1206f9c7dad9de5b5f45aea +README.zh.md: 6346a3a55bcffe7e6349bb88993e28982975f7fa diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f1ac47c98416..c50e2343cc38 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -34,7 +34,7 @@ The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/re ## Browser authentication and request trust -Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token. +Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. A browser document navigation receives a minimal HTML page instead, which reports the denial to the desktop shell so it can navigate the window back to the engine URL that carries the launch token; every other request keeps the plain text body, and no denial response carries a token. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token. The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$ALPHA_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 7f3d9d1dc29c..6346a3a55bcf 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" ## 浏览器认证与请求信任 -每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 +每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。浏览器文档导航会收到一份极简 HTML 页面,它把这次拒绝报告给桌面外壳,外壳再带着只有自己持有的启动令牌把窗口导航回引擎 URL;其余请求仍是纯文本响应,且任何拒绝响应都不携带令牌。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$ALPHA_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 74b39a65e3a7..ecc689473b79 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/packages/client/connection/src/browser-auth.ts @@ -17,6 +17,7 @@ const COOKIE_PREFIX = 'dsh-auth-' const COOKIE_PAYLOAD_VERSION = 1 const STORED_SECRET_VERSION = 1 const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ +const UNAUTHORIZED_MESSAGE = 'alpha web authentication required; reopen the URL printed by alpha web.' const PROCESS_LAUNCH_TOKENS = new WeakMap() interface StoredSecretPayload { @@ -77,6 +78,11 @@ function requestAuthority(headers: ConnectionTrustRequest['headers']): string | } } +/** Whether a request is a browser document navigation rather than a probe, script, or command-line call. */ +function isDocumentNavigation(req: ConnectionIndexRequest): boolean { + return req.method !== 'HEAD' && header(req.headers, 'sec-fetch-dest') === 'document' +} + function canonicalSecret(value: unknown): Buffer | undefined { if (typeof value !== 'string') return undefined const decoded = decodeBase64Url(value) @@ -177,6 +183,21 @@ async function initializeSecret(credentials: CredentialProvider): Promise' + + `

${UNAUTHORIZED_MESSAGE}

` + + '' + + '' +} + /** * Process launch-token exchange and persistent signed-cookie verification. * Connection loads the credential provider's signing secret during activation @@ -232,7 +253,7 @@ export class BrowserAuth { /** * Authenticate an index request. A valid root query token mints the cookie * and redirects to clean `/`; a valid cookie lets the caller serve the - * index; every other request receives the same minimal 401 response. + * index; every other request receives a 401 that reports the denial. * @param req - incoming root or configured-index request. * @param res - response owned when this method returns false. * @returns true only when the caller may serve index.html. @@ -302,12 +323,16 @@ export class BrowserAuth { } private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void { + // A browser navigation renders whatever the engine answers, so an unauthenticated one + // reports itself to the desktop shell, which can re-navigate with its launch token. + // Probes, scripts, and command-line callers keep the plain text body. + const navigation = isDocumentNavigation(req) res.writeHead(401, { 'cache-control': 'no-store', - 'content-type': 'text/plain; charset=utf-8', + 'content-type': navigation ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8', }) res.end(req.method === 'HEAD' ? undefined - : 'alpha web authentication required; reopen the URL printed by alpha web.\n') + : navigation ? unauthorizedDocument() : `${UNAUTHORIZED_MESSAGE}\n`) } } diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts index 37c816995fea..da3c99c3ebee 100644 --- a/packages/client/connection/tests/browser-auth.host.spec.ts +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -62,6 +62,7 @@ function createAuth( function request(url: string, authority = '127.0.0.1:3080', init?: { cookie?: string method?: string + secFetchDest?: string }): ConnectionIndexRequest { return { method: init?.method ?? 'GET', @@ -69,6 +70,7 @@ function request(url: string, authority = '127.0.0.1:3080', init?: { headers: { host: authority, ...init?.cookie === undefined ? {} : { cookie: init.cookie }, + ...init?.secFetchDest === undefined ? {} : { 'sec-fetch-dest': init.secFetchDest }, }, } } @@ -152,6 +154,8 @@ describe('BrowserAuth', () => { request('/?token=wrong'), request('/?token=wrong&token=again'), request('/index.html?token=wrong'), + request('/', '127.0.0.1:3080', { secFetchDest: 'document', method: 'HEAD' }), + request('/', '127.0.0.1:3080', { secFetchDest: 'empty' }), request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }), ]) { const denied = response() @@ -167,6 +171,23 @@ describe('BrowserAuth', () => { } }) + it('answers a browser document navigation with a page that reports the denial to the shell', async () => { + const auth = await createAuth(new RecordCredentials()) + const denied = response() + expect(auth.authorizeIndex( + request('/', '127.0.0.1:3080', { secFetchDest: 'document' }), + denied.value, + )).toBe(false) + expect(denied.state.status).toBe(401) + expect(denied.state.headers).toEqual({ + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8', + }) + expect(denied.state.body).toContain('alpha web authentication required; reopen the URL printed by alpha web.') + expect(denied.state.body).toContain('globalThis.dshDesktopBoot?.unauthorized(location.href)') + expect(denied.state.body).not.toContain('token=') + }) + it('rejects tampering, expiry, future issuance, and a longer lifetime than configured', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z')) From b173e4fdef1f215307231c9ca4cd687411fa7f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:34:45 +0800 Subject: [PATCH 2/7] fix(session-controller): declare the session-format-catalog dependency The foreign session readers stamp every converted header with the catalog's current version, but the manifest never listed the package that publishes it. Node then only resolved the import when some other consumer had already put a copy where the walk up from this package would find it, so a profile that mounted session-controller without that accident failed the row outright with ERR_MODULE_NOT_FOUND and took the whole composition down with it. The dependency joins the manifest, and the lockfile records the link. --- packages/api/session-controller/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 47dc65f42797..cba57c431df4 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -71,6 +71,7 @@ "dependencies": { "@mutantcat/dsh-brand": "workspace:^", "@mutantcat/dsh-deque": "workspace:^", + "@mutantcat/dsh-session-format-catalog": "workspace:^", "@mutantcat/schemastery": "workspace:^", "mime-types": "^3.0.2", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 265056c7d7a1..8ec8756b8ba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1045,6 +1045,9 @@ importers: '@mutantcat/dsh-deque': specifier: workspace:^ version: link:../../util/deque + '@mutantcat/dsh-session-format-catalog': + specifier: workspace:^ + version: link:../../session/session-format-catalog '@mutantcat/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery From 973c30a88bd2daec1f4f9d18b54310a7decbced4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:35:42 +0800 Subject: [PATCH 3/7] fix(session-controller): share the JSONL adapters' session scaffolding The Codex and Claude converters each walked their own file with the same loop, accumulating the same header fields and building the same event envelope, so every fix to one had to be made twice and one of the two already drifted: the id fallback split the path on `/` only, which on Windows turned a rollout file name into a whole absolute path. Both readers now share the record walk, the session accumulator and the event envelope, with the narrowing helpers they both reached for lifted into `json-records`. The shared fallback takes the base name and strips the suffix, and `node:sqlite` moves inside the functions that open the store so a process that never reads a ZCode session never loads the experimental module. The new spec pins what each writer contributes to a converted session, what a writer leaves out, and the abort and fallback paths. --- .../src/export-session-markdown.ts | 3 +- .../src/foreign-format-adapters.ts | 245 ++++++++++-------- .../session-controller/src/json-records.ts | 50 ++++ .../session-controller/src/zcode-sessions.ts | 29 +-- .../foreign-format-adapters.host.spec.ts | 167 ++++++++++++ .../tests/zcode-sessions.host.spec.ts | 2 +- .../api/session-controller/tsconfig.host.json | 1 + 7 files changed, 375 insertions(+), 122 deletions(-) create mode 100644 packages/api/session-controller/src/json-records.ts create mode 100644 packages/api/session-controller/tests/foreign-format-adapters.host.spec.ts diff --git a/packages/api/session-controller/src/export-session-markdown.ts b/packages/api/session-controller/src/export-session-markdown.ts index a98bc4856cad..746d6a14fe10 100644 --- a/packages/api/session-controller/src/export-session-markdown.ts +++ b/packages/api/session-controller/src/export-session-markdown.ts @@ -8,7 +8,6 @@ import type { Context } from '@mutantcat/cordis' import type { SessionEvent, SessionId } from '@mutantcat/dsh-session' -import type { SessionPersistence } from '@mutantcat/dsh-session-persistence' import { RemoteError } from '@mutantcat/dsh-typert-protocol' /** One exported message in the Markdown transcript. */ @@ -31,7 +30,7 @@ export async function exportSessionMarkdown( sessionId: SessionId, signal: AbortSignal, ): Promise { - const persistence = ctx.get('sessionPersistence') as SessionPersistence | undefined + const persistence = ctx.get('sessionPersistence') if (persistence === undefined) { throw new RemoteError('gateway/internal', 'no session-persistence backend is mounted', {}) } diff --git a/packages/api/session-controller/src/foreign-format-adapters.ts b/packages/api/session-controller/src/foreign-format-adapters.ts index f1eff09e88ff..b1cf2722f260 100644 --- a/packages/api/session-controller/src/foreign-format-adapters.ts +++ b/packages/api/session-controller/src/foreign-format-adapters.ts @@ -12,10 +12,11 @@ */ import { readdir, readFile } from 'node:fs/promises' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { SessionId, SessionLogOffset } from '@mutantcat/dsh-session' import type { SessionEvent, SessionHeader } from '@mutantcat/dsh-session' import { sessionFormatCatalog } from '@mutantcat/dsh-session-format-catalog' +import { jsonString, parseJsonlRecord } from './json-records.ts' import { isZcodeRoot, listZcodeSessions, readZcodeSession } from './zcode-sessions.ts' /** Supported foreign session formats. */ @@ -191,60 +192,36 @@ function convertCodexSession( sourcePath: string, signal?: AbortSignal, ): ForeignSessionContent { - let sessionId = '' - let createdAt = Date.now() - let cwd = '' - const events: SessionEvent[] = [] - let seq = 0 - - for (const line of lines) { - signal?.throwIfAborted() - let record: Record - try { - record = JSON.parse(line) - } catch { continue } + const session = newJsonlSession() + forEachJsonlRecord(lines, (record) => { const type = record['type'] as string const payload = record['payload'] as Record | undefined if (type === 'session_meta' && payload) { - sessionId = (payload['session_id'] as string) ?? (payload['id'] as string) ?? '' - const ts = record['timestamp'] as string | undefined - if (ts) createdAt = new Date(ts).getTime() - cwd = (payload['cwd'] as string) ?? '' + session.sessionId = jsonString(payload['session_id']) ?? jsonString(payload['id']) ?? '' + const ts = jsonString(record['timestamp']) + if (ts) session.createdAt = new Date(ts).getTime() + session.cwd = jsonString(payload['cwd']) ?? '' } else if (type === 'response_item' && payload) { - const event = convertCodexResponseItem(payload, seq, sourcePath) - if (event) { - events.push(event) - seq++ - } + const event = convertCodexResponseItem(payload, session.events.length) + if (event) session.events.push(event) } - } + }, signal) - if (sessionId === '') { - // Derive a session ID from the filename - const basename = sourcePath.split('/').pop() ?? '' - const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/) - sessionId = uuidMatch?.[1] ?? basename.replace(/\.jsonl$/, '') + if (session.sessionId === '') { + // A rollout name carries the session UUID the writer used everywhere else. + const fileName = sessionIdFromFileName(sourcePath) + session.sessionId = fileName.match(CODEX_SESSION_UUID)?.[1] ?? fileName } - const header: SessionHeader = { - version: sessionFormatCatalog.currentVersion as SessionHeader['version'], - id: SessionId(sessionId), - createdAt, - ...cwd === '' ? {} : { cwd }, - isSeeded: false, - delegationDepth: 0, - } - - return { header, events, inheritedEventCount: SessionLogOffset(0) } + return foreignContent(session.sessionId, session.createdAt, session.cwd, session.events) } /** Convert one Codex response_item to an Alpha event, or null to skip. */ function convertCodexResponseItem( payload: Record, seq: number, - _sourcePath: string, ): SessionEvent | undefined { const role = payload['role'] as string | undefined const content = payload['content'] @@ -252,18 +229,7 @@ function convertCodexResponseItem( if (role === 'user' || role === 'assistant') { const textContent = extractTextContent(content) if (textContent === '') return undefined - return { - type: role === 'user' ? 'user/message' : 'assistant/message', - data: { - message: { - role, - content: [{ type: 'text', text: textContent }], - source: role === 'user' ? { kind: 'user' } : { kind: 'assistant' }, - id: `imported-${seq}`, - }, - }, - surfaceOp: 'append', - } as unknown as SessionEvent + return importedMessageEvent(role, textContent, seq) } return undefined @@ -275,68 +241,33 @@ function convertClaudeSession( sourcePath: string, signal?: AbortSignal, ): ForeignSessionContent { - let sessionId = '' - let createdAt = Date.now() - let cwd = '' - const events: SessionEvent[] = [] - let seq = 0 - - for (const line of lines) { - signal?.throwIfAborted() - let record: Record - try { - record = JSON.parse(line) - } catch { continue } + const session = newJsonlSession() + forEachJsonlRecord(lines, (record) => { const type = record['type'] as string if (type === 'user' || type === 'assistant') { const message = record['message'] as Record | undefined - if (!message) continue + if (!message) return - if (sessionId === '') { - sessionId = (record['sessionId'] as string) ?? '' - const ts = record['timestamp'] as string | undefined - if (ts) createdAt = new Date(ts).getTime() - cwd = (record['cwd'] as string) ?? '' + if (session.sessionId === '') { + session.sessionId = jsonString(record['sessionId']) ?? '' + const ts = jsonString(record['timestamp']) + if (ts) session.createdAt = new Date(ts).getTime() + session.cwd = jsonString(record['cwd']) ?? '' } const role = message['role'] as string - const content = message['content'] - const textContent = extractTextContent(content) - if (textContent === '') continue - - events.push({ - type: role === 'user' ? 'user/message' : 'assistant/message', - data: { - message: { - role, - content: [{ type: 'text', text: textContent }], - source: role === 'user' ? { kind: 'user' } : { kind: 'assistant' }, - id: `imported-${seq}`, - }, - }, - surfaceOp: 'append', - } as unknown as SessionEvent) - seq++ - } - } + const textContent = extractTextContent(message['content']) + if (textContent === '') return - if (sessionId === '') { - const basename = sourcePath.split('/').pop() ?? '' - sessionId = basename.replace(/\.jsonl$/, '') - } + session.events.push(importedMessageEvent(role, textContent, session.events.length)) + } + }, signal) - const header: SessionHeader = { - version: sessionFormatCatalog.currentVersion as SessionHeader['version'], - id: SessionId(sessionId), - createdAt, - ...cwd === '' ? {} : { cwd }, - isSeeded: false, - delegationDepth: 0, - } + if (session.sessionId === '') session.sessionId = sessionIdFromFileName(sourcePath) - return { header, events, inheritedEventCount: SessionLogOffset(0) } + return foreignContent(session.sessionId, session.createdAt, session.cwd, session.events) } /** Extract text content from various content formats. */ @@ -358,3 +289,115 @@ function extractTextContent(content: unknown): string { } return '' } + +/** What one foreign JSONL adapter collects while walking a session file. */ +interface JsonlSession { + /** The id to import the session under, empty until a record carried one. */ + sessionId: string + /** When the session started, until a record carried a timestamp. */ + createdAt: number + /** The working directory the foreign writer recorded, if any. */ + cwd: string + /** The converted events, in the order the foreign writer wrote them. */ + events: SessionEvent[] +} + +/** The starting point every foreign JSONL session is read into. */ +function newJsonlSession(): JsonlSession { + return { sessionId: '', createdAt: Date.now(), cwd: '', events: [] } +} + +/** + * Hands every parsed line of a foreign JSONL session to a visitor. + * + * Foreign writers append to these files and never rewrite them, so a + * truncated final line is normal and is skipped rather than reported. + * + * @param lines - the non-empty lines of one foreign session file. + * @param visit - called once per line that carried a JSON object. + * @param signal - optional cancellation. + */ +function forEachJsonlRecord( + lines: readonly string[], + visit: (record: Record) => void, + signal?: AbortSignal, +): void { + for (const line of lines) { + signal?.throwIfAborted() + const record = parseJsonlRecord(line) + if (record) visit(record) + } +} + +/** + * Wrap one imported message as an Alpha session event. + * + * Only the role decides which of the two message surfaces an event lands on, + * so a role the foreign writer spelled anything other than `user` arrives as + * an assistant message. + * + * @param role - the foreign writer's role label. + * @param text - the flattened text of the message. + * @param seq - the event index within the imported session. + * @returns the event to append to the imported log. + */ +function importedMessageEvent(role: string, text: string, seq: number): SessionEvent { + return { + type: role === 'user' ? 'user/message' : 'assistant/message', + data: { + message: { + role, + content: [{ type: 'text', text }], + source: role === 'user' ? { kind: 'user' } : { kind: 'assistant' }, + id: `imported-${seq}`, + }, + }, + surfaceOp: 'append', + } as unknown as SessionEvent +} + +/** Session UUID a Codex rollout file name is built around. */ +const CODEX_SESSION_UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/ + +/** + * Derive a session id from a foreign session file name. + * + * A writer that recorded no id inside the file still names the file after the + * session, which leaves the name as the last resort. + * + * @param sourcePath - absolute path of the foreign session file. + * @returns the file name without its `.jsonl` suffix. + */ +function sessionIdFromFileName(sourcePath: string): string { + return basename(sourcePath).replace(/\.jsonl$/, '') +} + +/** + * Build the converted session both JSONL adapters return. + * + * The header carries only what the foreign writer recorded, so a session + * discovered without a working directory omits it instead of importing an + * empty one. + * + * @param sessionId - the id to import the session under. + * @param createdAt - when the foreign session started. + * @param cwd - the working directory the foreign writer recorded, if any. + * @param events - the converted events in foreign order. + * @returns the converted content with nothing inherited from an earlier log. + */ +function foreignContent( + sessionId: string, + createdAt: number, + cwd: string, + events: SessionEvent[], +): ForeignSessionContent { + const header: SessionHeader = { + version: sessionFormatCatalog.currentVersion as SessionHeader['version'], + id: SessionId(sessionId), + createdAt, + ...cwd === '' ? {} : { cwd }, + isSeeded: false, + delegationDepth: 0, + } + return { header, events, inheritedEventCount: SessionLogOffset(0) } +} diff --git a/packages/api/session-controller/src/json-records.ts b/packages/api/session-controller/src/json-records.ts new file mode 100644 index 000000000000..f758a4388eac --- /dev/null +++ b/packages/api/session-controller/src/json-records.ts @@ -0,0 +1,50 @@ +/** + * Reading untyped records out of foreign session files. + * + * Every foreign writer emits whatever shape its own version produced, so no + * field arriving from one can be trusted to exist or to carry the expected + * JSON type. The helpers below hold that narrowing in one place, letting each + * adapter describe its own schema while sharing the same safe reads. + * @module @mutantcat/dsh-api-session-controller/json-records + */ + +/** Narrow an unknown value to a plain object, else `{}`. */ +export function asObject(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +/** + * Parse a JSON string into a plain object. + * @param text - JSON text as stored in one foreign record field. + * @returns the parsed object, or `{}` when the text is malformed or not an object. + */ +export function parseJsonObject(text: string): Record { + try { + const parsed: unknown = JSON.parse(text) + return asObject(parsed) + } catch { return {} } +} + +/** + * Parse one JSONL line into a plain object. + * @param line - a single non-empty line of a foreign JSONL session file. + * @returns the parsed object, or `undefined` when the line is malformed or not an object. + */ +export function parseJsonlRecord(line: string): Record | undefined { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { return undefined } + return asObject(parsed) +} + +/** + * Read one string field out of a foreign record. + * @param value - the raw field value, of whatever JSON type the writer emitted. + * @returns the string, or `undefined` when the field is absent or not a string. + */ +export function jsonString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} diff --git a/packages/api/session-controller/src/zcode-sessions.ts b/packages/api/session-controller/src/zcode-sessions.ts index ae2eec700f85..8719bfdf20d7 100644 --- a/packages/api/session-controller/src/zcode-sessions.ts +++ b/packages/api/session-controller/src/zcode-sessions.ts @@ -14,15 +14,20 @@ * * The current store is opened read-only so importing never mutates the user's * ZCode database. Both generations are discovered from the same root. + * + * `node:sqlite` is imported inside the functions that open the store rather + * than at module scope, so a process that never imports a ZCode session never + * loads the module and never prints its experimental warning on Node 22. * @module @mutantcat/dsh-api-session-controller/zcode-sessions */ import { readdir, readFile } from 'node:fs/promises' import { basename, join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' +import type { DatabaseSync } from 'node:sqlite' import { SessionId, SessionLogOffset } from '@mutantcat/dsh-session' import type { SessionEvent, SessionHeader } from '@mutantcat/dsh-session' import { sessionFormatCatalog } from '@mutantcat/dsh-session-format-catalog' +import { asObject, parseJsonObject } from './json-records.ts' import type { ForeignSessionContent, ForeignSessionFile } from './foreign-format-adapters.ts' /** Current ZCode session store, relative to the ZCode data root. */ @@ -45,6 +50,7 @@ const WORKSPACE_HASH = /^[0-9a-f]{12}$/ */ export async function isZcodeRoot(root: string): Promise { try { + const { DatabaseSync } = await import('node:sqlite') new DatabaseSync(join(root, SQLITE_DB), { readOnly: true }).close() return true } catch { /* no readable current store */ } @@ -101,6 +107,7 @@ async function listSqliteSessions( files: ForeignSessionFile[], signal?: AbortSignal, ): Promise { + const { DatabaseSync } = await import('node:sqlite') let db: DatabaseSync try { db = new DatabaseSync(dbPath, { readOnly: true }) @@ -146,11 +153,12 @@ async function listLegacySessions( } /** Convert one SQLite-backed session to Alpha's format. */ -function readSqliteSession( +async function readSqliteSession( dbPath: string, sessionId: string, signal?: AbortSignal, -): ForeignSessionContent { +): Promise { + const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(dbPath, { readOnly: true }) try { const session = db @@ -280,18 +288,3 @@ function buildHeader(id: string, createdAt: number, cwd: string): SessionHeader delegationDepth: 0, } } - -/** Parse a JSON string into a plain object, or `{}` when it is not an object. */ -function parseJsonObject(text: string): Record { - try { - const parsed: unknown = JSON.parse(text) - return asObject(parsed) - } catch { return {} } -} - -/** Narrow an unknown value to a plain object, else `{}`. */ -function asObject(value: unknown): Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {} -} diff --git a/packages/api/session-controller/tests/foreign-format-adapters.host.spec.ts b/packages/api/session-controller/tests/foreign-format-adapters.host.spec.ts new file mode 100644 index 000000000000..8b63a9d878fc --- /dev/null +++ b/packages/api/session-controller/tests/foreign-format-adapters.host.spec.ts @@ -0,0 +1,167 @@ +/** + * Foreign session import for the two JSONL writers Alpha reads: Codex rollout + * files and Claude Code project logs. Both carry one JSON object per line, so + * these specs cover what each writer's records contribute to the converted + * header and events, and what a writer leaves out. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { sessionFormatCatalog } from '@mutantcat/dsh-session-format-catalog' +import type { SessionHeader } from '@mutantcat/dsh-session' +import type { ForeignSessionContent } from '../src/foreign-format-adapters.ts' +import { readForeignSession } from '../src/foreign-format-adapters.ts' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** Write `lines` as one foreign JSONL session file and return its path. */ +async function jsonlFile( + format: 'codex' | 'claude', + lines: readonly string[], + name = 'session.jsonl', +): Promise { + const root = await mkdtemp(join(tmpdir(), `foreign-${format}-`)) + roots.push(root) + const path = join(root, name) + await writeFile(path, `${lines.join('\n')}\n`, 'utf8') + return path +} + +/** The converted events of one imported session, as plain records. */ +function eventsOf(content: ForeignSessionContent): Record[] { + return content.events +} + +/** The version an imported session header is expected to carry. */ +function currentVersion(): SessionHeader['version'] { + return sessionFormatCatalog.currentVersion as SessionHeader['version'] +} + +describe('codex session import', () => { + it('converts session metadata and both message roles from a rollout file', async () => { + const path = await jsonlFile('codex', [ + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-01-02T03:04:05.000Z', + payload: { session_id: 'sess-codex', cwd: '/work/project' }, + }), + JSON.stringify({ type: 'response_item', payload: { role: 'user', content: 'fix the build' } }), + JSON.stringify({ type: 'response_item', payload: { role: 'assistant', content: [{ type: 'text', text: 'reading logs' }] } }), + JSON.stringify({ type: 'response_item', payload: { role: 'system', content: 'ignored' } }), + 'not json', + ]) + + const content = await readForeignSession({ path, format: 'codex' }) + expect(content.header).toEqual({ + version: currentVersion(), + id: 'sess-codex', + createdAt: Date.parse('2026-01-02T03:04:05.000Z'), + cwd: '/work/project', + isSeeded: false, + delegationDepth: 0, + }) + expect(content.inheritedEventCount).toBe(0) + expect(eventsOf(content)).toEqual([ + { type: 'user/message', data: { message: { role: 'user', content: [{ type: 'text', text: 'fix the build' }], source: { kind: 'user' }, id: 'imported-0' } }, surfaceOp: 'append' }, + { type: 'assistant/message', data: { message: { role: 'assistant', content: [{ type: 'text', text: 'reading logs' }], source: { kind: 'assistant' }, id: 'imported-1' } }, surfaceOp: 'append' }, + ]) + }) + + it('falls back to the rollout UUID, then to the file name, when no record carries an id', async () => { + const uuid = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b' + const lines = [JSON.stringify({ type: 'response_item', payload: { role: 'user', content: 'hello' } })] + + const fromUuid = await readForeignSession({ + path: await jsonlFile('codex', lines, `rollout-2026-01-02T03-04-05-${uuid}.jsonl`), + format: 'codex', + }) + expect(fromUuid.header.id).toBe(uuid) + + const fromName = await readForeignSession({ + path: await jsonlFile('codex', lines, 'rollout-no-uuid.jsonl'), + format: 'codex', + }) + expect(fromName.header.id).toBe('rollout-no-uuid') + }) + + it('ignores metadata the writer typed as something other than a string', async () => { + const path = await jsonlFile('codex', [ + JSON.stringify({ type: 'session_meta', payload: { session_id: 42, id: 'from-id', cwd: 7 } }), + JSON.stringify({ type: 'response_item', payload: { role: 'user', content: 'hello' } }), + ]) + + const content = await readForeignSession({ path, format: 'codex' }) + expect(content.header.id).toBe('from-id') + expect(content.header.cwd).toBeUndefined() + }) + + it('stops converting a session once the signal aborts part-way through', async () => { + const path = await jsonlFile('codex', [ + JSON.stringify({ type: 'response_item', payload: { role: 'user', content: 'first' } }), + JSON.stringify({ type: 'response_item', payload: { role: 'user', content: 'second' } }), + ]) + // Tolerates the entry check and the first record, then aborts. + let checks = 0 + const signal = { + throwIfAborted: () => { + if (checks++ >= 2) throw new Error('aborted') + }, + } as unknown as AbortSignal + + await expect(readForeignSession({ path, format: 'codex' }, signal)).rejects.toThrow('aborted') + }) +}) + +describe('claude session import', () => { + it('converts both roles and takes the header from the first record that carries one', async () => { + const path = await jsonlFile('claude', [ + JSON.stringify({ + type: 'user', + sessionId: 'sess-claude', + timestamp: '2026-02-03T04:05:06.000Z', + cwd: '/work/other', + message: { role: 'user', content: 'first' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'later-id', + cwd: '/work/changed', + message: { role: 'assistant', content: [{ type: 'text', text: 'second' }, { type: 'tool_use', name: 'Bash' }] }, + }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: '' } }), + ]) + + const content = await readForeignSession({ path, format: 'claude' }) + expect(content.header).toEqual({ + version: currentVersion(), + id: 'sess-claude', + createdAt: Date.parse('2026-02-03T04:05:06.000Z'), + cwd: '/work/other', + isSeeded: false, + delegationDepth: 0, + }) + expect(eventsOf(content)).toEqual([ + { type: 'user/message', data: { message: { role: 'user', content: [{ type: 'text', text: 'first' }], source: { kind: 'user' }, id: 'imported-0' } }, surfaceOp: 'append' }, + { type: 'assistant/message', data: { message: { role: 'assistant', content: [{ type: 'text', text: 'second' }], source: { kind: 'assistant' }, id: 'imported-1' } }, surfaceOp: 'append' }, + ]) + }) + + it('falls back to the file name and skips records without a message', async () => { + const path = await jsonlFile('claude', [ + JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } }), + JSON.stringify({ type: 'assistant' }), + JSON.stringify({ type: 'summary', message: { role: 'user', content: 'ignored' } }), + ], 'claude-session.jsonl') + + const content = await readForeignSession({ path, format: 'claude' }) + expect(content.header.id).toBe('claude-session') + expect(content.header.cwd).toBeUndefined() + expect(eventsOf(content)).toHaveLength(1) + }) +}) diff --git a/packages/api/session-controller/tests/zcode-sessions.host.spec.ts b/packages/api/session-controller/tests/zcode-sessions.host.spec.ts index 615e5d6f6d8f..df08fbcada0b 100644 --- a/packages/api/session-controller/tests/zcode-sessions.host.spec.ts +++ b/packages/api/session-controller/tests/zcode-sessions.host.spec.ts @@ -71,7 +71,7 @@ function writeSqliteStore(dbPath: string, sessions: readonly SqlSession[]): void /** The events of one converted session, as plain records. */ async function eventsOf(file: { path: string; sessionId?: string }): Promise[]> { const content = await readZcodeSession({ path: file.path, format: 'zcode', ...(file.sessionId === undefined ? {} : { sessionId: file.sessionId }) }) - return content.events as unknown as Record[] + return content.events } describe('zcode detection', () => { diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index b27a7943e542..fef95049d832 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -21,6 +21,7 @@ "src/zcode-sessions.ts", "src/history.ts", "src/import-harness-sessions.ts", + "src/json-records.ts", "src/list.ts", "src/media-references.ts", "src/model-selection-projection.ts", From 0e8948c0d9d4ba605d20691d35015a1ed27f9f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:35:57 +0800 Subject: [PATCH 4/7] fix(acp): await the settled tree before resolving the default model Sibling rows mount concurrently, so the settings section holding the user's default model can still be installing when a client's first session/new or session/resume lands. Reading the selection straight away then found nothing, the agent reached the loop with no model selected, and the loop failed on the `{{model}}` placeholder it had no value for. Both entry points now await the settled tree first, the same barrier the headless runner already applies before it reads a default. --- packages/acp/acp/src/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 37c5ad8b2cc5..1bc32eb969f4 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -114,10 +114,16 @@ export function apply(ctx: Context, config: AcpConfig): void { * neither starts sessions unselected so the loop fails loud. * @returns the resolved default selection, or undefined when none exists. */ - const deploymentSelection = (): ModelSelection | undefined => { + const deploymentSelection = async (): Promise => { if (config.provider !== undefined && config.model !== undefined) { return { provider: config.provider, model: config.model } } + // Sibling rows mount concurrently, so the settings section backing the user + // default can still be installing when a client's first session/new lands. + // Await the settled tree before reading it, as the headless runner does, + // or an unselected agent reaches the loop with no {{model}} to bind. + const loader = ctx.get('loader') as LoaderReadiness | undefined + await loader?.await() return agentDefaultModel.currentSelection() ?? undefined } @@ -218,7 +224,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // deployment that configures a roster has to join one here first // (@mutantcat/dsh-agent-presets README, "Composing a child agent"). let record: AcpSession - const selection = deploymentSelection() + const selection = await deploymentSelection() try { record = await AcpSession.create(ctx, { sessionId, @@ -270,7 +276,7 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams(`session cwd does not match: ${params.cwd}`) } let record: AcpSession - const selection = deploymentSelection() + const selection = await deploymentSelection() try { record = await AcpSession.resume(ctx, { sessionId, @@ -468,6 +474,11 @@ interface SessionListCursor { sessionId: string } +/** Settlement signal of the application tree, named without the loader dependency. */ +interface LoaderReadiness { + await(): Promise +} + /** Resolve and validate the deployment-owned session page limit. */ function resolveSessionListPageSize(value: number | undefined): number { const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE From ed3f32194b7c225526bbdf0b584da8c202d9bbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:36:08 +0800 Subject: [PATCH 5/7] ci: stage the sidecar path the rust check validates tauri-build asserts every externalBin path exists and copies it beside the crate output before a single crate compiles, and the only thing that ever puts a real Node sidecar there is prepare:tauri-runtime, which needs the whole workspace payload the check deliberately never installs. The lane therefore failed before it could compile anything. A check never launches the shell, so a stand-in file satisfies the assertion and keeps the lane free of pnpm. --- .github/workflows/ci.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe17270ffd65..1d75276c10b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -671,8 +671,10 @@ jobs: # Every other lane in this workflow runs Node, so a regression in the Rust # shell would otherwise first surface in the release build. The check needs # neither a pnpm install nor a bundled runtime: the frontend the build - # embeds is checked in under src-tauri/frontend, and the sidecar the bundle - # table names is resolved by the packer rather than by this compile. + # embeds is checked in under src-tauri/frontend. The packer is the only + # thing that ever downloads the sidecar named by the bundle table, but + # tauri-build's codegen asserts every externalBin path exists before it + # compiles, so the check stages a stand-in for it. windows-rust-check: if: github.event_name == 'pull_request' runs-on: >- @@ -702,6 +704,17 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-check- - uses: dtolnay/rust-toolchain@stable + # tauri-build asserts every externalBin path exists and copies it beside + # the crate output before a single crate compiles, and the only thing + # that ever puts a real Node sidecar there is prepare:tauri-runtime, + # which needs the whole workspace payload. A check never launches the + # shell, so a stand-in file is enough and keeps this lane free of pnpm. + - name: Stage the sidecar path the build script validates + shell: pwsh + run: | + $sidecar = 'apps/desktop/src-tauri/bin/node-x86_64-pc-windows-msvc.exe' + New-Item -ItemType Directory -Force (Split-Path -Parent $sidecar) | Out-Null + Set-Content -LiteralPath $sidecar -Value 'placeholder for prepare:tauri-runtime to replace' - name: Check the desktop shell shell: pwsh run: cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml From dbf2d0aa283eb7cc6747751dd5f1195d393e66ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:58:39 +0800 Subject: [PATCH 6/7] fix(session-controller): document the shared JSON record helpers The verify-export-jsdoc gate reads every exported name in a package API, so `asObject` reached the static lane without the `@param` and `@returns` lines the contract requires. The helper is shared by the foreign-format adapters this branch introduced, so the omission lands on the branch rather than on the package it came from. Describe the narrowing in the same terms the adapters use: the raw field value arrives as whatever JSON type the writer emitted, and the object comes back empty when the value is not one. --- packages/api/session-controller/src/json-records.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/api/session-controller/src/json-records.ts b/packages/api/session-controller/src/json-records.ts index f758a4388eac..91c13c75b6c3 100644 --- a/packages/api/session-controller/src/json-records.ts +++ b/packages/api/session-controller/src/json-records.ts @@ -8,7 +8,11 @@ * @module @mutantcat/dsh-api-session-controller/json-records */ -/** Narrow an unknown value to a plain object, else `{}`. */ +/** + * Narrow an unknown value to a plain object, else `{}`. + * @param value - the raw field value, of whatever JSON type the writer emitted. + * @returns the object, or `{}` when the value is not one. + */ export function asObject(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record From fac2ce6b80179376b6940e73639e6556d3f93c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:59:08 +0800 Subject: [PATCH 7/7] ci: stage the resource path the rust check validates The sidecar stand-in let the shell check compile, and tauri-build then stopped on the next assertion: `bundle.resources` maps `staging/runtime` into the bundle, and only prepare:tauri-runtime ever puts the payload there. That script needs the whole workspace payload the check deliberately never installs, so the lane stages the directory instead and stays free of pnpm. The real prepare step clears entries it does not own before it writes, so a directory left behind by a check cannot outlive the next real staging run. --- .github/workflows/ci.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d75276c10b5..09962c05ec8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,17 +704,18 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-check- - uses: dtolnay/rust-toolchain@stable - # tauri-build asserts every externalBin path exists and copies it beside - # the crate output before a single crate compiles, and the only thing - # that ever puts a real Node sidecar there is prepare:tauri-runtime, - # which needs the whole workspace payload. A check never launches the - # shell, so a stand-in file is enough and keeps this lane free of pnpm. - - name: Stage the sidecar path the build script validates + # tauri-build asserts every bundle resource exists before a single crate + # compiles, and the only thing that ever puts the sidecar and the runtime + # payload there is prepare:tauri-runtime, which needs the whole workspace + # payload. A check never launches the shell, so stand-ins satisfy the + # assertions and keep this lane free of pnpm. + - name: Stage the bundle paths the build script validates shell: pwsh run: | $sidecar = 'apps/desktop/src-tauri/bin/node-x86_64-pc-windows-msvc.exe' New-Item -ItemType Directory -Force (Split-Path -Parent $sidecar) | Out-Null Set-Content -LiteralPath $sidecar -Value 'placeholder for prepare:tauri-runtime to replace' + New-Item -ItemType Directory -Force 'apps/desktop/src-tauri/staging/runtime' | Out-Null - name: Check the desktop shell shell: pwsh run: cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml