From af4dece3a73a545d45e5a81948ac1374f9068ea1 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 10 Sep 2026 22:13:39 +0000 Subject: [PATCH] feat(sdk): add a peer endpoint so a program outside Qwen Code can join cross-session messaging (#11560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk): add a peer endpoint so a program outside Qwen Code can join cross-session messaging `@qwen-code/sdk/peer` is an implementation of the cross-session protocol for a program that is not itself a Qwen Code session — a voice front-end, a relay, a build watcher. `PeerEndpoint.start({ name })` binds an inbox and publishes a session record, so the program is listed by `qwen sessions ps` and by every session's `list_agents`; `list()` and `send()` address sessions by name; `onMessage` receives what they send; receipts are applied as state transitions and can be awaited. It is written from the protocol page with Node's own modules alone and shares no code with Qwen Code. That makes it the page's first independent implementation, and a conformance suite runs it against Qwen Code's own registry, inbox, directory and send path in both directions: listing and reaching each other, frames parsed and answered each way, a folded drop receipt settling a burst, a user-minted controller token recognised and a forged one not, identical verdicts on hostile lines, record files and names, and every published limit pinned to Qwen Code's constants. The endpoint takes the shared `.json` when it is free and a minted `-<8 hex>.json` when it is held, never replacing a record it cannot prove dead. Its inbox accepts only its own token, it never deletes records or sockets other processes wrote, and trust comes only from a controller token. On Linux it refuses to publish a record without a start token and PID namespace. Implementing the page found two rules it left implicit, now stated in "Writing your own record": `procStart` and `pidNs` are required on Linux, and a held `.json` means writing the minted name instead. Separate Node-only subpath, kept out of the browser-bundled entries; the build checks its size and that it carries no runtime dependency. * fix(sdk): present the controller token only on sends marked controller Review follow-ups on the peer endpoint. The controller token was presented on every send, so any process that published a record under a name the program then addressed read the plaintext of a credential Qwen Code only ever stores hashed. A send now presents it only when marked `controller: true`; unmarked sends — from an endpoint holding a token too — present the recipient's own token. On a marked send, two records for one session id and name are ambiguous rather than collapsed to the newer, so a copied record cannot take the grant. A malformed `controllerToken` (empty, no `qpc_` prefix) is rejected at start with `invalid-controller-token`, as is a marked send without one. Other fixes: - Two `start()` calls in one process raced for `.json` and one record was lost; registrations inside a process now run one at a time. - `send()` checks `closed` again after the directory read, so a send overlapping `close()` rejects instead of writing a dead reply address. - `ambiguous.matches` holds only addresses that select one session. - Receipt `reason` is flattened and bounded like every other label. - The local concurrent-send ceiling is marked `local` and described as this program's limit, not as a busy peer. - An explicit `socketPath` no longer re-permissions a directory the call did not create. - `probePeerSocket` is renamed `probePeerSocketVerdict`, matching core; the status list is the single source of the status type. - `flattenPeerLabel` matches control and format characters by Unicode category and caps in code points, in core as well, so the two implementations still agree. - The build checks every emitted peer declaration, from a module with tests of its own; the surface test is compiled by the typecheck and matches imports as path segments. Docs: `list_agents` and `send_message` need `agents.crossSessionMessaging` on the receiving session; delivery without review also follows a matching `fromMode`, and `agents.crossSessionInbound` outranks a controller grant; a controller send to another endpoint is dropped unread; the endpoint's inbox applies none of the protocol page's §6; the `socketPath` sibling fallback; and the protocol page gives separate reasons for `pidNs` and `procStart`. The design doc no longer says the default entry ships to browsers. Tests pin each of these, plus the paths the first round left unexercised: ledger and answered-id eviction past their bounds, start rollback, the Linux identity guard, registry directory tightening and read errors, the over-long line with the default deadline, the symlinked and over-long socket paths, prompt close, Windows, and a live PID with a mismatched start token. --- docs/design/2026-09-10-peer-endpoint-sdk.md | 151 +++ .../2026-09-10-peer-endpoint-sdk.zh-CN.md | 66 ++ docs/developers/sdk-typescript.md | 82 ++ docs/users/features/commands.md | 5 + docs/users/features/cross-session-protocol.md | 28 +- .../cli/src/ui/commands/peers-command.test.ts | 13 +- packages/core/src/ipc/peer-envelope.test.ts | 15 + packages/core/src/ipc/peer-envelope.ts | 27 +- packages/sdk-typescript/README.md | 82 ++ packages/sdk-typescript/package.json | 5 + packages/sdk-typescript/scripts/build.js | 31 + .../scripts/peer-build-assertions.js | 78 ++ packages/sdk-typescript/src/peer/client.ts | 222 +++++ packages/sdk-typescript/src/peer/directory.ts | 189 ++++ packages/sdk-typescript/src/peer/endpoint.ts | 860 ++++++++++++++++++ packages/sdk-typescript/src/peer/errors.ts | 45 + packages/sdk-typescript/src/peer/frames.ts | 346 +++++++ packages/sdk-typescript/src/peer/identity.ts | 141 +++ packages/sdk-typescript/src/peer/inbox.ts | 401 ++++++++ packages/sdk-typescript/src/peer/index.ts | 110 +++ packages/sdk-typescript/src/peer/label.ts | 89 ++ packages/sdk-typescript/src/peer/registry.ts | 419 +++++++++ .../test/unit/peer-build-assertions.test.ts | 63 ++ .../test/unit/peer-surface.test.ts | 91 ++ .../test/unit/peer/client.test.ts | 179 ++++ .../test/unit/peer/conformance.test.ts | 555 +++++++++++ .../test/unit/peer/directory.test.ts | 206 +++++ .../test/unit/peer/endpoint-identity.test.ts | 63 ++ .../test/unit/peer/endpoint.test.ts | 748 +++++++++++++++ .../test/unit/peer/frames.test.ts | 233 +++++ .../sdk-typescript/test/unit/peer/helpers.ts | 98 ++ .../test/unit/peer/inbox.test.ts | 324 +++++++ .../test/unit/peer/label.test.ts | 66 ++ .../test/unit/peer/registry.test.ts | 316 +++++++ .../sdk-typescript/tsconfig.test-fence.json | 5 +- 35 files changed, 6326 insertions(+), 26 deletions(-) create mode 100644 docs/design/2026-09-10-peer-endpoint-sdk.md create mode 100644 docs/design/2026-09-10-peer-endpoint-sdk.zh-CN.md create mode 100644 packages/sdk-typescript/scripts/peer-build-assertions.js create mode 100644 packages/sdk-typescript/src/peer/client.ts create mode 100644 packages/sdk-typescript/src/peer/directory.ts create mode 100644 packages/sdk-typescript/src/peer/endpoint.ts create mode 100644 packages/sdk-typescript/src/peer/errors.ts create mode 100644 packages/sdk-typescript/src/peer/frames.ts create mode 100644 packages/sdk-typescript/src/peer/identity.ts create mode 100644 packages/sdk-typescript/src/peer/inbox.ts create mode 100644 packages/sdk-typescript/src/peer/index.ts create mode 100644 packages/sdk-typescript/src/peer/label.ts create mode 100644 packages/sdk-typescript/src/peer/registry.ts create mode 100644 packages/sdk-typescript/test/unit/peer-build-assertions.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer-surface.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/client.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/conformance.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/directory.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/endpoint-identity.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/endpoint.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/frames.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/helpers.ts create mode 100644 packages/sdk-typescript/test/unit/peer/inbox.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/label.test.ts create mode 100644 packages/sdk-typescript/test/unit/peer/registry.test.ts diff --git a/docs/design/2026-09-10-peer-endpoint-sdk.md b/docs/design/2026-09-10-peer-endpoint-sdk.md new file mode 100644 index 00000000000..408d84495e6 --- /dev/null +++ b/docs/design/2026-09-10-peer-endpoint-sdk.md @@ -0,0 +1,151 @@ +# A peer endpoint for programs outside Qwen Code + +[English](2026-09-10-peer-endpoint-sdk.md) | [简体中文](2026-09-10-peer-endpoint-sdk.zh-CN.md) + +Status: implemented alongside this document. Adds `@qwen-code/sdk/peer`, a +standalone implementation of the cross-session protocol for a program that +is not a Qwen Code session. + +## Problem + +[Cross-Session Protocol](../users/features/cross-session-protocol.md) +describes everything a program outside Qwen Code must do to be found, +reached and answered: write a registry record, bind an inbox, present the +right token, frame its messages, and read receipts as state transitions. +Two things were missing. + +### Nobody had implemented the page + +Every reader and writer of the protocol was Qwen Code itself, and the page +was written from that code. Nothing showed the page was complete or +unambiguous. A rule that lived only in the code — how a record's file name +must agree with its PID, which token a receipt presents, when an inbox takes +a sibling socket name — could be missing from the page, and nothing would +notice until an outside program failed against a live session. + +### Every consumer would write the protocol by hand + +The first outside consumer, a voice front-end that directs a user's sessions +(#10118), needs exactly this: appear in the directory, send instructions as a +trusted controller, and hear back. Without a library it would reimplement +start tokens read from `/proc`, atomic record writes, socket placement, the +auth line, the message id grammar and the receipt state machine in its own +repository — each one a chance to disagree with the sessions it talks to. + +## Design + +**A second implementation, not a wrapper.** The module is written from the +protocol page using Node's own modules alone. Qwen Code's core is not a +runtime dependency of the SDK, and a wrapper around it would not have tested +the page anyway. The build refuses a bundle that contains any runtime +dependency or Qwen Code source. + +**Tested against Qwen Code, in both directions.** A conformance suite runs +the endpoint against Qwen Code's own registry, inbox, directory and send path +in one process. Each lists and reaches the other; frames sent each way are +parsed and answered; a folded drop receipt settles a burst; a controller +token minted the way a user mints one is recognised, and a forged one is not. +The same suite feeds hostile lines, record files and names to both +implementations and requires the same verdicts, and pins every limit the +page publishes to Qwen Code's constants. A disagreement there means one of +the implementations, or the page, is wrong. + +**Its own subpath, Node only.** `@qwen-code/sdk/peer` is a separate entry, +like the transports entry. The daemon entries are bundled for browsers, where +the endpoint's sockets cannot exist, and the default entry is what every +`query()` user loads; the endpoint is opt-in for both. + +**The shared record name when it is free.** An endpoint publishes +`.json`, the name every Qwen Code build with a registry can read. When +that name is held — by another endpoint in the same process, by a record from +another namespace or machine that collides on the PID, by a newer schema — it +publishes a minted `-<8 hex>.json` rather than overwrite something that +may be live. Only a record provably left by an earlier process with the same +PID is replaced. Claims inside one process run one at a time, so two +endpoints starting together cannot both take the shared name. + +**It finds, receives, sends and tracks — nothing more.** The inbox accepts +exactly one token, the endpoint's own. It keeps none of the review machinery +a Qwen Code session keeps for itself: no rate limits, no holds, no controller +grants. A program that joins the directory decides for itself what to do +with a message. It does remember the ids it has answered, so a sender that +retries an id gets the same answer instead of the program handling the +message twice. + +**It never deletes what others wrote.** Listing reads the directory and +applies the page's liveness rules, but never removes a record or a socket +file. Clearing out dead sessions is left to Qwen Code sessions; a program +that merely joined has no business removing other processes' files. + +**The controller token travels only where the caller sends it.** A program +has no review class it can honestly assert, so what it sends is held for +review by default. The user grants delivery by minting a controller token, +and the endpoint presents it in place of the recipient's own token — but only +on sends marked `controller: true`. The token works against every session in +the home and cannot be re-read from anywhere, since only its hash is stored, +while addresses resolve from records any program running as the user can +write; presenting it on every send would hand it to whoever published a +record answering to a name. On a marked send, two records for one session id +and name are ambiguous instead of collapsing to the newer, so a copied record +cannot quietly take the grant. The receiving session's +`agents.crossSessionInbound` setting still outranks the grant, and a +`fromMode` that matches the receiver's review class also delivers without +review — the documentation states both, and that `kind` and `name` buy +nothing. + +**Its lifetime follows the program.** The open inbox keeps the process +running, as a server would. Closing removes the record and the socket, and an +exit hook does the same on `process.exit`. The endpoint installs no signal +handlers, which would change how the program exits; a process killed without +closing leaves a record that Qwen Code sessions clear once its start token +proves the process gone. + +**On Linux, no record without its identity.** When the start token or the +PID namespace cannot be read, `start()` fails instead of publishing a record +that readers could neither trust nor ever clear away. + +## Changes to the protocol page + +Implementing the page from its text found two rules it left implicit. Both +are now stated in "Writing your own record": + +- On Linux, `pidNs` is required: every reader compares a record's `pidNs` + with its own, so a record without one is never listed and never swept. + `procStart` is required too, for a different reason: without it a reader + falls back to plain PID liveness and cannot tell a recycled PID from the + process that wrote the record. +- When `.json` is already taken, a writer replaces it only when it can + prove the file was left by an earlier process with the same PID — same + `pidNs`, same boot id, different start ticks — and otherwise writes + `-<8 hex>.json`. Readers accept both names, and the record in the way + may belong to a live process in another namespace or on another machine. + +The conformance suite found no disagreement between the page, Qwen Code and +the endpoint on anything else it covers. + +## What it does not do + +- Several endpoints behind one inbox. Two endpoints in one process each bind + their own socket and publish their own record. +- Windows: there is no socket placement for it yet, so `start()` fails with + `unsupported-platform`. +- Protection on the program's own inbox, such as rate limits or holds. +- Equivalents in the Python and Java SDKs. + +## Verification + +- Unit suites for frames, labels, registry reads and writes, the directory, + the client, the inbox and the endpoint. +- The conformance suite described above. +- A public-surface test that pins the subpath's exports and its + `package.json` entry. +- The build checks that the bundle stays within its budget and contains no + runtime dependency, and that none of its emitted declarations references an + internal package; those checks have tests of their own. + +## Follow-ups + +- Turn `agents.crossSessionMessaging` on by default, so a user's sessions + can be reached without a settings change. +- Inbound delivery to sessions a program drives over ACP. +- Windows named pipes, for Qwen Code sessions and this endpoint alike. diff --git a/docs/design/2026-09-10-peer-endpoint-sdk.zh-CN.md b/docs/design/2026-09-10-peer-endpoint-sdk.zh-CN.md new file mode 100644 index 00000000000..83641e57e81 --- /dev/null +++ b/docs/design/2026-09-10-peer-endpoint-sdk.zh-CN.md @@ -0,0 +1,66 @@ +# 面向 Qwen Code 之外程序的对等端点 + +[English](2026-09-10-peer-endpoint-sdk.md) | [简体中文](2026-09-10-peer-endpoint-sdk.zh-CN.md) + +状态:与本文档一同实现。新增 `@qwen-code/sdk/peer`:为不是 Qwen Code 会话的程序提供一份独立的跨会话协议实现。 + +## 问题 + +[Cross-Session Protocol](../users/features/cross-session-protocol.md) 描述了 Qwen Code 之外的程序要被发现、被联系、能收到回应所需做的一切:写注册记录、绑定 inbox、出示正确的令牌、按格式组帧、把回执当作状态迁移来读。但还缺两件事。 + +### 没有人实现过这一页 + +协议的每个读写方都是 Qwen Code 自己,这一页也是照着那份代码写的。没有任何东西证明它完整、没有歧义。只存在于代码里的规则——记录文件名如何与 PID 对应、回执出示哪个令牌、inbox 何时改用兄弟 socket 名——可能在文档里缺失,而直到某个外部程序在真实会话上失败之前都不会有人发现。 + +### 每个使用方都得手写协议 + +第一个外部使用方是一个指挥用户所有会话的语音前端(#10118),它需要的正是这些:出现在目录里、以受信 controller 身份发指令、收到回应。没有现成的库,它就得在自己的仓库里重新实现:从 `/proc` 读启动令牌、原子写记录、socket 放置、auth 行、消息 id 语法、回执状态机——每一处都是和对端会话产生分歧的机会。 + +## 设计 + +**第二份实现,而不是包装。** 该模块依据协议文档、只用 Node 自带模块编写。Qwen Code 的 core 不是 SDK 的运行时依赖,包装它也验证不了这一页。构建会拒绝任何含运行时依赖或 Qwen Code 源码的产物。 + +**与 Qwen Code 双向对测。** 一组一致性测试在同一进程里让端点和 Qwen Code 自己的注册表、inbox、目录与发送路径互相运行:双方都能列出并联系到对方;两个方向发送的帧都被解析并得到应答;一条合并的 dropped 回执能结算一批消息;按用户铸造方式得到的 controller 令牌会被认出,伪造的不会。同一组测试把恶意构造的帧、记录文件和名字喂给两份实现并要求判定一致,并把文档公布的每个上限钉到 Qwen Code 的常量上。这里出现分歧,意味着两份实现之一或文档本身有错。 + +**独立子路径,仅限 Node。** `@qwen-code/sdk/peer` 是单独的入口,形态与 transports 入口相同。daemon 入口会被打包进浏览器,端点需要的 socket 在那里不存在;默认入口则是每个 `query()` 使用者都会加载的。对两者而言端点都是可选引入的。 + +**共享记录名空闲时就用它。** 端点写 `.json`,这是所有带注册表的 Qwen Code 版本都读得懂的名字。该名字被占用时——被同进程的另一个端点、被另一个命名空间或机器上 PID 撞号的记录、被更新 schema 的记录——端点改写一个新铸的 `-<8 hex>.json`,而不去覆盖可能仍然存活的记录。只有能证明是同 PID 的更早进程遗留的记录才会被替换。同一进程内的认领逐个进行,因此两个同时启动的端点不会都拿到共享名。 + +**只做发现、接收、发送与跟踪。** inbox 只接受一个令牌,即端点自己的。它不带 Qwen Code 会话为保护自己而设的审阅机制:没有限流、没有 hold、没有 controller 授权。加入目录的程序自己决定如何处理收到的消息。它会记住已应答过的消息 id,这样发送方重试同一 id 时得到同样的答复,而不是让程序把消息处理两遍。 + +**从不删除别人写的东西。** 列举时读目录并应用文档里的存活规则,但从不删除任何记录或 socket 文件。清理已死会话的记录是 Qwen Code 会话的事;一个只是加入目录的程序无权删除其他进程的文件。 + +**controller 令牌只随调用方指定的发送出去。** 程序没有能如实声明的审阅类别,所以它发出的消息默认会被 hold 等待审阅。用户通过铸造 controller 令牌授权投递,端点用它代替接收方自己的令牌出示——但只在标记了 `controller: true` 的发送上。令牌对 home 里的每个会话都有效,且只存哈希、无处可重读;而地址是从任何以该用户身份运行的程序都能写的记录里解析出来的,每次发送都出示令牌就等于把它交给任何发布了同名记录的进程。在标记的发送上,同一会话 id 与名字的两条记录视为有歧义,而不是折叠为较新的那条,这样复制出来的记录无法悄悄截走授权。接收会话的 `agents.crossSessionInbound` 设置仍然优先于授权;与接收方审阅类别一致的 `fromMode` 同样会免审投递——文档把这两点都写明了,也写明 `kind` 和 `name` 换不来任何东西。 + +**生命周期跟随程序。** 打开的 inbox 会让进程保持运行,就像一个服务器。关闭时删除记录和 socket,退出钩子在 `process.exit` 时做同样的事。端点不安装信号处理器,因为那会改变程序的退出方式;未关闭就被杀掉的进程会留下记录,Qwen Code 会话在其启动令牌证明进程已不在时将其清除。 + +**在 Linux 上,没有身份就不写记录。** 读不到启动令牌或 PID 命名空间时,`start()` 直接失败,而不是发布一条读者既无法信任、也永远清理不掉的记录。 + +## 对协议文档的修改 + +照文档文本实现时发现有两条规则文档没有明说,现已写进 "Writing your own record": + +- 在 Linux 上,`pidNs` 是必需的:每个读者都会拿记录的 `pidNs` 和自己的比较,缺少它的记录永远不会被列出,也永远不会被清扫。`procStart` 同样必需,但理由不同:没有它,读者只能退回到单纯判断 PID 是否存活,无法区分被复用的 PID 与写入记录的原进程。 +- `.json` 已被占用时,写入方只有在能证明该文件是同 PID 的更早进程遗留的——`pidNs` 相同、boot id 相同、start ticks 不同——才替换它,否则改写 `-<8 hex>.json`。读者两种名字都接受,而挡路的那条记录可能属于另一命名空间或另一台机器上的活进程。 + +在一致性测试覆盖到的其余方面,文档、Qwen Code 与端点之间没有发现分歧。 + +## 不做的事 + +- 多个端点共用一个 inbox。同一进程里的两个端点各自绑定 socket、各自发布记录。 +- Windows:目前没有对应的 socket 放置方案,`start()` 以 `unsupported-platform` 失败。 +- 为程序自己的 inbox 提供限流、hold 等保护。 +- Python 与 Java SDK 中的对应实现。 + +## 验证 + +- 帧、标签、注册表读写、目录、客户端、inbox 与端点的单元测试。 +- 上文描述的一致性测试。 +- 钉住该子路径导出与 `package.json` 条目的公共面测试。 +- 构建时检查产物不超预算、不含任何运行时依赖,且生成的每个类型声明都不引用任何内部包;这些检查本身也有测试。 + +## 后续工作 + +- 默认开启 `agents.crossSessionMessaging`,让用户的会话无需改设置即可被联系。 +- 程序经 ACP 驱动的会话的入站投递。 +- Windows 命名管道,Qwen Code 会话与本端点同时支持。 diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index 5139081085f..d8487f70bff 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -185,6 +185,88 @@ The SDK requires the daemon's `session_id_override` capability before sending th This option always creates a new thread session and is not an idempotent attach. If the create outcome is ambiguous, use the known ID with load or resume. Omitting the option preserves the existing create-or-attach behavior. +## Talking to running sessions + +`@qwen-code/sdk/peer` lets a program that is not a Qwen Code session join the +sessions running as the same user on the same machine — a voice front-end, a +relay, a build watcher. The program shows up in `qwen sessions ps`, and in the +`list_agents` of every session that has `agents.crossSessionMessaging` turned +on — which is also what lets those sessions message it by name with +`send_message`. It can message them back. It runs on Node only and needs +nothing beyond Node itself. + +```typescript +import { PeerEndpoint } from '@qwen-code/sdk/peer'; + +const endpoint = await PeerEndpoint.start({ + name: 'voice-bridge', + onMessage: (message) => + console.log(`${message.fromName}: ${message.content}`), +}); + +const [session] = await endpoint.list(); +if (session) { + const sent = await endpoint.send({ + to: session.address, + content: 'What are you working on?', + }); + if (sent.kind === 'sent') { + const receipt = await endpoint.awaitReceipt(sent.msgId, { final: true }); + console.log(receipt?.status); // delivered, denied, refused, ... + } +} + +await endpoint.close(); +``` + +A message like that is held for the session's user to review. To direct a +session without that review, mint a controller token with +`qwen sessions controllers add --label voice-bridge`, give it to the endpoint, +and mark the sends that should present it: + +```typescript +const endpoint = await PeerEndpoint.start({ + name: 'voice-bridge', + controllerToken: process.env['QWEN_CONTROLLER_TOKEN'], +}); +await endpoint.send({ + to: 'my-app-3f', + content: 'run the tests', + controller: true, +}); +``` + +Things to know: + +- A session has an inbox only while its `agents.crossSessionMessaging` setting + is on, and the setting is off by default. Without it the session does not + appear in `list()`, and its own `list_agents` and `send_message` cannot see + or reach the program either. `qwen sessions ps` lists the program regardless. +- A message is delivered without review in exactly two cases: the send + presents a controller token (`controller: true`), or its `fromMode` names the + receiving session's own review class. `fromMode` is a claim nothing + authenticates, so a program that is not a coding session should leave it + out. Nothing in the record — not `kind`, not `name` — buys delivery. The + receiving session's `agents.crossSessionInbound` setting outranks both: + `hold` or `refuse` there wins over a controller token. +- Mark only the sends meant to direct a session. Addresses are resolved from + records any program running as you can write, so a controller send presents + the token to whichever process's record answers to that address. A + controller send to another peer endpoint is dropped unread, because an + endpoint's inbox accepts only its own token. +- The endpoint's inbox applies none of the protections a Qwen Code session + applies to its own: no rate limit, no holds, and no duplicate window beyond + the last 200 messages it answered. Every message is answered `delivered` and + handed to `onMessage` as it arrives, so apply your own limits there if you + need them. Without `onMessage`, every message is answered `refused`. +- Call `close()` before exiting, including from your own signal handlers. A + process killed without closing leaves its record behind until a Qwen Code + session lists the directory and sees the process is gone. +- UNIX domain sockets only: Windows is not supported yet. + +The record schema, wire format and receipt states are documented in +[Cross-Session Protocol](../users/features/cross-session-protocol.md). + ## Permission Modes The SDK supports different permission modes for controlling tool execution: diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 45cefcabf1c..b092762ec3e 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -1066,3 +1066,8 @@ writing one: the record schema and how liveness is judged, the socket paths and framing, the auth line, every frame field, the receipt states and their transitions, and what a receiver does with a message before its model sees it. + +A Node program does not have to write any of that by hand: +`@qwen-code/sdk/peer` implements the contract. `PeerEndpoint.start({ name })` +publishes the record and binds the inbox, `list()` and `send()` address +sessions by name, and `onMessage` receives what they send. diff --git a/docs/users/features/cross-session-protocol.md b/docs/users/features/cross-session-protocol.md index fd80084d7f9..af8bda39d8e 100644 --- a/docs/users/features/cross-session-protocol.md +++ b/docs/users/features/cross-session-protocol.md @@ -8,6 +8,13 @@ connection, and what it sends back. Everything here is what the code does today at schema version 1 and frame version 1; the last section says what may change and how you will know. +For a Node program, `@qwen-code/sdk/peer` implements the joining side of +this page — the record, the inbox, the auth line, frames and receipts — with +nothing but Node, and its tests run it against Qwen Code's own implementation +in both directions. It applies none of §6 to its own inbox: a program that +needs rate limits, holds or a duplicate window applies them itself. Use it, or +read on to write your own. + Every value that crosses a process boundary is untrusted on arrival and validated by the reader. Where this page says a field "must" have some shape, a value that does not is dropped, never rejected with an error. @@ -73,11 +80,22 @@ able to receive receipts — writes the same record for itself: its own `pid`, `procStart` and `pidNs` computed the same way, a `sessionId` it mints (any UUID), `kind: "external"`, a `name` (yours, or derived the same way; it is flattened to one line and bounded when displayed), and -`ipcPath` + `ipcToken` for an inbox it binds itself (§2). Write to a -temp file in the same directory and `rename` over the target; create the -file 0600; refuse to write through a symlink. Remove the record on exit. -A record whose process is gone is swept by the next session that lists, -but only when `procStart` proves the PID is not merely reused. +`ipcPath` + `ipcToken` for an inbox it binds itself (§2). On Linux, +`pidNs` is required: every reader compares it with its own, so a record +without one is never listed — and never swept. `procStart` is required too, +for a different reason: without it a reader falls back to plain PID liveness +and cannot tell a recycled PID from the process that wrote the record. Write +to a temp file in the same directory and `rename` over the target; create +the file 0600; refuse to write through a symlink. If `.json` already +holds something you cannot prove was left by an earlier process with your +PID — same `pidNs`, same boot id, different start ticks — write +`-<8 hex>.json` instead of replacing it: readers accept both names, +and the record there may belong to a live process in another namespace or +on another machine. Remove the record on exit. A record whose process is +gone is swept by the next session that lists, but only when `procStart` +proves the PID is not merely reused. `PeerEndpoint.start()` in +`@qwen-code/sdk/peer` does all of this, and removes the record again on +`close()`. **Reading.** Anything that can read the directory can read every record, including tokens: being able to discover a session and being able to diff --git a/packages/cli/src/ui/commands/peers-command.test.ts b/packages/cli/src/ui/commands/peers-command.test.ts index d0e77034491..22111fd9b66 100644 --- a/packages/cli/src/ui/commands/peers-command.test.ts +++ b/packages/cli/src/ui/commands/peers-command.test.ts @@ -55,14 +55,11 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ ? 'this session can apply some actions without per-action review and the sender does not' : `held (${cause})`, flattenPeerLabel: (value: string) => { - const oneLine = value - .replace( - // eslint-disable-next-line no-control-regex - /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u206f\ufeff]+/g, - ' ', - ) - .trim(); - return oneLine.length > 200 ? `${oneLine.slice(0, 199)}\u2026` : oneLine; + const oneLine = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim(); + const points = Array.from(oneLine); + return points.length > 200 + ? `${points.slice(0, 199).join('')}\u2026` + : oneLine; }, canonicalizeMsgId: (msgId: string) => msgId.replace(/-/g, '').toLowerCase(), listPeerControllers: async () => { diff --git a/packages/core/src/ipc/peer-envelope.test.ts b/packages/core/src/ipc/peer-envelope.test.ts index 9b338e9deba..4367ac50195 100644 --- a/packages/core/src/ipc/peer-envelope.test.ts +++ b/packages/core/src/ipc/peer-envelope.test.ts @@ -123,6 +123,21 @@ describe('flattenPeerLabel', () => { expect(flattenPeerLabel('a\u202eb')).not.toContain('\u202e'); expect(flattenPeerLabel('x\ufeffy')).not.toContain('\ufeff'); expect(flattenPeerLabel('hid\u200dden text')).toBe('hid den text'); + // By category, not by a list: tag characters, the Mongolian vowel + // separator and interlinear annotation marks are format characters too. + expect(flattenPeerLabel('voice\u{E0041}\u{E0042}bridge')).toBe( + 'voice bridge', + ); + expect(flattenPeerLabel('a\u180eb')).toBe('a b'); + expect(flattenPeerLabel('a\ufff9b\ufffbc')).toBe('a b c'); + }); + + it('caps a label in code points, never splitting a surrogate pair', () => { + const flattened = flattenPeerLabel('\u{1F600}'.repeat(250)); + const points = Array.from(flattened); + expect(points).toHaveLength(200); + expect(points.at(-2)).toBe('\u{1F600}'); + expect(points.at(-1)).toBe('\u2026'); }); }); diff --git a/packages/core/src/ipc/peer-envelope.ts b/packages/core/src/ipc/peer-envelope.ts index acce6e29433..32c23d63a6b 100644 --- a/packages/core/src/ipc/peer-envelope.ts +++ b/packages/core/src/ipc/peer-envelope.ts @@ -33,13 +33,15 @@ import type { PeerControllerIdentity } from './peer-controllers.js'; const CROSS_SESSION_TAG = 'cross_session_message'; /** - * Characters that render as nothing: control characters plus the invisible - * format set (zero-width spaces, bidi overrides, soft hyphen and kin). - * {@link flattenPeerLabel} strips them from peer-supplied attributes so a - * label cannot read differently than it compares. + * Characters that render as nothing: control and format characters, and + * the line and paragraph separators — by Unicode category, so zero-width + * spaces, bidi overrides and soft hyphens are covered along with the blocks + * nobody thinks to list (tag characters, the Mongolian vowel separator, + * interlinear annotation marks). {@link flattenPeerLabel} strips them from + * peer-supplied attributes so a label cannot read differently than it + * compares. */ -const INVISIBLE_CHARACTERS = - '\\u0000-\\u001f\\u007f-\\u009f\\u00ad\\u061c\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2060-\\u206f\\ufeff'; +const INVISIBLE_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu; /** * Framing appended after the envelope. @@ -113,7 +115,7 @@ export function defangEnvelopeTags(text: string): string { } /** - * Longest attribute value kept. + * Longest attribute value kept, in code points. * * A working reply address cannot exceed `MAX_SOCKET_PATH_BYTES` (103) and * a display name is a handful of characters, but both arrive from the peer @@ -136,11 +138,12 @@ const MAX_ATTRIBUTE_CHARS = 200; * letting a label read differently than it compares. */ export function flattenPeerLabel(value: string): string { - const oneLine = value - .replace(new RegExp(`[${INVISIBLE_CHARACTERS}]+`, 'g'), ' ') - .trim(); - return oneLine.length > MAX_ATTRIBUTE_CHARS - ? `${oneLine.slice(0, MAX_ATTRIBUTE_CHARS - 1)}\u2026` + const oneLine = value.replace(INVISIBLE_CHARACTERS, ' ').trim(); + // Counted in code points, so the cut never leaves half of an astral + // character behind as a lone surrogate. + const points = Array.from(oneLine); + return points.length > MAX_ATTRIBUTE_CHARS + ? `${points.slice(0, MAX_ATTRIBUTE_CHARS - 1).join('')}\u2026` : oneLine; } diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 787a5586065..08ef42f9ea5 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -201,6 +201,88 @@ guideline, project up to roughly 1,000 ordinary records on the main thread; move larger or unusually text-heavy inputs to a Web Worker and call the same entry there. +### Talking to running sessions + +`@qwen-code/sdk/peer` lets a program that is not a Qwen Code session join the +sessions running as the same user on the same machine — a voice front-end, a +relay, a build watcher. The program shows up in `qwen sessions ps`, and in the +`list_agents` of every session that has `agents.crossSessionMessaging` turned +on — which is also what lets those sessions message it by name with +`send_message`. It can message them back. It runs on Node only and needs +nothing beyond Node itself. + +```typescript +import { PeerEndpoint } from '@qwen-code/sdk/peer'; + +const endpoint = await PeerEndpoint.start({ + name: 'voice-bridge', + onMessage: (message) => + console.log(`${message.fromName}: ${message.content}`), +}); + +const [session] = await endpoint.list(); +if (session) { + const sent = await endpoint.send({ + to: session.address, + content: 'What are you working on?', + }); + if (sent.kind === 'sent') { + const receipt = await endpoint.awaitReceipt(sent.msgId, { final: true }); + console.log(receipt?.status); // delivered, denied, refused, ... + } +} + +await endpoint.close(); +``` + +A message like that is held for the session's user to review. To direct a +session without that review, mint a controller token with +`qwen sessions controllers add --label voice-bridge`, give it to the endpoint, +and mark the sends that should present it: + +```typescript +const endpoint = await PeerEndpoint.start({ + name: 'voice-bridge', + controllerToken: process.env['QWEN_CONTROLLER_TOKEN'], +}); +await endpoint.send({ + to: 'my-app-3f', + content: 'run the tests', + controller: true, +}); +``` + +Things to know: + +- A session has an inbox only while its `agents.crossSessionMessaging` setting + is on, and the setting is off by default. Without it the session does not + appear in `list()`, and its own `list_agents` and `send_message` cannot see + or reach the program either. `qwen sessions ps` lists the program regardless. +- A message is delivered without review in exactly two cases: the send + presents a controller token (`controller: true`), or its `fromMode` names the + receiving session's own review class. `fromMode` is a claim nothing + authenticates, so a program that is not a coding session should leave it + out. Nothing in the record — not `kind`, not `name` — buys delivery. The + receiving session's `agents.crossSessionInbound` setting outranks both: + `hold` or `refuse` there wins over a controller token. +- Mark only the sends meant to direct a session. Addresses are resolved from + records any program running as you can write, so a controller send presents + the token to whichever process's record answers to that address. A + controller send to another peer endpoint is dropped unread, because an + endpoint's inbox accepts only its own token. +- The endpoint's inbox applies none of the protections a Qwen Code session + applies to its own: no rate limit, no holds, and no duplicate window beyond + the last 200 messages it answered. Every message is answered `delivered` and + handed to `onMessage` as it arrives, so apply your own limits there if you + need them. Without `onMessage`, every message is answered `refused`. +- Call `close()` before exiting, including from your own signal handlers. A + process killed without closing leaves its record behind until a Qwen Code + session lists the directory and sees the process is gone. +- UNIX domain sockets only: Windows is not supported yet. + +The record schema, wire format and receipt states are documented in +[Cross-Session Protocol](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/cross-session-protocol.md). + ### Message Types The SDK provides type guards to identify different message types: diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 210907d7b81..9b4745ac03e 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -27,6 +27,11 @@ "import": "./dist/daemon/transcript.js", "require": "./dist/daemon/transcript.cjs" }, + "./peer": { + "types": "./dist/peer/index.d.ts", + "import": "./dist/peer/index.js", + "require": "./dist/peer/index.cjs" + }, "./package.json": "./package.json" }, "bin": { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 20d045f5cf8..709b48bcd0a 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -17,6 +17,10 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import esbuild from 'esbuild'; import { serveBridgeBinBuildOptions } from './serve-bridge-bin-build-options.js'; +import { + assertPeerBundle, + assertPeerDeclarations, +} from './peer-build-assertions.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -221,6 +225,33 @@ await esbuild.build({ treeShaking: true, }); +// Opt-in peer subpath (`@qwen-code/sdk/peer`): the cross-session protocol for +// a program that is not a Qwen Code session. Node-only, so it never joins a +// browser bundle, and it must stay free of every runtime dependency. +for (const [format, outfile] of [ + ['esm', join(rootDir, 'dist', 'peer', 'index.js')], + ['cjs', join(rootDir, 'dist', 'peer', 'index.cjs')], +]) { + await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'peer', 'index.ts')], + bundle: true, + format, + platform: 'node', + target: 'node22', + outfile, + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, + }); + assertPeerBundle(outfile); +} +assertPeerDeclarations(join(rootDir, 'dist', 'peer')); + // Build serve-bridge CLI bin entry. The options — including the absence of a // hashbang `banner`, see `serveBridgeBinBuildOptions` — are shared with the // test that pins the emitted bytes. diff --git a/packages/sdk-typescript/scripts/peer-build-assertions.js b/packages/sdk-typescript/scripts/peer-build-assertions.js new file mode 100644 index 00000000000..cd9ea886807 --- /dev/null +++ b/packages/sdk-typescript/scripts/peer-build-assertions.js @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * What the `@qwen-code/sdk/peer` build must never ship. + * + * The subpath is a standalone implementation of the cross-session protocol: + * a bundle carrying one of the SDK's runtime dependencies, or Qwen Code's + * own sources, would no longer be one, and a declaration that imports an + * internal package would not resolve for anyone who installs the SDK. Kept + * in its own module so the build and a test share one definition. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +// Measured with `npm run build && wc -c dist/peer/*`: about 24 KiB (esm) +// and 25 KiB (cjs) at introduction. The headroom is for growth, not for a +// dependency slipping in; the token check below catches that separately. +export const MAX_PEER_BUNDLE_BYTES = 40 * 1024; + +const FORBIDDEN_BUNDLE_TOKENS = [ + '@modelcontextprotocol', + 'zod', + 'qwen-code-core', + 'acp-bridge', +]; + +// Not a bare `@qwen-code/` prefix: the module's own doc comment names +// `@qwen-code/sdk/peer`, and the compiler keeps comments. +const FORBIDDEN_DECLARATION_TOKENS = [ + '@qwen-code/qwen-code-core', + '@qwen-code/acp-bridge', +]; + +export function assertPeerBundle(filePath, maxBytes = MAX_PEER_BUNDLE_BYTES) { + const size = statSync(filePath).size; + if (size > maxBytes) { + throw new Error( + `Peer bundle ${filePath} is ${size} bytes; expected <= ${maxBytes}`, + ); + } + const contents = readFileSync(filePath, 'utf8'); + const found = FORBIDDEN_BUNDLE_TOKENS.find((token) => + contents.includes(token), + ); + if (found) { + throw new Error(`Peer bundle ${filePath} contains a dependency: ${found}`); + } +} + +/** + * Every declaration the compiler emitted for the subpath, not just the + * entry: the entry only re-exports, and a type import from an internal + * package would land in whichever module used it. + */ +export function assertPeerDeclarations(dir) { + const declarations = readdirSync(dir).filter((name) => + name.endsWith('.d.ts'), + ); + if (!declarations.includes('index.d.ts')) { + throw new Error(`Peer declarations in ${dir} have no index.d.ts`); + } + for (const name of declarations) { + const contents = readFileSync(join(dir, name), 'utf8'); + const found = FORBIDDEN_DECLARATION_TOKENS.find((token) => + contents.includes(token), + ); + if (found) { + throw new Error( + `Peer declaration ${name} references an internal package: ${found}`, + ); + } + } +} diff --git a/packages/sdk-typescript/src/peer/client.ts b/packages/sdk-typescript/src/peer/client.ts new file mode 100644 index 00000000000..1304d60edd4 --- /dev/null +++ b/packages/sdk-typescript/src/peer/client.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Dialing an inbox: write one frame and hang up, or ask whether anything + * is listening. + * + * No pooling and no persistent link. Messages are rare, an address is + * stable for its session's lifetime, and a short-lived connection makes a + * dead peer show up at once as a refused dial rather than as a write into a + * broken pipe. + */ + +import * as net from 'node:net'; +import * as path from 'node:path'; +import { + buildAuthLine, + encodePeerFrame, + MAX_FRAME_CHARS, + type PeerFrame, +} from './frames.js'; + +/** Give up on a peer that accepts a connection and never drains it. */ +export const SEND_TIMEOUT_MS = 5_000; + +/** How long a reachability probe waits for a local socket to answer. */ +export const PROBE_TIMEOUT_MS = 250; + +/** + * Most sends in flight at once. Each holds a descriptor until it settles, + * and receipts are drawn by traffic other processes control; past this, a + * send fails with `EBUSY` rather than taking the process's descriptors. + */ +export const MAX_CONCURRENT_SENDS = 64; + +let inFlightSends = 0; + +export class PeerSendError extends Error { + /** + * True when the failure is this process's own limit rather than anything + * at the other end: nothing was dialed and nothing was written. + */ + readonly local: boolean; + + constructor( + message: string, + /** The errno behind the failure, when there is one. */ + readonly code: string | undefined, + options: { local?: boolean } = {}, + ) { + super(message); + this.name = 'PeerSendError'; + this.local = options.local ?? false; + } +} + +/** + * True for a path this process is willing to dial. + * + * An address is read from a file another process wrote, so it is input, + * not fact: an absolute local path, or a local pipe on Windows. Nothing + * relative, nothing that could reach another host. + */ +export function isLocalIpcPath(candidate: string): boolean { + if (typeof candidate !== 'string' || candidate.length === 0) return false; + if (candidate.includes('\0')) return false; + if (process.platform === 'win32') { + const normalized = candidate.replace(/\//g, '\\').toLowerCase(); + return ( + normalized.startsWith('\\\\.\\pipe\\') || + normalized.startsWith('\\\\?\\pipe\\') + ); + } + if (candidate.startsWith('//')) return false; + return path.isAbsolute(candidate); +} + +export interface SendPeerFrameOptions { + /** + * The token to present on the auth line: the recipient's `ipcToken`, or a + * controller token. Leave it out for an inbox whose record advertises no + * token. + */ + authToken?: string; + timeoutMs?: number; +} + +/** + * Write one frame to `socketPath`. + * + * Resolving means the frame was written and the peer closed the connection + * — not that anything acted on it. Receipts are what say that. + * + * Rejects with a {@link PeerSendError}. `ENOENT` and `ECONNREFUSED` mean the + * address is stale; `EAGAIN` and `EBUSY` mean the peer is alive but busy; + * `ETIMEDOUT` means it accepted the connection and stopped reading, and may + * still read the frame later; `EMSGSIZE` means the frame was too long to + * send at all. + */ +export function sendPeerFrame( + socketPath: string, + frame: PeerFrame, + options: SendPeerFrameOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? SEND_TIMEOUT_MS; + return new Promise((resolve, reject) => { + if (!isLocalIpcPath(socketPath)) { + reject( + new PeerSendError( + `Refusing to dial a path that is not local: ${socketPath}`, + undefined, + ), + ); + return; + } + const encoded = encodePeerFrame(frame); + if (encoded.length - 1 > MAX_FRAME_CHARS) { + reject( + new PeerSendError( + `The frame is ${encoded.length - 1} characters, over the ${MAX_FRAME_CHARS} a peer accepts`, + 'EMSGSIZE', + ), + ); + return; + } + if (inFlightSends >= MAX_CONCURRENT_SENDS) { + reject( + new PeerSendError( + `Already sending ${inFlightSends} frames; not opening another connection`, + 'EBUSY', + { local: true }, + ), + ); + return; + } + + const socket = net.connect({ path: socketPath }); + inFlightSends += 1; + let settled = false; + const settle = (error?: NodeJS.ErrnoException) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + inFlightSends -= 1; + if (error) { + socket.destroy(); + reject(new PeerSendError(error.message, error.code)); + } else { + resolve(); + } + }; + // An absolute deadline rather than an idle timer, which any incoming + // byte would reset. + const deadline = setTimeout(() => { + settle( + Object.assign(new Error(`Timed out sending to ${socketPath}`), { + code: 'ETIMEDOUT', + }), + ); + }, timeoutMs); + socket.on('error', (error: NodeJS.ErrnoException) => settle(error)); + socket.on('connect', () => { + // The auth line rides in the same write as the frame, so a partial + // flush can never leave the frame stranded without its credentials. + socket.end( + options.authToken !== undefined + ? buildAuthLine(options.authToken) + encoded + : encoded, + ); + }); + socket.on('close', () => settle()); + }); +} + +/** + * What a probe established. Only `dead` is a definitive negative; `unknown` + * — a timeout, a permission error, descriptor exhaustion — establishes + * nothing. + */ +export type PeerSocketVerdict = 'alive' | 'dead' | 'unknown'; + +/** + * Dial `socketPath` and report what that established. + * + * A full listen backlog (`EAGAIN`, `EBUSY`) is a busy peer, so alive. A + * missing path or a socket file nothing holds (`ENOENT`, `ECONNREFUSED`) is + * dead: a socket file outlives a crash, and only a dial tells the two apart. + */ +export function probePeerSocketVerdict( + socketPath: string, +): Promise { + return new Promise((resolve) => { + if (!isLocalIpcPath(socketPath)) { + resolve('unknown'); + return; + } + const socket = net.connect({ path: socketPath }); + let settled = false; + const settle = (verdict: PeerSocketVerdict) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + resolve(verdict); + }; + const deadline = setTimeout(() => settle('unknown'), PROBE_TIMEOUT_MS); + deadline.unref(); + socket.on('connect', () => settle('alive')); + socket.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EAGAIN' || error.code === 'EBUSY') { + settle('alive'); + } else if (error.code === 'ENOENT' || error.code === 'ECONNREFUSED') { + settle('dead'); + } else { + settle('unknown'); + } + }); + }); +} diff --git a/packages/sdk-typescript/src/peer/directory.ts b/packages/sdk-typescript/src/peer/directory.ts new file mode 100644 index 00000000000..6a1c8779f7b --- /dev/null +++ b/packages/sdk-typescript/src/peer/directory.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Turning what someone typed into a session to write to. + * + * Sessions are addressed by name, because a name reads back to a person + * and survives a restart where a socket path does not. Names are not + * unique, so each session also has a short `ref`, and an address that could + * mean two sessions is an error rather than a guess: a message delivered to + * the wrong session cannot be taken back. + */ + +import { probePeerSocketVerdict, type PeerSocketVerdict } from './client.js'; +import { flattenPeerLabel, peerRef } from './label.js'; +import type { SessionRecord } from './registry.js'; + +/** A live session that advertises an inbox. */ +export interface PeerDirectoryEntry { + sessionId: string; + name: string; + ref: string; + cwd: string; + pid: number; + /** What the session says registered it; absent reads as `tui`. */ + kind: string; + ipcPath: string; + ipcToken?: string; + startedAt: number; +} + +/** The addressable view of a record, or null when it has no inbox. */ +export function toDirectoryEntry( + record: SessionRecord, +): PeerDirectoryEntry | null { + if (!record.ipcPath) return null; + const name = flattenPeerLabel(record.name); + if (name.length === 0) return null; + return { + sessionId: record.sessionId, + name, + ref: peerRef(record.sessionId), + cwd: flattenPeerLabel(record.cwd), + pid: record.pid, + kind: record.kind ?? 'tui', + ipcPath: record.ipcPath, + ...(record.ipcToken !== undefined ? { ipcToken: record.ipcToken } : {}), + startedAt: record.startedAt, + }; +} + +/** + * The entries whose inbox answers a dial. + * + * Each distinct address is dialed once: a process hosting several sessions + * advertises one inbox in all their records, and asking once per record + * would open that many connections to one socket on every lookup. + * + * Then, unless `collapseTwins` is false, one entry per (session id, name) is + * kept, the newest: resuming a session in a second terminal runs the same id + * under another process, and two identical entries would make every address + * for it ambiguous. The collapse also means a copied record shadows the + * original, so a caller about to present a credential turns it off and + * treats the pair as the ambiguity it is. + */ +export async function reachableEntries( + records: readonly SessionRecord[], + probe: ( + socketPath: string, + ) => Promise = probePeerSocketVerdict, + options: { collapseTwins?: boolean } = {}, +): Promise { + const candidates = records + .map(toDirectoryEntry) + .filter((entry): entry is PeerDirectoryEntry => entry !== null); + const verdicts = new Map( + await Promise.all( + [...new Set(candidates.map((entry) => entry.ipcPath))].map( + async (ipcPath) => [ipcPath, await probe(ipcPath)] as const, + ), + ), + ); + const reachable = candidates.filter( + (entry) => verdicts.get(entry.ipcPath) === 'alive', + ); + if (options.collapseTwins === false) return reachable; + const newest = new Map(); + for (const entry of reachable) { + const key = `${entry.sessionId}\0${entry.name}`; + const seen = newest.get(key); + if (!seen || entry.startedAt > seen.startedAt) newest.set(key, entry); + } + return [...newest.values()]; +} + +export type PeerResolution = + | { kind: 'one'; peer: PeerDirectoryEntry } + | { kind: 'none' } + | { kind: 'ambiguous'; matches: PeerDirectoryEntry[] }; + +/** + * Match `name`, `name [ref]`, `[ref]` or a bare ref. + * + * Every string is read every way it can be — `name [ref]` is also a + * candidate literal name, a bare word is also a candidate ref — and the + * matches are merged rather than ranked. Ranking would quietly pick one + * session when two have a claim. + */ +export function resolvePeerTarget( + peers: readonly PeerDirectoryEntry[], + target: string, +): PeerResolution { + const trimmed = target.trim(); + if (trimmed.length === 0) return { kind: 'none' }; + const decide = (matches: PeerDirectoryEntry[]): PeerResolution => { + const unique = [...new Set(matches)]; + if (unique.length === 1) return { kind: 'one', peer: unique[0]! }; + if (unique.length > 1) return { kind: 'ambiguous', matches: unique }; + return { kind: 'none' }; + }; + + const withRef = /^(.*?)\s*\[([0-9a-f]{4,12})\]$/i.exec(trimmed); + if (withRef) { + const namePart = withRef[1]!; + const ref = withRef[2]!.toLowerCase(); + return decide([ + ...peers.filter((peer) => peer.name === trimmed), + ...peers.filter( + (peer) => + peer.ref === ref && (namePart.length === 0 || peer.name === namePart), + ), + ]); + } + return decide([ + ...peers.filter((peer) => peer.name === trimmed), + ...peers.filter((peer) => peer.ref === trimmed.toLowerCase()), + ]); +} + +/** + * The shortest address that resolves back to exactly this peer, or + * undefined when adversarial names leave none. No address is better than + * one that routes somewhere else. + */ +export function advertisablePeerAddress( + peer: PeerDirectoryEntry, + peers: readonly PeerDirectoryEntry[], +): string | undefined { + return [peer.name, `${peer.name} [${peer.ref}]`, `[${peer.ref}]`].find( + (candidate) => { + const resolved = resolvePeerTarget(peers, candidate); + return resolved.kind === 'one' && resolved.peer === peer; + }, + ); +} + +/** + * Up to `limit` names containing what was typed, prefix matches first. No + * edit distance: a confident suggestion of the wrong session is worse than + * none. + */ +export function suggestPeerNames( + peers: readonly PeerDirectoryEntry[], + target: string, + limit = 3, +): string[] { + const needle = target + .trim() + .replace(/\s*\[[0-9a-f]{0,12}\]?$/i, '') + .trim() + .toLowerCase(); + if (needle.length === 0) return []; + return peers + .filter((peer) => peer.name.toLowerCase().includes(needle)) + .sort( + (a, b) => + Number(!a.name.toLowerCase().startsWith(needle)) - + Number(!b.name.toLowerCase().startsWith(needle)), + ) + .slice(0, limit) + .map((peer) => + peers.filter((other) => other.name === peer.name).length > 1 + ? `${peer.name} [${peer.ref}]` + : peer.name, + ); +} diff --git a/packages/sdk-typescript/src/peer/endpoint.ts b/packages/sdk-typescript/src/peer/endpoint.ts new file mode 100644 index 00000000000..caf6a5bb5c4 --- /dev/null +++ b/packages/sdk-typescript/src/peer/endpoint.ts @@ -0,0 +1,860 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A program's place among the Qwen Code sessions running as this user. + * + * Starting an endpoint binds an inbox and publishes a session record, so + * the program shows up in `qwen sessions ps`, and in the `list_agents` of + * every session that has `agents.crossSessionMessaging` on — which is also + * what lets those sessions address it by name from `send_message`. It can + * address sessions the same way. It keeps a small ledger of what it sent, + * so the receipts that come back read as state changes rather than as a + * stream of unrelated notices. + * + * What the endpoint is trusted to do is not decided here. A message it + * sends is held for the receiving session's user to review unless the send + * presents a controller token the user minted (`controller: true`), or its + * `fromMode` claims the receiver's own review class — and the receiver's + * `agents.crossSessionInbound` setting outranks both. Nothing in the record, + * its `kind` or its `name`, changes that. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; +import { PeerSendError, sendPeerFrame } from './client.js'; +import { + advertisablePeerAddress, + reachableEntries, + resolvePeerTarget, + suggestPeerNames, + type PeerDirectoryEntry, +} from './directory.js'; +import { describeError, PeerEndpointError } from './errors.js'; +import { + buildDeliveryStatusFrame, + buildUserFrame, + canonicalizeMsgId, + type PeerControlFrame, + type PeerDeliveryStatus, + type PeerDropReason, + type PeerFrame, + type PeerMessagePriority, + type PeerModeClass, + type PeerUserFrame, +} from './frames.js'; +import { readPidNamespaceId, readProcStartToken } from './identity.js'; +import { startPeerInbox, type PeerInbox } from './inbox.js'; +import { boundSessionName, flattenPeerLabel, peerRef } from './label.js'; +import { + isValidSessionKind, + readLiveSessionRecords, + removeOwnRecord, + removeOwnRecordSync, + resolveQwenHome, + SESSION_REGISTRY_SCHEMA_VERSION, + sessionRegistryDir, + writeOwnRecord, +} from './registry.js'; + +/** + * Sends remembered for their receipts. The oldest is forgotten first, so an + * unanswered send stops being tracked after this many later ones. + */ +export const MAX_TRACKED_SENDS = 200; + +/** + * Received message ids remembered with the answer they got, so a sender + * that retries an id is told the same thing again rather than having the + * message handled twice. + */ +export const MAX_REMEMBERED_MESSAGES = 200; + +/** Every controller token a user mints starts with this. */ +const CONTROLLER_TOKEN_PREFIX = 'qpc_'; + +/** Longer than any token a receiving session will even consider. */ +const MAX_CONTROLLER_TOKEN_CHARS = 256; + +export interface PeerEndpointOptions { + /** + * What sessions call this program. Flattened to one line and cut to 40 + * characters; it need not be unique. + */ + name: string; + /** What registered, for listings. Default `external`. */ + kind?: string; + /** The working directory to record. Default `process.cwd()`. */ + cwd?: string; + /** A stable id for this endpoint. Default: a fresh UUID. */ + sessionId?: string; + /** Free text recorded as the writer's version. */ + version?: string; + /** The Qwen home to join. Default `QWEN_HOME`, else `~/.qwen`. */ + qwenHome?: string; + /** + * Bind the inbox here instead of choosing a path. If something live + * already answers there, a sibling `-<8 hex>.sock` is bound instead; + * {@link PeerEndpoint.ipcPath} is the address actually published. + */ + socketPath?: string; + /** + * A controller token minted with `qwen sessions controllers add`. + * + * Presented only on sends marked `controller: true`. The token works + * against every session in the Qwen home, and addresses are resolved from + * records any program running as this user can write, so a send presents + * it to whichever process's record answers to the address. Mark only the + * sends meant to direct a session. + * + * A controller send is delivered without review unless the receiving + * session's own `agents.crossSessionInbound` setting says `hold` or + * `refuse` — that outranks the grant. It is for Qwen Code sessions: + * another peer endpoint's inbox accepts only its own token, so a + * controller send to one is dropped unread. + */ + controllerToken?: string; + /** + * Called for each message a session sends here. Without it, the endpoint + * answers every message `refused`. + */ + onMessage?: (message: PeerInboundMessage) => void | Promise; + /** Called when a receipt moves one of this endpoint's sends. */ + onReceipt?: (receipt: PeerReceipt) => void; + /** Called when `onMessage` or `onReceipt` throws or rejects. */ + onError?: (error: Error) => void; + /** Keep the process running while the endpoint is open. Default true. */ + keepAlive?: boolean; + /** + * Remove the record and the socket when the process exits through + * `process.exit` or an empty event loop. Default true. A process killed + * by a signal it does not handle leaves both behind; the next Qwen Code + * session that lists the directory clears the record once the start + * token proves this process is gone. + */ + closeOnExit?: boolean; +} + +export interface PeerInboundMessage { + msgId: string; + content: string; + priority: PeerMessagePriority; + /** The sender's inbox path. */ + from?: string; + /** The name the sender gave. A claim. */ + fromName?: string; + /** The review class the sender asserted. A claim. */ + fromMode?: PeerModeClass; +} + +export interface PeerReceipt { + msgId: string; + /** The address the message was sent to, as {@link PeerEndpoint.send} took it. */ + address: string; + status: PeerDeliveryStatus; + previous: PeerDeliveryStatus | 'pending'; + /** + * Text the recipient wrote for a person, flattened to one line and + * bounded like every other label. Never parse it. + */ + reason?: string; + dropReason?: PeerDropReason; +} + +/** A running session this endpoint can send to. */ +export interface PeerSessionSummary { + sessionId: string; + name: string; + ref: string; + /** The shortest address that selects exactly this session. */ + address: string; + cwd: string; + pid: number; + kind: string; + startedAt: number; +} + +export interface PeerSendOptions { + /** `name`, `name [ref]`, `[ref]` or a bare ref. */ + to: string; + content: string; + priority?: PeerMessagePriority; + /** + * Present this endpoint's controller token instead of the recipient's own + * token; see {@link PeerEndpointOptions.controllerToken}. While marked, an + * address that two records answer to — one session id and name published + * twice — is ambiguous rather than resolved to the newer record, so a + * copied record cannot quietly take the send. + */ + controller?: boolean; + /** + * The review class to assert. The recipient compares it with its own and + * delivers without review when they match, and nothing authenticates the + * claim. A program that is not a coding session has no honest value to + * give; leave it out. + */ + fromMode?: PeerModeClass; +} + +export type PeerSendResult = + | { kind: 'sent'; msgId: string; peer: PeerSessionSummary } + /** The address names this endpoint. */ + | { kind: 'self' } + | { kind: 'not-found'; suggestions: string[] } + /** + * The address could mean several sessions; retry with one of these. A + * session no address can select on its own is left out, so the list is + * empty when nothing tells the candidates apart. + */ + | { kind: 'ambiguous'; matches: string[] } + | { + kind: 'failed'; + peer: PeerSessionSummary; + /** The errno behind the failure, when there is one. */ + code?: string; + reason: string; + /** + * Present when the frame may still be read (the recipient accepted the + * connection and stopped reading), so a receipt can still arrive. + */ + msgId?: string; + }; + +export interface AwaitReceiptOptions { + /** Default 30 seconds. */ + timeoutMs?: number; + /** + * Wait past `held` for a decision. Without it, the first receipt of any + * kind resolves the wait. + */ + final?: boolean; +} + +/** Receipt transitions a sent message can make; anything else is a repeat. */ +const RECEIPT_TRANSITIONS: Record< + PeerDeliveryStatus | 'pending', + ReadonlySet +> = { + pending: new Set([ + 'held', + 'delivered', + 'denied', + 'refused', + 'expired', + 'misaddressed', + 'dropped', + ]), + held: new Set(['delivered', 'denied', 'expired', 'misaddressed']), + delivered: new Set(['expired', 'misaddressed']), + denied: new Set(), + refused: new Set(), + expired: new Set(), + misaddressed: new Set(), + dropped: new Set(), +}; + +/** + * Errnos that prove a frame never reached the recipient, so the ledger + * forgets it. A timeout is not one: the bytes may sit in the recipient's + * buffer and be read, and receipted, later. + */ +const NEVER_WRITTEN_CODES = new Set([ + undefined, + 'ENOENT', + 'ECONNREFUSED', + 'EMSGSIZE', + 'EAGAIN', + 'EBUSY', +]); + +interface TrackedSend { + msgId: string; + address: string; + state: PeerDeliveryStatus | 'pending'; + last?: PeerReceipt; + forgotten?: boolean; + waiters: Set<() => void>; +} + +/** Turn a send failure into something a caller can act on. */ +export function describeSendFailure(error: unknown): string { + if (error instanceof PeerSendError) { + // This process's own ceiling, reached before anything was dialed: it + // says nothing about the session the send was for. + if (error.local) { + return 'this program already has too many sends in flight, so nothing was written to that session; retry once some settle'; + } + switch (error.code) { + case 'ENOENT': + case 'ECONNREFUSED': + return 'that session has exited and its address is stale; list the sessions again'; + case 'EAGAIN': + case 'EBUSY': + return 'that session is alive but momentarily busy; retry shortly'; + case 'ETIMEDOUT': + return 'that session accepted the connection but had not read the message after 5 seconds; it may still read it, so wait for a receipt rather than re-sending'; + default: + return error.message; + } + } + return describeError(error); +} + +/** + * The shape a receiving session will consider as a controller token. An + * empty value — the usual result of an unset environment variable — or a + * value without the prefix can never authenticate, and presenting it would + * get the send dropped unread while it still reported `sent`. + */ +function isPresentableControllerToken(token: string): boolean { + return ( + token.startsWith(CONTROLLER_TOKEN_PREFIX) && + token.length > CONTROLLER_TOKEN_PREFIX.length && + token.length <= MAX_CONTROLLER_TOKEN_CHARS && + !/\s/.test(token) + ); +} + +export class PeerEndpoint { + readonly sessionId: string; + readonly name: string; + readonly ref: string; + readonly kind: string; + readonly cwd: string; + /** + * The token this endpoint's inbox requires. Anyone who can read the + * registry can read it; never print it where a model or a log sees it. + */ + readonly ipcToken: string; + /** Where this endpoint reads and writes session records. */ + readonly registryDir: string; + readonly startedAt: number; + + private readonly options: PeerEndpointOptions; + private inbox!: PeerInbox; + private recordFile!: string; + private exitHook: (() => void) | undefined; + private closed = false; + private readonly sends = new Map(); + private readonly answered = new Map(); + + private constructor( + options: PeerEndpointOptions, + name: string, + kind: string, + sessionId: string, + ) { + this.options = options; + this.name = name; + this.kind = kind; + this.sessionId = sessionId; + this.ref = peerRef(sessionId); + this.cwd = options.cwd ?? process.cwd(); + this.ipcToken = randomBytes(32).toString('hex'); + this.registryDir = sessionRegistryDir(resolveQwenHome(options.qwenHome)); + this.startedAt = Date.now(); + } + + /** + * Bind an inbox and publish this endpoint's record. + * + * The inbox is bound first: a record is listed the moment it lands, and + * one that named no inbox yet would be a session nobody can reach. If the + * record cannot be written, the inbox is closed again and nothing is left + * behind. + */ + static async start(options: PeerEndpointOptions): Promise { + const name = boundSessionName(options.name ?? ''); + if (name.length === 0) { + throw new PeerEndpointError( + 'invalid-name', + 'a peer endpoint needs a name that is not blank once flattened to one line', + ); + } + const kind = options.kind ?? 'external'; + if (!isValidSessionKind(kind)) { + throw new PeerEndpointError( + 'invalid-kind', + `"${kind}" is not a kind: use lowercase letters, digits and dashes, starting with a letter, at most 16 characters`, + ); + } + const sessionId = options.sessionId ?? randomUUID(); + if (sessionId.trim().length === 0) { + throw new PeerEndpointError( + 'invalid-session-id', + 'a peer endpoint needs a session id that is not blank', + ); + } + if ( + options.controllerToken !== undefined && + !isPresentableControllerToken(options.controllerToken) + ) { + throw new PeerEndpointError( + 'invalid-controller-token', + 'controllerToken must be a token minted with `qwen sessions controllers add`, which starts with qpc_; an empty value usually means the variable holding it is unset', + ); + } + + // On Linux a record without both of these is worse than none: without + // the namespace readers never list it or clear it away, and without the + // start token they cannot tell this process from a later one that + // inherits its PID. Re-read once, because the first read can land on a + // moment of descriptor pressure. + let procStart = readProcStartToken(process.pid); + let pidNs = readPidNamespaceId(); + if ( + process.platform === 'linux' && + (procStart === null || pidNs === null) + ) { + procStart = readProcStartToken(process.pid); + pidNs = readPidNamespaceId(); + if (procStart === null || pidNs === null) { + throw new PeerEndpointError( + 'registry-unwritable', + 'could not read this process start token or PID namespace, and a record without them could not be told apart from another process', + ); + } + } + + const endpoint = new PeerEndpoint(options, name, kind, sessionId); + endpoint.inbox = await startPeerInbox({ + ...(options.socketPath !== undefined + ? { socketPath: options.socketPath } + : {}), + requiredToken: endpoint.ipcToken, + onFrame: (frame) => endpoint.handleFrame(frame), + keepAlive: options.keepAlive ?? true, + }); + try { + endpoint.recordFile = await writeOwnRecord(endpoint.registryDir, { + schemaVersion: SESSION_REGISTRY_SCHEMA_VERSION, + pid: process.pid, + procStart, + pidNs, + sessionId, + cwd: endpoint.cwd, + name, + startedAt: endpoint.startedAt, + qwenVersion: options.version ?? null, + kind, + ipcPath: endpoint.inbox.socketPath, + ipcToken: endpoint.ipcToken, + }); + } catch (error) { + await endpoint.inbox.close(); + throw new PeerEndpointError( + 'registry-unwritable', + `could not write the session record in ${endpoint.registryDir}: ${describeError(error)}`, + ); + } + + if (options.closeOnExit ?? true) { + const hook = () => endpoint.closeSync(); + endpoint.exitHook = hook; + process.once('exit', hook); + } + return endpoint; + } + + /** The socket this endpoint listens on. */ + get ipcPath(): string { + return this.inbox.socketPath; + } + + /** The record file this endpoint published. */ + get recordPath(): string { + return this.recordFile; + } + + /** + * The sessions this endpoint can send to right now: live, answering a + * dial, and not this endpoint. A session whose `agents.crossSessionMessaging` + * is off has no inbox and is not listed. + */ + async list(): Promise { + this.assertOpen(); + const peers = this.othersIn(await this.directory(true)); + return peers.flatMap((peer) => { + const address = advertisablePeerAddress(peer, peers); + return address === undefined ? [] : [summarize(peer, address)]; + }); + } + + /** + * Send `content` to the session `to` names. + * + * `sent` means the frame was written, not that anyone read it; the + * receipts that follow say what became of it (see + * {@link PeerEndpoint.awaitReceipt}). + */ + async send(options: PeerSendOptions): Promise { + this.assertOpen(); + const controller = options.controller === true; + const controllerToken = this.options.controllerToken; + if (controller && controllerToken === undefined) { + throw new PeerEndpointError( + 'invalid-controller-token', + 'a controller send needs an endpoint started with a controllerToken', + ); + } + const directory = await this.directory(!controller); + // close() may have run while the directory was read. A send that went + // out anyway would carry a reply address that no longer exists. + this.assertOpen(); + const peers = this.othersIn(directory); + const resolved = resolvePeerTarget(peers, options.to); + + if (resolved.kind === 'none') { + const incarnations = [ + this.selfEntry(), + ...directory.filter((peer) => peer.sessionId === this.sessionId), + ]; + if (resolvePeerTarget(incarnations, options.to).kind !== 'none') { + return { kind: 'self' }; + } + return { + kind: 'not-found', + suggestions: suggestPeerNames(peers, options.to), + }; + } + if (resolved.kind === 'ambiguous') { + // Only addresses that select one session are worth retrying with; a + // session none can select is left out rather than advertised under a + // string that resolves straight back here. + return { + kind: 'ambiguous', + matches: resolved.matches.flatMap((peer) => { + const address = advertisablePeerAddress(peer, peers); + return address === undefined ? [] : [address]; + }), + }; + } + + const peer = resolved.peer; + const summary = summarize( + peer, + advertisablePeerAddress(peer, peers) ?? options.to.trim(), + ); + if (options.content.length === 0) { + return { + kind: 'failed', + peer: summary, + reason: 'the message is empty; there is nothing to deliver', + }; + } + + const frame = buildUserFrame({ + content: options.content, + from: this.ipcPath, + replyToken: this.ipcToken, + fromName: this.name, + // Pins the frame to the session the address resolved to. Addresses + // are keyed by PID and PIDs are reused: a different session found at + // that address later answers `misaddressed` instead of acting on a + // message meant for its predecessor. + toSessionId: peer.sessionId, + ...(options.priority !== undefined ? { priority: options.priority } : {}), + ...(options.fromMode !== undefined ? { fromMode: options.fromMode } : {}), + }); + const authToken = controller ? controllerToken : peer.ipcToken; + // Tracked before the write: a recipient that is slow to read can still + // receipt a frame whose send timed out here. + this.track(frame.msgId, summary.address); + try { + await sendPeerFrame( + peer.ipcPath, + frame, + authToken !== undefined ? { authToken } : {}, + ); + return { kind: 'sent', msgId: frame.msgId, peer: summary }; + } catch (error) { + const code = error instanceof PeerSendError ? error.code : undefined; + const unwritten = NEVER_WRITTEN_CODES.has(code); + if (unwritten) this.forget(frame.msgId); + return { + kind: 'failed', + peer: summary, + ...(code !== undefined ? { code } : {}), + reason: describeSendFailure(error), + ...(unwritten ? {} : { msgId: frame.msgId }), + }; + } + } + + /** + * The receipt for a message this endpoint sent. + * + * Resolves at once when one has already arrived. With `final`, waits past + * `held` for the decision. Resolves `undefined` on timeout, when the id is + * not one this endpoint is tracking, and when the endpoint closes. + */ + awaitReceipt( + msgId: string, + options: AwaitReceiptOptions = {}, + ): Promise { + const entry = this.sends.get(canonicalizeMsgId(msgId)); + if (!entry) return Promise.resolve(undefined); + const final = options.final ?? false; + const satisfied = () => + entry.last !== undefined && + (!final || (entry.state !== 'pending' && entry.state !== 'held')); + if (satisfied()) return Promise.resolve(entry.last); + if (this.closed) return Promise.resolve(undefined); + + return new Promise((resolve) => { + const finish = (receipt: PeerReceipt | undefined) => { + clearTimeout(timer); + entry.waiters.delete(wake); + resolve(receipt); + }; + const wake = () => { + if (satisfied()) finish(entry.last); + else if (this.closed || entry.forgotten) finish(undefined); + }; + const timer = setTimeout( + () => finish(undefined), + options.timeoutMs ?? 30_000, + ); + entry.waiters.add(wake); + }); + } + + /** + * Stop listening and remove this endpoint's record and socket. Pending + * {@link PeerEndpoint.awaitReceipt} calls resolve `undefined`. Safe to + * call more than once. + */ + async close(): Promise { + if (this.closed) return; + this.beginClose(); + await this.inbox.close(); + await removeOwnRecord(this.recordFile, this.sessionId); + } + + private closeSync(): void { + if (this.closed) return; + this.beginClose(); + this.inbox.closeSync(); + removeOwnRecordSync(this.recordFile, this.sessionId); + } + + private beginClose(): void { + this.closed = true; + if (this.exitHook) { + process.removeListener('exit', this.exitHook); + this.exitHook = undefined; + } + for (const entry of this.sends.values()) wakeAll(entry); + } + + private assertOpen(): void { + if (this.closed) { + throw new PeerEndpointError('closed', 'this peer endpoint is closed'); + } + } + + private async directory( + collapseTwins: boolean, + ): Promise { + return reachableEntries( + await readLiveSessionRecords(this.registryDir), + undefined, + { collapseTwins }, + ); + } + + /** + * Everyone but this endpoint. Excluded by session id, so another process + * running with the same id — which an endpoint given a stable + * `sessionId` can be — is excluded too, rather than messaged as a peer. + */ + private othersIn( + directory: readonly PeerDirectoryEntry[], + ): PeerDirectoryEntry[] { + return directory.filter((peer) => peer.sessionId !== this.sessionId); + } + + private selfEntry(): PeerDirectoryEntry { + return { + sessionId: this.sessionId, + name: this.name, + ref: this.ref, + cwd: flattenPeerLabel(this.cwd), + pid: process.pid, + kind: this.kind, + ipcPath: this.ipcPath, + ipcToken: this.ipcToken, + startedAt: this.startedAt, + }; + } + + private track(msgId: string, address: string): void { + this.sends.set(canonicalizeMsgId(msgId), { + msgId, + address, + state: 'pending', + waiters: new Set(), + }); + while (this.sends.size > MAX_TRACKED_SENDS) { + const oldest = this.sends.keys().next().value; + if (oldest === undefined) break; + this.forget(oldest); + } + } + + private forget(msgId: string): void { + const key = canonicalizeMsgId(msgId); + const entry = this.sends.get(key); + if (!entry) return; + this.sends.delete(key); + entry.forgotten = true; + wakeAll(entry); + } + + private handleFrame(frame: PeerFrame): void { + if (this.closed) return; + if (frame.type === 'control') { + this.applyReceipt(frame); + } else { + this.receive(frame); + } + } + + private applyReceipt(frame: PeerControlFrame): void { + this.settle(frame.origMsgId, frame); + if (frame.status !== 'dropped') return; + // One drop receipt can stand for a burst. The transition table only + // lets a still-pending message become dropped, so an id in the list + // that already moved on is left where it is. + for (const msgId of frame.droppedMsgIds ?? []) this.settle(msgId, frame); + } + + private settle(msgId: string, frame: PeerControlFrame): void { + const entry = this.sends.get(canonicalizeMsgId(msgId)); + // A receipt names an id, and anything that can reach this inbox can + // write one for any id. Only ids this endpoint sent are answered for, + // and only a receipt that moves the message is reported. + if (!entry || !RECEIPT_TRANSITIONS[entry.state].has(frame.status)) return; + const receipt: PeerReceipt = { + msgId: entry.msgId, + address: entry.address, + status: frame.status, + previous: entry.state, + // Written by whatever answered, and bound for a person: flattened + // like every other label that crosses in, so it cannot carry escape + // sequences or a megabyte of text into a terminal or a prompt. + ...(frame.reason !== undefined + ? { reason: flattenPeerLabel(frame.reason) } + : {}), + ...(frame.dropReason !== undefined + ? { dropReason: frame.dropReason } + : {}), + }; + entry.state = frame.status; + entry.last = receipt; + wakeAll(entry); + const onReceipt = this.options.onReceipt; + if (onReceipt) this.invoke(() => onReceipt(receipt)); + } + + private receive(frame: PeerUserFrame): void { + const key = canonicalizeMsgId(frame.msgId); + const answered = this.answered.get(key); + if (answered !== undefined) { + this.reply(frame, answered); + return; + } + // A frame pinned to another session was written for whoever held this + // address before. Not remembered: the id belongs to that session. + if ( + frame.toSessionId !== undefined && + frame.toSessionId !== this.sessionId + ) { + this.reply(frame, 'misaddressed'); + return; + } + const onMessage = this.options.onMessage; + const status: PeerDeliveryStatus = onMessage ? 'delivered' : 'refused'; + this.answered.set(key, status); + while (this.answered.size > MAX_REMEMBERED_MESSAGES) { + const oldest = this.answered.keys().next().value; + if (oldest === undefined) break; + this.answered.delete(oldest); + } + // Answered before the handler runs, so the sender is not kept waiting + // on whatever the handler does with the message. + this.reply(frame, status); + if (!onMessage) return; + const message: PeerInboundMessage = { + msgId: frame.msgId, + content: frame.message.content, + priority: frame.priority, + ...(frame.from !== undefined ? { from: frame.from } : {}), + ...(frame.fromName !== undefined + ? { fromName: flattenPeerLabel(frame.fromName) } + : {}), + ...(frame.fromMode !== undefined ? { fromMode: frame.fromMode } : {}), + }; + this.invoke(() => onMessage(message)); + } + + /** + * Best-effort: a sender that has exited, or that gave no address, simply + * never hears back. + */ + private reply(frame: PeerUserFrame, status: PeerDeliveryStatus): void { + if (!frame.from) return; + sendPeerFrame( + frame.from, + buildDeliveryStatusFrame({ + status, + origMsgId: frame.msgId, + from: this.ipcPath, + }), + frame.replyToken !== undefined ? { authToken: frame.replyToken } : {}, + ).catch(() => {}); + } + + private invoke(callback: () => void | Promise): void { + try { + const result = callback(); + if (result instanceof Promise) { + result.catch((error: unknown) => this.reportError(error)); + } + } catch (error) { + this.reportError(error); + } + } + + private reportError(error: unknown): void { + const onError = this.options.onError; + if (!onError) return; + try { + onError(error instanceof Error ? error : new Error(String(error))); + } catch { + // Nowhere left to report it. + } + } +} + +function wakeAll(entry: TrackedSend): void { + for (const wake of [...entry.waiters]) wake(); +} + +function summarize( + peer: PeerDirectoryEntry, + address: string, +): PeerSessionSummary { + return { + sessionId: peer.sessionId, + name: peer.name, + ref: peer.ref, + address, + cwd: peer.cwd, + pid: peer.pid, + kind: peer.kind, + startedAt: peer.startedAt, + }; +} diff --git a/packages/sdk-typescript/src/peer/errors.ts b/packages/sdk-typescript/src/peer/errors.ts new file mode 100644 index 00000000000..a6dbc6b5a90 --- /dev/null +++ b/packages/sdk-typescript/src/peer/errors.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Why a peer endpoint could not start, or can no longer act. + * + * - `invalid-name`: the name is blank once flattened to one line. + * - `invalid-kind`: the kind is not lowercase ASCII letters, digits and + * dashes of at most 16 characters starting with a letter. + * - `invalid-session-id`: the session id is blank. + * - `invalid-controller-token`: the controller token is not a token a + * session would consider — empty, or without the `qpc_` prefix — or a + * controller send was asked of an endpoint started without one. + * - `unsupported-platform`: this platform has no UNIX domain sockets the + * endpoint knows how to place. + * - `bind-failed`: no candidate socket path could be bound. + * - `registry-unwritable`: the session record could not be written. + * - `closed`: the endpoint was closed. + */ +export type PeerEndpointErrorCode = + | 'invalid-name' + | 'invalid-kind' + | 'invalid-session-id' + | 'invalid-controller-token' + | 'unsupported-platform' + | 'bind-failed' + | 'registry-unwritable' + | 'closed'; + +export class PeerEndpointError extends Error { + constructor( + readonly code: PeerEndpointErrorCode, + message: string, + ) { + super(message); + this.name = 'PeerEndpointError'; + } +} + +export function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/sdk-typescript/src/peer/frames.ts b/packages/sdk-typescript/src/peer/frames.ts new file mode 100644 index 00000000000..26a369c16d1 --- /dev/null +++ b/packages/sdk-typescript/src/peer/frames.ts @@ -0,0 +1,346 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * What crosses a peer connection: the auth line that opens it, and the + * frames after it. + * + * One JSON object per line. Every field is checked on arrival, because the + * process at the other end is only as trustworthy as anything else running + * as this user. A value of the wrong shape is dropped rather than reported: + * a newer peer is then merely partly unintelligible, never fatal. + */ + +import { randomUUID } from 'node:crypto'; + +/** Bumped only when the shape of an existing field changes. */ +export const PEER_FRAME_VERSION = 1; + +/** + * Longest line either side accepts, in UTF-16 code units. + * + * Counted on the decoded string rather than in bytes: both ends compare it + * against a JS string, and re-encoding every line just to count bytes would + * cost more than the precision is worth. A receiver drops the connection on + * a longer line, so a sender measures first and fails with a reason instead + * of a bare reset. + */ +export const MAX_FRAME_CHARS = 1024 * 1024; + +/** + * Most ids one `dropped` receipt settles beyond the one it is addressed for. + * The list is drawn from ids senders chose, so it is bounded like every + * other array that crosses a process boundary. + */ +export const MAX_DROPPED_MSG_IDS = 256; + +/** How a delivered message competes with whatever the recipient is doing. */ +export type PeerMessagePriority = 'now' | 'next'; + +/** + * The review class a sender asserts: `prompting` when a person reviews each + * action, `bypass` when some apply without review. A claim, not a + * credential; a program that is not a coding session has no honest value to + * give and should leave it out. + */ +export type PeerModeClass = 'bypass' | 'prompting'; + +/** Which wall a `dropped` message met. */ +export type PeerDropReason = 'rate-limited' | 'duplicate' | 'queue-full'; + +/** + * What became of a message on the receiving side. + * + * The one list of them: the type is derived from it, so the set the parser + * accepts cannot drift from the set the type names. + */ +export const PEER_DELIVERY_STATUSES = [ + 'held', + 'denied', + 'refused', + 'expired', + 'delivered', + 'misaddressed', + 'dropped', +] as const; + +export type PeerDeliveryStatus = (typeof PEER_DELIVERY_STATUSES)[number]; + +export interface PeerUserFrame { + msgV: number; + msgId: string; + type: 'user'; + /** The sender's own inbox path, where receipts go. */ + from?: string; + /** The token the sender's inbox requires, so receipts authenticate. */ + replyToken?: string; + fromName?: string; + fromMode?: PeerModeClass; + /** The session id the sender read from the recipient's record. */ + toSessionId?: string; + priority: PeerMessagePriority; + message: { role: 'user'; content: string }; +} + +export interface PeerControlFrame { + msgV: number; + msgId: string; + type: 'control'; + action: 'delivery_status'; + status: PeerDeliveryStatus; + /** The `msgId` this receipt reports on. */ + origMsgId: string; + from?: string; + /** Free text for a person. Never parse it. */ + reason?: string; + /** Only meaningful with `status: 'dropped'`. */ + dropReason?: PeerDropReason; + /** Further ids this receipt settles. Only with `status: 'dropped'`. */ + droppedMsgIds?: string[]; +} + +export type PeerFrame = PeerUserFrame | PeerControlFrame; + +/** + * The shape a message id must have. + * + * Ids double as handles a person types to decide a held message, so one + * with whitespace, or one that strips to nothing, could never be decided on + * its own — and one that canonicalizes to `all` would decide everything. + */ +const MSG_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; + +/** Dashes stripped, case folded: the form every id comparison uses. */ +export function canonicalizeMsgId(msgId: string): string { + return msgId.replace(/-/g, '').toLowerCase(); +} + +export function isPeerMsgId(value: unknown): value is string { + return ( + typeof value === 'string' && + MSG_ID_RE.test(value) && + canonicalizeMsgId(value) !== 'all' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function isDeliveryStatus(value: unknown): value is PeerDeliveryStatus { + return ( + typeof value === 'string' && + (PEER_DELIVERY_STATUSES as readonly string[]).includes(value) + ); +} + +function optionalDropReason(value: unknown): PeerDropReason | undefined { + return value === 'rate-limited' || + value === 'duplicate' || + value === 'queue-full' + ? value + : undefined; +} + +/** + * Parse one line into a frame, or return null. + * + * A higher `msgV`, an unknown `type` and a malformed field all come back as + * null rather than an error: nothing about an unintelligible line is worth + * interrupting anyone for. + */ +export function parsePeerFrame(line: string): PeerFrame | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + + const msgV = parsed['msgV']; + if (typeof msgV !== 'number' || msgV > PEER_FRAME_VERSION) return null; + const msgId = parsed['msgId']; + if (!isPeerMsgId(msgId)) return null; + + if (parsed['type'] === 'user') { + const message = parsed['message']; + if (!isRecord(message) || message['role'] !== 'user') return null; + const content = message['content']; + if (typeof content !== 'string' || content.length === 0) return null; + const fromMode = parsed['fromMode']; + const replyToken = optionalString(parsed['replyToken']); + const toSessionId = optionalString(parsed['toSessionId']); + return { + msgV, + msgId, + type: 'user', + from: optionalString(parsed['from']), + ...(replyToken !== undefined ? { replyToken } : {}), + fromName: optionalString(parsed['fromName']), + ...(fromMode === 'bypass' || fromMode === 'prompting' + ? { fromMode } + : {}), + ...(toSessionId !== undefined ? { toSessionId } : {}), + priority: parsed['priority'] === 'now' ? 'now' : 'next', + message: { role: 'user', content }, + }; + } + + if (parsed['type'] === 'control') { + if (parsed['action'] !== 'delivery_status') return null; + const status = parsed['status']; + if (!isDeliveryStatus(status)) return null; + const origMsgId = parsed['origMsgId']; + if (typeof origMsgId !== 'string' || origMsgId.length === 0) return null; + // Only a drop carries these. Reading them off any other status would let + // a peer attach ids to, say, a `delivered` receipt and settle messages + // that receipt says nothing about. + const dropReason = + status === 'dropped' + ? optionalDropReason(parsed['dropReason']) + : undefined; + const rawIds = status === 'dropped' ? parsed['droppedMsgIds'] : undefined; + const droppedMsgIds = Array.isArray(rawIds) + ? rawIds.filter(isPeerMsgId).slice(0, MAX_DROPPED_MSG_IDS) + : []; + return { + msgV, + msgId, + type: 'control', + action: 'delivery_status', + status, + origMsgId, + from: optionalString(parsed['from']), + reason: optionalString(parsed['reason']), + ...(dropReason !== undefined ? { dropReason } : {}), + ...(droppedMsgIds.length > 0 ? { droppedMsgIds } : {}), + }; + } + + return null; +} + +/** One frame as one line, terminator included. */ +export function encodePeerFrame(frame: PeerFrame): string { + return `${JSON.stringify(frame)}\n`; +} + +/** + * The first line of a connection to an inbox that requires a token. + * + * Shaped like a frame, so an inbox that requires no token reads it as an + * unknown type and skips it: leading with it is always safe. + */ +export function buildAuthLine(token: string): string { + return `${JSON.stringify({ msgV: PEER_FRAME_VERSION, type: 'auth', token })}\n`; +} + +/** The token an auth line presents, or null when the line is not one. */ +export function parsePeerAuthLine(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + const msgV = parsed['msgV']; + if (typeof msgV !== 'number' || msgV > PEER_FRAME_VERSION) return null; + if (parsed['type'] !== 'auth') return null; + const token = parsed['token']; + return typeof token === 'string' && token.length > 0 ? token : null; +} + +export interface BuildUserFrameFields { + content: string; + from?: string; + replyToken?: string; + fromName?: string; + fromMode?: PeerModeClass; + toSessionId?: string; + priority?: PeerMessagePriority; +} + +export function buildUserFrame(fields: BuildUserFrameFields): PeerUserFrame { + return { + msgV: PEER_FRAME_VERSION, + msgId: randomUUID(), + type: 'user', + ...(fields.from !== undefined ? { from: fields.from } : {}), + ...(fields.replyToken !== undefined + ? { replyToken: fields.replyToken } + : {}), + ...(fields.fromName !== undefined ? { fromName: fields.fromName } : {}), + ...(fields.fromMode !== undefined ? { fromMode: fields.fromMode } : {}), + ...(fields.toSessionId !== undefined + ? { toSessionId: fields.toSessionId } + : {}), + priority: fields.priority ?? 'next', + message: { role: 'user', content: fields.content }, + }; +} + +export interface BuildDeliveryStatusFields { + status: PeerDeliveryStatus; + origMsgId: string; + from?: string; + reason?: string; + dropReason?: PeerDropReason; + droppedMsgIds?: readonly string[]; +} + +export function buildDeliveryStatusFrame( + fields: BuildDeliveryStatusFields, +): PeerControlFrame { + return { + msgV: PEER_FRAME_VERSION, + msgId: randomUUID(), + type: 'control', + action: 'delivery_status', + status: fields.status, + origMsgId: fields.origMsgId, + ...(fields.from !== undefined ? { from: fields.from } : {}), + reason: fields.reason ?? describeDeliveryStatus(fields.status), + ...(fields.status === 'dropped' && fields.dropReason !== undefined + ? { dropReason: fields.dropReason } + : {}), + // Left off rather than sent empty: the field means "these ids too". + ...(fields.status === 'dropped' && + fields.droppedMsgIds !== undefined && + fields.droppedMsgIds.length > 0 + ? { droppedMsgIds: fields.droppedMsgIds.slice(0, MAX_DROPPED_MSG_IDS) } + : {}), + }; +} + +/** A sentence for a person about what a status means for the sender. */ +export function describeDeliveryStatus(status: PeerDeliveryStatus): string { + switch (status) { + case 'held': + return 'Your message is waiting for the recipient to review it.'; + case 'denied': + return 'The recipient reviewed your message and declined it.'; + case 'refused': + return 'The recipient does not take messages like this one, so nobody saw it. Do not re-send it.'; + case 'expired': + return 'Your message was not delivered: it waited too long, or the recipient closed first.'; + case 'delivered': + return 'Your message was delivered.'; + case 'misaddressed': + return 'That address belongs to a different session than the one you named. Look the recipient up again before re-sending.'; + case 'dropped': + return 'Your message was turned away before anyone saw it. Treat it as unsent.'; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +} diff --git a/packages/sdk-typescript/src/peer/identity.ts b/packages/sdk-typescript/src/peer/identity.ts new file mode 100644 index 00000000000..779e89f2f50 --- /dev/null +++ b/packages/sdk-typescript/src/peer/identity.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The facts a session record uses to prove which process wrote it. + * + * A PID alone proves nothing: PIDs are recycled within a boot, repeat + * across PID namespaces, and repeat across machines that share one home + * directory. A record therefore carries three things — the PID, a start + * token that changes when the PID is recycled, and the PID namespace it + * was written from — and a reader believes a record only when all three + * still describe a running process as the reader sees it. + * + * Linux exposes all three cheaply. Everywhere else the token and the + * namespace are `null`, and liveness degrades to "is the PID running". + */ + +import * as fs from 'node:fs'; + +/** + * True when `pid` belongs to a running process. + * + * `EPERM` means the process exists but belongs to another user: that is + * still alive. A zombie answers the signal too, but it has exited and will + * never act again, so it counts as dead. + */ +export function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return !isZombie(pid); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return (code === 'EPERM' || code === 'EACCES') && !isZombie(pid); + } +} + +/** Fields after the parenthesized command name of `/proc//stat`. */ +function statFieldsAfterComm(pid: number): string[] | null { + let raw: string; + try { + raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + } catch { + return null; + } + // The command name may itself contain spaces and ')', so anchor on the + // last one: field 3 (state) is the first token after it. + const commEnd = raw.lastIndexOf(')'); + if (commEnd === -1) return null; + return raw + .slice(commEnd + 1) + .trim() + .split(/\s+/); +} + +function isZombie(pid: number): boolean { + if (process.platform !== 'linux') return false; + return statFieldsAfterComm(pid)?.[0] === 'Z'; +} + +let cachedBootId: string | undefined; + +/** + * The kernel's per-boot id, or null when it cannot be read. + * + * A success is cached, since it cannot change while this process lives. A + * failure is not: the first read tends to land at a moment of descriptor + * pressure, and caching that would disable reuse protection for good. + */ +export function readLocalBootId(): string | null { + if (cachedBootId !== undefined) return cachedBootId; + if (process.platform !== 'linux') return null; + try { + const value = fs + .readFileSync('/proc/sys/kernel/random/boot_id', 'utf8') + .trim(); + if (/^[0-9a-f-]+$/i.test(value)) { + cachedBootId = value; + return value; + } + } catch { + // Retried on the next call. + } + return null; +} + +/** + * `:` for `pid` on Linux, or null. + * + * Start ticks separate two processes that shared a PID within one boot; the + * boot id separates boots, and with them machines. Without the boot id this + * returns null rather than bare ticks, so one machine never writes two + * token shapes that a reader could mistake for a mismatch. + */ +export function readProcStartToken(pid: number): string | null { + if (process.platform !== 'linux') return null; + if (!Number.isInteger(pid) || pid <= 0) return null; + const bootId = readLocalBootId(); + if (bootId === null) return null; + // Field 22 (starttime) is the 20th token after the command name. + const startTime = statFieldsAfterComm(pid)?.[19]; + return startTime !== undefined && /^\d+$/.test(startTime) + ? `${bootId}:${startTime}` + : null; +} + +/** The inode of this process's PID namespace on Linux, or null. */ +export function readPidNamespaceId(): number | null { + if (process.platform !== 'linux') return null; + try { + return fs.statSync('/proc/self/ns/pid').ino; + } catch { + return null; + } +} + +/** The boot id inside a start token, or null for a token without one. */ +export function bootIdOf(procStart: string): string | null { + const separator = procStart.indexOf(':'); + return separator === -1 ? null : procStart.slice(0, separator); +} + +/** + * True when `pid` is alive and is the process that recorded `procStart`. + * + * A missing token on either side degrades to plain liveness: declaring a + * live session dead is the worse mistake. + */ +export function isSameProcess( + pid: number, + procStart: string | null | undefined, +): boolean { + if (!isPidAlive(pid)) return false; + if (procStart == null) return true; + const current = readProcStartToken(pid); + if (current === null) return true; + return current === procStart; +} diff --git a/packages/sdk-typescript/src/peer/inbox.ts b/packages/sdk-typescript/src/peer/inbox.ts new file mode 100644 index 00000000000..3f3d03401c3 --- /dev/null +++ b/packages/sdk-typescript/src/peer/inbox.ts @@ -0,0 +1,401 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The listening side: one UNIX domain socket that reads NDJSON frames. + * + * Access control is the filesystem plus a token. The directory is 0700 and + * the socket 0600, so only this user can connect; the first line of every + * connection must present the token published in this endpoint's record, + * or the connection is dropped unread. A token proves the connection is + * allowed, not who opened it, so every field of a frame stays a claim. + * + * This inbox accepts exactly one token. It does not keep the review + * machinery a Qwen Code session keeps for itself — rate limits, holds, + * trusted controllers — because a program that joins the directory decides + * for itself what to do with a message it receives. + */ + +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import * as fsSync from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { isLocalIpcPath, probePeerSocketVerdict } from './client.js'; +import { describeError, PeerEndpointError } from './errors.js'; +import { + MAX_FRAME_CHARS, + parsePeerAuthLine, + parsePeerFrame, + type PeerFrame, +} from './frames.js'; + +/** + * Longest usable socket path: `sun_path` holds 108 bytes on Linux and 104 + * on macOS, NUL included, and bind fails rather than truncating. + */ +export const MAX_SOCKET_PATH_BYTES = 103; + +/** The directory (or, in a shared temp directory, the prefix) of a socket. */ +export const SOCKET_DIR_NAME = 'qwen-socks'; + +/** + * Most connections open at once. A sender connects, writes and hangs up, + * so anything past a handful is a bug or a flood, and without a ceiling one + * process could hold this one's descriptors. + */ +export const MAX_PEER_CONNECTIONS = 64; + +/** + * How long a connection may go without completing a line that counts. + * + * Only the auth line and a frame that parses re-arm it. A timer any byte + * resets could be held open by a peer dribbling one byte at a time, and one + * a junk line resets by a peer writing a junk line per period. + */ +export const LINE_DEADLINE_MS = 30_000; + +const SOCKET_DIR_MODE = 0o700; +const SOCKET_MODE = 0o600; + +const NONCE_DIRNAME = new RegExp(`^${SOCKET_DIR_NAME}-[0-9a-f]{16}$`); + +export interface PeerInboxOptions { + /** + * Bind here instead of walking the candidate paths. If something live + * already answers there, a sibling `-<8 hex>.sock` is bound instead; + * the returned inbox's `socketPath` is the address actually bound. + */ + socketPath?: string; + /** The token a connection's first line must present. */ + requiredToken: string; + /** Called for each frame that parses. Should not throw. */ + onFrame: (frame: PeerFrame) => void; + /** Keep the process running while the inbox is open. Default true. */ + keepAlive?: boolean; + /** Override {@link LINE_DEADLINE_MS}, for tests. */ + lineDeadlineMs?: number; +} + +export interface PeerInbox { + readonly socketPath: string; + /** Drop live connections, stop listening and remove the socket file. */ + close(): Promise; + /** The same, for an exit handler where nothing can await. */ + closeSync(): void; +} + +/** + * Where this process may bind, best first. + * + * `$XDG_RUNTIME_DIR` is a per-user directory that disappears at logout, + * the right lifetime for a socket. Anywhere else the temp directory may be + * shared with other users, where a predictable directory name is a target + * someone else can create first — so the name carries a nonce, and peers + * learn the address from the record, never by deriving it. + */ +export function resolveInboxCandidates(pid: number = process.pid): string[] { + if (process.platform === 'win32') return []; + const candidates: string[] = []; + const runtimeDir = process.env['XDG_RUNTIME_DIR']; + if (runtimeDir) { + candidates.push(path.join(runtimeDir, SOCKET_DIR_NAME, `${pid}.sock`)); + } + const nonce = randomBytes(8).toString('hex'); + candidates.push( + path.join(os.tmpdir(), `${SOCKET_DIR_NAME}-${nonce}`, `${pid}.sock`), + path.join('/tmp', `${SOCKET_DIR_NAME}-${nonce}`, `${pid}.sock`), + ); + return candidates.filter( + (candidate, index) => + Buffer.byteLength(candidate) <= MAX_SOCKET_PATH_BYTES && + candidates.indexOf(candidate) === index, + ); +} + +/** + * A name beside `socketPath` keyed by the same PID, for when the PID-keyed + * one is held: two PID namespaces sharing a runtime directory can both + * resolve it. Null when the sibling would not fit `sun_path`. + */ +function siblingSocketPath(socketPath: string): string | null { + const base = path.basename(socketPath, '.sock').split('-')[0]!; + const sibling = path.join( + path.dirname(socketPath), + `${base}-${randomBytes(4).toString('hex')}.sock`, + ); + return Buffer.byteLength(sibling) <= MAX_SOCKET_PATH_BYTES ? sibling : null; +} + +function tokenMatches(expected: string, presented: string): boolean { + const a = Buffer.from(expected); + const b = Buffer.from(presented); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** + * Split a connection's bytes into lines. The cap is measured on what is + * left after complete lines are taken, so it bounds one line rather than + * how the bytes happened to arrive. + */ +function createLineReader( + onLine: (line: string) => void, + onOverflow: () => void, +): (chunk: string) => void { + let buffer = ''; + return (chunk) => { + buffer += chunk; + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim().length > 0) onLine(line); + newline = buffer.indexOf('\n'); + } + if (buffer.length > MAX_FRAME_CHARS) { + buffer = ''; + onOverflow(); + } + }; +} + +function listen(server: net.Server, target: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(target, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} + +async function bindAt( + requested: string, + options: PeerInboxOptions, +): Promise { + if (!isLocalIpcPath(requested)) { + throw new Error(`not an absolute local path: ${requested}`); + } + if (Buffer.byteLength(requested) > MAX_SOCKET_PATH_BYTES) { + throw new Error(`longer than ${MAX_SOCKET_PATH_BYTES} bytes: ${requested}`); + } + const dir = path.dirname(requested); + const ownsDir = NONCE_DIRNAME.test(path.basename(dir)); + const dropDirIfOurs = () => + ownsDir ? fs.rmdir(dir).catch(() => {}) : Promise.resolve(); + + const created = await fs.mkdir(dir, { + recursive: true, + mode: SOCKET_DIR_MODE, + }); + try { + // mkdir and chmod both follow a symlink, and a shared temp directory is + // somewhere another user can create this name first. Insist on a real + // directory this user owns before trusting its permissions. + const stats = await fs.lstat(dir); + if (!stats.isDirectory()) throw new Error(`${dir} is not a directory`); + const uid = process.getuid?.(); + if (uid !== undefined && stats.uid !== uid) { + throw new Error(`${dir} belongs to uid ${stats.uid}, not ${uid}`); + } + // Tightened only when it is a directory inboxes keep for themselves — + // one this call created, or one named for sockets. A directory a caller + // chose for its socket keeps its own permissions: the socket's 0600 is + // the access control, and taking traversal away from a shared directory + // would break whatever else lives there. + if ( + created !== undefined || + ownsDir || + path.basename(dir) === SOCKET_DIR_NAME + ) { + await fs.chmod(dir, SOCKET_DIR_MODE); + } + } catch (error) { + await dropDirIfOurs(); + throw error; + } + + // A socket file left by a crash blocks bind forever, so it has to go — + // but only when nothing answers on it. Anything short of a definitive + // `dead` takes a sibling name instead: unlinking a live socket would make + // its owner silently unreachable. + let target = requested; + if ((await probePeerSocketVerdict(target)) !== 'dead') { + const sibling = siblingSocketPath(target); + if (sibling === null) { + await dropDirIfOurs(); + throw new Error(`${target} is in use and no sibling name fits`); + } + target = sibling; + } + await fs.unlink(target).catch(() => {}); + + const keepAlive = options.keepAlive ?? true; + const lineDeadlineMs = options.lineDeadlineMs ?? LINE_DEADLINE_MS; + const connections = new Set(); + + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + connections.add(socket); + socket.setEncoding('utf8'); + if (!keepAlive) socket.unref(); + + let deadline: NodeJS.Timeout | undefined; + const arm = () => { + clearTimeout(deadline); + deadline = setTimeout(() => socket.destroy(), lineDeadlineMs); + deadline.unref(); + }; + arm(); + + let authed = false; + // Terminal: lines already buffered from the same chunk must not revive + // a connection that failed to authenticate. + let refused = false; + const read = createLineReader( + (line) => { + if (refused) return; + if (!authed) { + const presented = parsePeerAuthLine(line); + if ( + presented !== null && + tokenMatches(options.requiredToken, presented) + ) { + authed = true; + arm(); + return; + } + refused = true; + socket.destroy(); + return; + } + const frame = parsePeerFrame(line); + if (frame === null) return; + arm(); + try { + options.onFrame(frame); + } catch { + // A throwing callback must not take the connection down with it. + } + }, + () => { + refused = true; + socket.destroy(); + }, + ); + socket.on('data', read); + // The server is half-open so a sender can end its side right after + // writing; close this side too, or the connection lingers. + socket.on('end', () => socket.end()); + socket.on('error', () => {}); + socket.on('close', () => { + clearTimeout(deadline); + connections.delete(socket); + }); + }); + server.maxConnections = MAX_PEER_CONNECTIONS; + + try { + await listen(server, target); + } catch (error) { + // The path was taken between the probe and the listen. One sibling + // settles that; a second failure is a real one. + const sibling = + (error as NodeJS.ErrnoException).code === 'EADDRINUSE' + ? siblingSocketPath(target) + : null; + if (sibling === null) { + await dropDirIfOurs(); + throw error; + } + try { + await listen(server, sibling); + target = sibling; + } catch (retryError) { + await dropDirIfOurs(); + throw retryError; + } + } + server.on('error', () => {}); + + try { + await fs.chmod(target, SOCKET_MODE); + } catch (error) { + // The permission bits are the whole of the access control here; a + // socket that cannot be locked down is worse than none. + server.close(); + await fs.unlink(target).catch(() => {}); + await dropDirIfOurs(); + throw error; + } + if (!keepAlive) server.unref(); + + let closed = false; + const destroyConnections = () => { + for (const socket of connections) socket.destroy(); + connections.clear(); + }; + return { + socketPath: target, + async close() { + if (closed) return; + closed = true; + destroyConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await fs.unlink(target).catch(() => {}); + await dropDirIfOurs(); + }, + closeSync() { + if (closed) return; + closed = true; + destroyConnections(); + server.close(); + try { + fsSync.unlinkSync(target); + if (ownsDir) fsSync.rmdirSync(dir); + } catch { + // Best effort on the way out. + } + }, + }; +} + +/** + * Bind an inbox at `options.socketPath`, or at the first candidate path + * that works. + * + * Throws {@link PeerEndpointError} `unsupported-platform` where there is no + * candidate at all, and `bind-failed` when every candidate failed — naming + * the first, which is the one configuration or the environment chose. + */ +export async function startPeerInbox( + options: PeerInboxOptions, +): Promise { + const candidates = + options.socketPath !== undefined + ? [options.socketPath] + : resolveInboxCandidates(); + if (candidates.length === 0) { + throw new PeerEndpointError( + 'unsupported-platform', + 'peer inboxes need UNIX domain sockets, which this platform does not offer here', + ); + } + let firstFailure: string | undefined; + for (const candidate of candidates) { + try { + return await bindAt(candidate, options); + } catch (error) { + firstFailure ??= `${candidate}: ${describeError(error)}`; + } + } + throw new PeerEndpointError( + 'bind-failed', + `could not bind a peer inbox (tried ${candidates.length} path${ + candidates.length === 1 ? '' : 's' + }; first: ${firstFailure})`, + ); +} diff --git a/packages/sdk-typescript/src/peer/index.ts b/packages/sdk-typescript/src/peer/index.ts new file mode 100644 index 00000000000..1d5a1e21f09 --- /dev/null +++ b/packages/sdk-typescript/src/peer/index.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `@qwen-code/sdk/peer`: join the Qwen Code sessions running as this user. + * + * An implementation of the cross-session protocol for a program that is + * not itself a Qwen Code session — a voice front-end, a relay, a build + * watcher. Node only, and built from Node's own modules alone. + * + * ```ts + * import { PeerEndpoint } from '@qwen-code/sdk/peer'; + * + * const endpoint = await PeerEndpoint.start({ + * name: 'voice-bridge', + * onMessage: (message) => console.log(message.fromName, message.content), + * }); + * const [session] = await endpoint.list(); + * const sent = await endpoint.send({ to: session.address, content: 'status?' }); + * ``` + * + * `PeerEndpoint` is the whole of what most programs need. The pieces it is + * built from are exported too, for a program that wants to hold its own + * record or socket. + */ + +export { + MAX_REMEMBERED_MESSAGES, + MAX_TRACKED_SENDS, + PeerEndpoint, + describeSendFailure, + type AwaitReceiptOptions, + type PeerEndpointOptions, + type PeerInboundMessage, + type PeerReceipt, + type PeerSendOptions, + type PeerSendResult, + type PeerSessionSummary, +} from './endpoint.js'; +export { PeerEndpointError, type PeerEndpointErrorCode } from './errors.js'; +export { + MAX_DROPPED_MSG_IDS, + MAX_FRAME_CHARS, + PEER_DELIVERY_STATUSES, + PEER_FRAME_VERSION, + buildAuthLine, + buildDeliveryStatusFrame, + buildUserFrame, + canonicalizeMsgId, + describeDeliveryStatus, + encodePeerFrame, + isPeerMsgId, + parsePeerAuthLine, + parsePeerFrame, + type BuildDeliveryStatusFields, + type BuildUserFrameFields, + type PeerControlFrame, + type PeerDeliveryStatus, + type PeerDropReason, + type PeerFrame, + type PeerMessagePriority, + type PeerModeClass, + type PeerUserFrame, +} from './frames.js'; +export { + MAX_CONCURRENT_SENDS, + PROBE_TIMEOUT_MS, + PeerSendError, + SEND_TIMEOUT_MS, + isLocalIpcPath, + probePeerSocketVerdict, + sendPeerFrame, + type PeerSocketVerdict, + type SendPeerFrameOptions, +} from './client.js'; +export { + LINE_DEADLINE_MS, + MAX_PEER_CONNECTIONS, + MAX_SOCKET_PATH_BYTES, + resolveInboxCandidates, + startPeerInbox, + type PeerInbox, + type PeerInboxOptions, +} from './inbox.js'; +export { + advertisablePeerAddress, + reachableEntries, + resolvePeerTarget, + suggestPeerNames, + toDirectoryEntry, + type PeerDirectoryEntry, + type PeerResolution, +} from './directory.js'; +export { + SESSION_REGISTRY_SCHEMA_VERSION, + readLiveSessionRecords, + resolveQwenHome, + sessionRegistryDir, + type SessionRecord, +} from './registry.js'; +export { + MAX_LABEL_CHARS, + MAX_SESSION_NAME_CHARS, + deriveSessionName, + flattenPeerLabel, + peerRef, +} from './label.js'; diff --git a/packages/sdk-typescript/src/peer/label.ts b/packages/sdk-typescript/src/peer/label.ts new file mode 100644 index 00000000000..2e61bb6b4e8 --- /dev/null +++ b/packages/sdk-typescript/src/peer/label.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Names, as sessions display and type them. + * + * Every name and directory in the registry was written by the process it + * describes and ends up in listings, tool output and error messages. So it + * is flattened to one line — control, format and bidirectional characters + * collapsed to a space — and bounded, before anything shows it. + */ + +import { createHash } from 'node:crypto'; +import * as path from 'node:path'; + +/** + * Control and format characters, and the line and paragraph separators — + * matched by Unicode category rather than by a list of ranges, so blocks + * nobody thought to list (tag characters, the Mongolian vowel separator, + * interlinear annotation marks) are covered along with zero-width spaces + * and bidi overrides. + */ +const INVISIBLE_RUN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu; + +const ELLIPSIS = '…'; + +/** Longest flattened label kept anywhere a sender names itself. */ +export const MAX_LABEL_CHARS = 200; + +/** Longest name a record keeps: it is printed in a fixed-width column. */ +export const MAX_SESSION_NAME_CHARS = 40; + +/** + * One line of printable text, at most {@link MAX_LABEL_CHARS} code points. + * Counted in code points so the cut never leaves half of an astral + * character behind. + */ +export function flattenPeerLabel(value: string): string { + const oneLine = value.replace(INVISIBLE_RUN, ' ').trim(); + const points = Array.from(oneLine); + return points.length > MAX_LABEL_CHARS + ? `${points.slice(0, MAX_LABEL_CHARS - 1).join('')}${ELLIPSIS}` + : oneLine; +} + +/** + * The name a record stores: flattened, then cut to + * {@link MAX_SESSION_NAME_CHARS} code points. Counted in code points so an + * astral character at the boundary is never split into a lone surrogate. + */ +export function boundSessionName(name: string): string { + const points = Array.from(flattenPeerLabel(name)); + return points.length > MAX_SESSION_NAME_CHARS + ? `${points.slice(0, MAX_SESSION_NAME_CHARS - 1).join('')}${ELLIPSIS}` + : points.join(''); +} + +/** + * Six hex characters of `sha256(sessionId)`: the handle that tells two + * sessions of the same name apart. Derived rather than random, so a session + * prints the same one every time it is listed. + */ +export function peerRef(sessionId: string): string { + return createHash('sha256').update(sessionId).digest('hex').slice(0, 6); +} + +/** + * A readable default name: the directory's basename plus two hex characters + * of the session id, since two sessions in one directory is the common case. + */ +export function deriveSessionName(cwd: string, sessionId: string): string { + const base = Array.from( + path + .basename(cwd) + .normalize('NFC') + .replace(/[^\p{L}\p{M}\p{N}._-]+/gu, '-') + .replace(/^-+|-+$/g, ''), + ) + .slice(0, 32) + .join(''); + const suffix = createHash('sha256') + .update(sessionId) + .digest('hex') + .slice(0, 2); + return `${base || 'session'}-${suffix}`; +} diff --git a/packages/sdk-typescript/src/peer/registry.ts b/packages/sdk-typescript/src/peer/registry.ts new file mode 100644 index 00000000000..609e664d0b5 --- /dev/null +++ b/packages/sdk-typescript/src/peer/registry.ts @@ -0,0 +1,419 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The session registry: one small JSON file per running session, in a flat + * directory under the Qwen home. + * + * A running session is found by reading this directory, and a record is + * believed only when the process it names is still the one that wrote it + * (see `identity.ts`). This module writes and removes this process's own + * record and reads everyone's — and never deletes anyone else's. Clearing + * out the records of sessions that died is left to Qwen Code sessions + * themselves: a program that joins the directory has no business removing + * what other processes wrote. + */ + +import { randomBytes } from 'node:crypto'; +import * as fsSync from 'node:fs'; +import * as fs from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + bootIdOf, + isSameProcess, + readLocalBootId, + readPidNamespaceId, + readProcStartToken, +} from './identity.js'; + +export const SESSION_REGISTRY_SCHEMA_VERSION = 1; + +const REGISTRY_DIR_MODE = 0o700; +const RECORD_FILE_MODE = 0o600; + +/** A record is a few hundred bytes; anything this large is not one. */ +const MAX_RECORD_BYTES = 64 * 1024; + +/** + * `.json`, or the `-<8 hex>.json` a process holding more than one + * record writes. Strict on purpose: a lenient match would read a stray + * `2026-notes.json` as PID 2026. + */ +const RECORD_FILENAME = /^(\d+)(?:-[0-9a-f]{8})?\.json$/; + +/** What a reader keeps in `kind`: wider than what any one build writes. */ +const KIND_RE = /^[a-z][a-z0-9-]{0,15}$/; + +export interface SessionRecord { + schemaVersion: number; + pid: number; + /** `:` on Linux; null elsewhere. */ + procStart: string | null; + /** PID-namespace inode on Linux; null elsewhere. */ + pidNs: number | null; + sessionId: string; + cwd: string; + name: string; + /** Epoch milliseconds. */ + startedAt: number; + qwenVersion: string | null; + /** What registered: `tui`, `headless`, `serve`, `external`, or newer. */ + kind?: string; + /** The inbox socket; absent means discoverable but not messageable. */ + ipcPath?: string; + /** What a connection to `ipcPath` presents on its auth line. */ + ipcToken?: string; +} + +export function isValidSessionKind(kind: string): boolean { + return KIND_RE.test(kind); +} + +/** + * The Qwen home this process should use: `explicit`, else `QWEN_HOME`, else + * `~/.qwen`. A leading `~` is expanded and a relative path is resolved + * against the working directory, the way Qwen Code itself reads the + * variable — two programs that disagree on this never see each other. + */ +export function resolveQwenHome(explicit?: string): string { + const configured = explicit || process.env['QWEN_HOME']; + if (configured) { + let resolved = configured; + if ( + resolved === '~' || + resolved.startsWith('~/') || + resolved.startsWith('~\\') + ) { + resolved = path.join( + os.homedir(), + ...resolved + .slice(2) + .split(/[/\\]+/) + .filter(Boolean), + ); + } + return path.resolve(resolved); + } + const home = os.homedir(); + return home ? path.join(home, '.qwen') : path.join(os.tmpdir(), '.qwen'); +} + +export function sessionRegistryDir(qwenHome: string): string { + return path.join(qwenHome, 'sessions'); +} + +/** The PID a record filename is keyed by, or null when it is not a record. */ +export function pidOfRecordFilename(name: string): number | null { + const match = RECORD_FILENAME.exec(name); + if (!match) return null; + const digits = match[1]!; + const pid = Number.parseInt(digits, 10); + // Canonical decimal only: nothing writes `007.json`, so a file named that + // belongs to something else. + return Number.isSafeInteger(pid) && pid > 0 && String(pid) === digits + ? pid + : null; +} + +type RecordRead = + | { status: 'ok'; record: SessionRecord } + /** Absent. */ + | { status: 'missing' } + /** Not a record this schema can describe: torn, malformed, oversized. */ + | { status: 'unusable' } + /** A well-formed record from a newer schema. */ + | { status: 'newer' } + /** The read failed for a reason other than absence; the file may be fine. */ + | { status: 'read-error' }; + +function parseRecordText(raw: string): RecordRead { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { status: 'unusable' }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { status: 'unusable' }; + } + const value = parsed as Record; + const schemaVersion = value['schemaVersion']; + if (typeof schemaVersion !== 'number') return { status: 'unusable' }; + if (schemaVersion > SESSION_REGISTRY_SCHEMA_VERSION) { + return { status: 'newer' }; + } + const { pid, sessionId, cwd, name, startedAt } = value; + if ( + typeof pid !== 'number' || + !Number.isInteger(pid) || + pid <= 0 || + typeof sessionId !== 'string' || + typeof cwd !== 'string' || + typeof name !== 'string' || + typeof startedAt !== 'number' || + !Number.isFinite(startedAt) + ) { + return { status: 'unusable' }; + } + const { procStart, pidNs, qwenVersion, kind, ipcPath, ipcToken } = value; + return { + status: 'ok', + record: { + schemaVersion, + pid, + procStart: typeof procStart === 'string' ? procStart : null, + pidNs: typeof pidNs === 'number' && Number.isFinite(pidNs) ? pidNs : null, + sessionId, + cwd, + name, + startedAt, + qwenVersion: typeof qwenVersion === 'string' ? qwenVersion : null, + // Absent means a writer older than the field, which reads differently + // from a malformed one; a kind this build does not know is kept. + ...(typeof kind === 'string' && KIND_RE.test(kind) ? { kind } : {}), + ...(typeof ipcPath === 'string' && ipcPath.length > 0 ? { ipcPath } : {}), + ...(typeof ipcToken === 'string' && ipcToken.length > 0 + ? { ipcToken } + : {}), + }, + }; +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; +} + +async function readRecordFile(filePath: string): Promise { + try { + const stats = await fs.stat(filePath); + if (!stats.isFile() || stats.size > MAX_RECORD_BYTES) { + return { status: 'unusable' }; + } + return parseRecordText(await fs.readFile(filePath, 'utf8')); + } catch (error) { + return isMissing(error) ? { status: 'missing' } : { status: 'read-error' }; + } +} + +function readRecordFileSync(filePath: string): RecordRead { + try { + const stats = fsSync.statSync(filePath); + if (!stats.isFile() || stats.size > MAX_RECORD_BYTES) { + return { status: 'unusable' }; + } + return parseRecordText(fsSync.readFileSync(filePath, 'utf8')); + } catch (error) { + return isMissing(error) ? { status: 'missing' } : { status: 'read-error' }; + } +} + +/** + * Every record whose process is still the one that wrote it, newest first. + * + * A record is skipped — never deleted — when its file name and `pid` + * disagree, when it was written from another PID namespace or another boot + * (its PIDs resolve to different processes here), or when its process is + * gone. An unreadable directory is an empty list. + */ +export async function readLiveSessionRecords( + dir: string, +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch { + return []; + } + const ownNamespace = readPidNamespaceId(); + const ownBootId = readLocalBootId(); + const live: SessionRecord[] = []; + await Promise.all( + entries.map(async (name) => { + const pid = pidOfRecordFilename(name); + if (pid === null) return; + const read = await readRecordFile(path.join(dir, name)); + if (read.status !== 'ok') return; + const record = read.record; + if (record.pid !== pid) return; + if (record.pidNs !== ownNamespace) return; + const recordBootId = + record.procStart === null ? null : bootIdOf(record.procStart); + if (recordBootId !== null && recordBootId !== ownBootId) return; + if (!isSameProcess(record.pid, record.procStart)) return; + live.push(record); + }), + ); + return live.sort((a, b) => b.startedAt - a.startedAt); +} + +/** + * True when a record at this process's PID-keyed path was left by an + * earlier process that held the same PID number, in this namespace and this + * boot. Only that record is provably nobody's now: a record from this very + * process belongs to whatever else in it registered, and one without a start + * token cannot be told apart from a live one. + */ +function isDeadPredecessor(record: SessionRecord): boolean { + if (record.pid !== process.pid) return false; + if (record.pidNs !== readPidNamespaceId()) return false; + if (record.procStart === null) return false; + const current = readProcStartToken(process.pid); + if (current === null) return false; + return ( + bootIdOf(record.procStart) === bootIdOf(current) && + record.procStart !== current + ); +} + +async function isClaimable( + filePath: string, + shared: boolean, +): Promise { + let stats: fsSync.Stats; + try { + stats = await fs.lstat(filePath); + } catch (error) { + return isMissing(error); + } + // A minted name is never reused, and nothing but a plain file is ever + // replaced: a symlink here was planted, not written by a session. + if (!shared || !stats.isFile()) return false; + const existing = await readRecordFile(filePath); + switch (existing.status) { + case 'missing': + case 'unusable': + return true; + case 'ok': + return isDeadPredecessor(existing.record); + default: + return false; + } +} + +async function ensureRegistryDir(dir: string): Promise { + await fs.mkdir(dir, { recursive: true, mode: REGISTRY_DIR_MODE }); + try { + // mkdir's mode is masked by the umask and ignored for an existing + // directory; chmod is what actually makes it 0700. + await fs.chmod(dir, REGISTRY_DIR_MODE); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // Filesystems without POSIX permissions cannot honour it at all. + if (code !== 'ENOSYS' && code !== 'ENOTSUP') throw error; + } +} + +/** + * Write `contents` beside `filePath` and rename it into place, so a reader + * sees either the old file or the whole new one. The temporary is created + * exclusively, which also refuses to follow a symlink planted at its name. + */ +async function writeAtomically( + filePath: string, + contents: string, +): Promise { + const temporary = `${filePath}.${randomBytes(6).toString('hex')}.tmp`; + let handle: FileHandle | undefined; + try { + handle = await fs.open(temporary, 'wx', RECORD_FILE_MODE); + await handle.writeFile(contents, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.rename(temporary, filePath); + } catch (error) { + await handle?.close().catch(() => {}); + await fs.unlink(temporary).catch(() => {}); + throw error; + } +} + +/** + * Registrations in this process, one at a time. + * + * Claiming the PID-keyed name is a read and then a rename. Two claims that + * overlap inside one process would both find the name free and both take + * it, the second silently replacing the first and leaving that endpoint in + * no file at all. In sequence, the second finds the first's record and + * mints a name of its own. Other processes cannot collide on the name: it + * is keyed by this PID. + */ +let registrations: Promise = Promise.resolve(); + +/** + * Publish `record` for this process and return where it went. + * + * The PID-keyed `.json` is the name every reader understands, so it is + * taken when it is free. When it is held — by another registration in this + * same process, by a record from another namespace or machine that shares + * this PID number, by a newer schema — the record goes to a freshly minted + * `-<8 hex>.json` instead of overwriting something that may be live. + */ +export function writeOwnRecord( + dir: string, + record: SessionRecord, +): Promise { + const run = () => claimAndWrite(dir, record); + const written = registrations.then(run, run); + registrations = written.catch(() => {}); + return written; +} + +async function claimAndWrite( + dir: string, + record: SessionRecord, +): Promise { + await ensureRegistryDir(dir); + const contents = JSON.stringify(record, null, 2); + const shared = path.join(dir, `${record.pid}.json`); + if (await isClaimable(shared, true)) { + await writeAtomically(shared, contents); + return shared; + } + for (let attempt = 0; attempt < 3; attempt += 1) { + const minted = path.join( + dir, + `${record.pid}-${randomBytes(4).toString('hex')}.json`, + ); + if (await isClaimable(minted, false)) { + await writeAtomically(minted, contents); + return minted; + } + } + throw new Error(`no free record name for PID ${record.pid} in ${dir}`); +} + +function isOwnRecord(read: RecordRead, sessionId: string): boolean { + return ( + read.status === 'ok' && + read.record.pid === process.pid && + read.record.sessionId === sessionId + ); +} + +/** + * Remove this process's record for `sessionId`. Anything else found at the + * path — a record another registration has since written there — stays. + */ +export async function removeOwnRecord( + filePath: string, + sessionId: string, +): Promise { + if (!isOwnRecord(await readRecordFile(filePath), sessionId)) return; + await fs.unlink(filePath).catch(() => {}); +} + +/** {@link removeOwnRecord} for an exit handler, where nothing can await. */ +export function removeOwnRecordSync(filePath: string, sessionId: string): void { + if (!isOwnRecord(readRecordFileSync(filePath), sessionId)) return; + try { + fsSync.unlinkSync(filePath); + } catch { + // Already gone. + } +} diff --git a/packages/sdk-typescript/test/unit/peer-build-assertions.test.ts b/packages/sdk-typescript/test/unit/peer-build-assertions.test.ts new file mode 100644 index 00000000000..b8e078d6255 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer-build-assertions.test.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + assertPeerBundle, + assertPeerDeclarations, +} from '../../scripts/peer-build-assertions.js'; + +describe('peer build assertions', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qpb-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function write(name: string, contents: string): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, contents); + return filePath; + } + + it('passes declarations that only name the subpath itself', () => { + write( + 'index.d.ts', + "/** `@qwen-code/sdk/peer` */\nexport * from './endpoint.js';\n", + ); + write('endpoint.d.ts', 'export declare class PeerEndpoint {}\n'); + expect(() => assertPeerDeclarations(dir)).not.toThrow(); + }); + + it('reads every declaration, not only the entry that re-exports them', () => { + write('index.d.ts', "export * from './registry.js';\n"); + write( + 'registry.d.ts', + "import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';\n", + ); + expect(() => assertPeerDeclarations(dir)).toThrow(/registry\.d\.ts/); + }); + + it('insists the entry declaration exists', () => { + write('endpoint.d.ts', 'export {};\n'); + expect(() => assertPeerDeclarations(dir)).toThrow(/index\.d\.ts/); + }); + + it('refuses a bundle over budget or carrying a dependency', () => { + const clean = write('clean.js', 'export const a = 1;\n'); + expect(() => assertPeerBundle(clean)).not.toThrow(); + expect(() => assertPeerBundle(clean, 4)).toThrow(/bytes/); + const leaky = write('leaky.js', 'import "zod";\n'); + expect(() => assertPeerBundle(leaky)).toThrow(/zod/); + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer-surface.test.ts b/packages/sdk-typescript/test/unit/peer-surface.test.ts new file mode 100644 index 00000000000..65dd8e72aa2 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer-surface.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import * as Peer from '../../src/peer/index.js'; +import type { + PeerEndpointOptions, + PeerReceipt, + PeerSendResult, +} from '../../src/peer/index.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(here, '..', '..'); + +describe('@qwen-code/sdk/peer — opt-in peer surface', () => { + it('exports the endpoint and the pieces it is built from', () => { + expect(typeof Peer.PeerEndpoint.start).toBe('function'); + for (const name of [ + 'PeerEndpointError', + 'PeerSendError', + 'parsePeerFrame', + 'encodePeerFrame', + 'buildAuthLine', + 'parsePeerAuthLine', + 'buildUserFrame', + 'buildDeliveryStatusFrame', + 'sendPeerFrame', + 'probePeerSocketVerdict', + 'startPeerInbox', + 'readLiveSessionRecords', + 'resolveQwenHome', + 'resolvePeerTarget', + 'peerRef', + 'flattenPeerLabel', + ] as const) { + expect(typeof Peer[name]).toBe('function'); + } + }); + + it('names every outcome of a send in its type', () => { + expectTypeOf().toEqualTypeOf< + 'sent' | 'self' | 'not-found' | 'ambiguous' | 'failed' + >(); + expectTypeOf().toEqualTypeOf< + Peer.PeerDeliveryStatus | 'pending' + >(); + expectTypeOf().toEqualTypeOf(); + }); + + // An import of the peer module, in any of the forms a module specifier + // can take: `../peer`, `./peer/frames.js`, `@qwen-code/sdk/peer`. Prose + // that merely mentions the word does not count. + const isPeerImport = (source: string) => + /from\s+['"][^'"]*\/peer(?:\/[^'"]*)?['"]/.test(source); + + it('recognises every form a peer import can take', () => { + expect(isPeerImport("export * from '../peer';")).toBe(true); + expect(isPeerImport("import { x } from './peer/frames.js';")).toBe(true); + expect(isPeerImport("import { x } from '@qwen-code/sdk/peer';")).toBe(true); + expect(isPeerImport("import { x } from './session.js';")).toBe(false); + expect(isPeerImport('// talks to a peer/relay')).toBe(false); + }); + + it('stays out of the default and daemon entries', () => { + // The daemon entries are bundled for browsers, where the endpoint's + // sockets cannot exist; the default entry is what every query() user + // loads. The endpoint is opt-in for both. + for (const entry of ['src/index.ts', 'src/daemon/index.ts']) { + expect(isPeerImport(readFileSync(join(packageRoot, entry), 'utf8'))).toBe( + false, + ); + } + }); + + it('declares the ./peer subpath in package.json exports', () => { + const pkg = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8'), + ) as { exports: Record> }; + expect(pkg.exports['./peer']).toEqual({ + types: './dist/peer/index.d.ts', + import: './dist/peer/index.js', + require: './dist/peer/index.cjs', + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/client.test.ts b/packages/sdk-typescript/test/unit/peer/client.test.ts new file mode 100644 index 00000000000..80d4a632615 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/client.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isLocalIpcPath, + PeerSendError, + probePeerSocketVerdict, + sendPeerFrame, +} from '../../../src/peer/client.js'; +import { + buildAuthLine, + buildUserFrame, + MAX_FRAME_CHARS, +} from '../../../src/peer/frames.js'; +import { MAX_CONCURRENT_SENDS } from '../../../src/peer/client.js'; +import { makeTempRoot, noUnixSockets } from './helpers.js'; + +describe('isLocalIpcPath', () => { + it.runIf(process.platform !== 'win32')( + 'accepts only absolute local paths', + () => { + expect(isLocalIpcPath('/run/user/1000/qwen-socks/1.sock')).toBe(true); + expect(isLocalIpcPath('relative.sock')).toBe(false); + expect(isLocalIpcPath('//server/share/1.sock')).toBe(false); + expect(isLocalIpcPath('/tmp/a\0b')).toBe(false); + expect(isLocalIpcPath('')).toBe(false); + }, + ); +}); + +describe.skipIf(noUnixSockets)('sendPeerFrame and probePeerSocket', () => { + let root: string; + const servers: net.Server[] = []; + + beforeEach(() => { + root = makeTempRoot(); + }); + + afterEach(() => { + for (const server of servers.splice(0)) server.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + function listen( + name: string, + onConnection: (socket: net.Socket) => void, + ): Promise { + const socketPath = path.join(root, name); + const server = net.createServer({ allowHalfOpen: true }, onConnection); + servers.push(server); + return new Promise((resolve) => + server.listen(socketPath, () => resolve(socketPath)), + ); + } + + it('writes the auth line and the frame together, and resolves when the peer closes', async () => { + const chunks: string[] = []; + const socketPath = await listen('peer.sock', (socket) => { + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => chunks.push(chunk)); + socket.on('end', () => socket.end()); + }); + const frame = buildUserFrame({ content: 'hello' }); + + await sendPeerFrame(socketPath, frame, { authToken: 'secret' }); + + expect(chunks[0]).toBe( + `${buildAuthLine('secret')}${JSON.stringify(frame)}\n`, + ); + }); + + it('leaves the auth line off when there is no token to present', async () => { + let received = ''; + const socketPath = await listen('open.sock', (socket) => { + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + received += chunk; + }); + socket.on('end', () => socket.end()); + }); + const frame = buildUserFrame({ content: 'hello' }); + await sendPeerFrame(socketPath, frame); + expect(received).toBe(`${JSON.stringify(frame)}\n`); + }); + + it('times out on a peer that accepts and never closes', async () => { + const socketPath = await listen('stuck.sock', () => {}); + await expect( + sendPeerFrame(socketPath, buildUserFrame({ content: 'x' }), { + timeoutMs: 50, + }), + ).rejects.toMatchObject({ name: 'PeerSendError', code: 'ETIMEDOUT' }); + }); + + it('reports a missing address, and refuses an oversized frame without dialing', async () => { + const missing = path.join(root, 'missing.sock'); + await expect( + sendPeerFrame(missing, buildUserFrame({ content: 'x' })), + ).rejects.toMatchObject({ code: 'ENOENT', local: false }); + await expect( + sendPeerFrame( + missing, + buildUserFrame({ content: 'x'.repeat(MAX_FRAME_CHARS) }), + ), + ).rejects.toMatchObject({ code: 'EMSGSIZE' }); + const relative = sendPeerFrame( + 'relative.sock', + buildUserFrame({ content: 'x' }), + ); + await expect(relative).rejects.toBeInstanceOf(PeerSendError); + await expect(relative).rejects.toMatchObject({ code: undefined }); + }); + + it('tells a listener from nothing, and establishes nothing about a path it will not dial', async () => { + const socketPath = await listen('alive.sock', (socket) => socket.end()); + expect(await probePeerSocketVerdict(socketPath)).toBe('alive'); + expect(await probePeerSocketVerdict(path.join(root, 'missing.sock'))).toBe( + 'dead', + ); + expect(await probePeerSocketVerdict('relative.sock')).toBe('unknown'); + }); + + it.runIf(process.platform === 'linux')( + 'reads a file nothing listens on as dead', + async () => { + const stale = path.join(root, 'stale.sock'); + fs.writeFileSync(stale, ''); + expect(await probePeerSocketVerdict(stale)).toBe('dead'); + }, + ); +}); + +describe.skipIf(noUnixSockets)('the concurrent send ceiling', () => { + it('refuses the send past it locally, before dialing anything', async () => { + const root = makeTempRoot(); + const socketPath = path.join(root, 'blackhole.sock'); + const held: net.Socket[] = []; + const blackhole = net.createServer({ allowHalfOpen: true }, (socket) => { + held.push(socket); + socket.on('error', () => {}); + }); + await new Promise((resolve) => blackhole.listen(socketPath, resolve)); + try { + const inFlight = Array.from({ length: MAX_CONCURRENT_SENDS }, () => + sendPeerFrame(socketPath, buildUserFrame({ content: 'x' }), { + timeoutMs: 20_000, + }).catch((error: unknown) => error), + ); + await vi.waitFor(() => expect(held).toHaveLength(MAX_CONCURRENT_SENDS)); + + // A path nothing listens on: past the ceiling it is never dialed, so + // the answer is the local refusal and not ENOENT. + const started = Date.now(); + const refused = await sendPeerFrame( + path.join(root, 'never-dialed.sock'), + buildUserFrame({ content: 'y' }), + ).catch((error: unknown) => error); + expect(refused).toMatchObject({ + name: 'PeerSendError', + code: 'EBUSY', + local: true, + }); + expect(Date.now() - started).toBeLessThan(1_000); + + for (const socket of held) socket.destroy(); + await Promise.all(inFlight); + } finally { + blackhole.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/conformance.test.ts b/packages/sdk-typescript/test/unit/peer/conformance.test.ts new file mode 100644 index 00000000000..fa106f7e27d --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/conformance.test.ts @@ -0,0 +1,555 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The peer endpoint against Qwen Code's own implementation of the same + * protocol, in both directions. + * + * `@qwen-code/sdk/peer` shares no code with a Qwen Code session: it was + * written from the cross-session protocol page. So every place the two + * could disagree — what a record looks like, when it counts as live, how a + * frame parses, which token a connection presents, what a receipt says — is + * exercised here with one side from each. A failure means one of them, or + * the page, is wrong. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as coreControllers from '../../../../core/src/ipc/peer-controllers.js'; +import * as coreDirectory from '../../../../core/src/ipc/peer-directory.js'; +import * as coreEnvelope from '../../../../core/src/ipc/peer-envelope.js'; +import * as coreFrames from '../../../../core/src/ipc/peer-frames.js'; +import * as coreSend from '../../../../core/src/ipc/peer-send.js'; +import * as coreSocketPath from '../../../../core/src/ipc/socket-path.js'; +import * as coreClient from '../../../../core/src/ipc/uds-client.js'; +import * as coreInbox from '../../../../core/src/ipc/uds-inbox.js'; +import * as coreRegistry from '../../../../core/src/services/session-registry.js'; +import * as coreLiveness from '../../../../core/src/utils/process-liveness.js'; +import * as sdk from '../../../src/peer/index.js'; +import { + isSameProcess, + readPidNamespaceId, + readProcStartToken, +} from '../../../src/peer/identity.js'; +import { boundSessionName } from '../../../src/peer/label.js'; +import { makeTempRoot, noUnixSockets } from './helpers.js'; + +interface CoreArrival { + frame: coreFrames.PeerFrame; + auth?: string; + controller?: { id: string; label: string }; +} + +describe.skipIf(noUnixSockets)('peer endpoint ↔ Qwen Code session', () => { + let root: string; + let home: string; + let savedHome: string | undefined; + const cleanups: Array<() => Promise> = []; + + beforeEach(() => { + root = makeTempRoot(); + home = path.join(root, 'home'); + savedHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = home; + coreRegistry.resetRegisteredRecordPathForTest(); + }); + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + coreRegistry.resetRegisteredRecordPathForTest(); + if (savedHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = savedHome; + fs.rmSync(root, { recursive: true, force: true }); + }); + + async function startEndpoint( + options: Partial = {}, + ): Promise { + const endpoint = await sdk.PeerEndpoint.start({ + name: 'voice-bridge', + qwenHome: home, + socketPath: path.join(root, `sdk-${cleanups.length}.sock`), + closeOnExit: false, + keepAlive: false, + ...options, + }); + cleanups.push(() => endpoint.close()); + return endpoint; + } + + /** A session registered and listening the way a hosted Qwen Code session is. */ + async function startCoreSession( + options: Pick = {}, + ) { + const token = 'c'.repeat(64); + const arrivals: CoreArrival[] = []; + const registration = await coreRegistry.registerSession({ + sessionId: 'core-session', + cwd: '/w/core', + name: 'core-tui', + kind: 'tui', + slot: 'own', + }); + expect(registration.registered).toBe(true); + const inbox = await coreInbox.startPeerInbox({ + socketPath: path.join(root, 'core.sock'), + requiredToken: token, + onFrame: (frame, auth, controller) => + arrivals.push({ frame, auth, controller }), + ...options, + }); + expect(inbox).not.toBeNull(); + cleanups.push(async () => { + await inbox!.close(); + await coreRegistry.unregisterSession(registration.slot); + }); + expect( + await coreRegistry.patchSessionRecord( + { ipcPath: inbox!.socketPath, ipcToken: token }, + registration.slot, + ), + ).toBe(true); + return { inbox: inbox!, token, arrivals, slot: registration.slot }; + } + + it('is listed and reached by a Qwen Code session', async () => { + const endpoint = await startEndpoint(); + + expect(await coreRegistry.listLiveSessions()).toEqual([ + expect.objectContaining({ + sessionId: endpoint.sessionId, + name: 'voice-bridge', + kind: 'external', + ipcPath: endpoint.ipcPath, + ipcToken: endpoint.ipcToken, + }), + ]); + expect(await coreDirectory.listMessageablePeers()).toEqual([ + expect.objectContaining({ + sessionId: endpoint.sessionId, + name: endpoint.name, + ref: endpoint.ref, + kind: 'external', + }), + ]); + }); + + it('lists and reaches a Qwen Code session, which reads its frame and answers', async () => { + const core = await startCoreSession(); + const endpoint = await startEndpoint(); + + expect( + (await endpoint.list()).map((s) => [s.name, s.kind, s.sessionId]), + ).toEqual([['core-tui', 'tui', 'core-session']]); + + const sent = await endpoint.send({ + to: 'core-tui', + content: 'open the failing test', + }); + expect(sent.kind).toBe('sent'); + await vi.waitFor(() => expect(core.arrivals).toHaveLength(1)); + const { frame, auth } = core.arrivals[0]!; + expect(auth).toBe('peer'); + expect(frame).toMatchObject({ + type: 'user', + from: endpoint.ipcPath, + replyToken: endpoint.ipcToken, + fromName: 'voice-bridge', + toSessionId: 'core-session', + message: { role: 'user', content: 'open the failing test' }, + }); + expect(frame).not.toHaveProperty('fromMode'); + + const user = frame as coreFrames.PeerUserFrame; + await coreClient.sendDeliveryStatus( + user.from!, + { status: 'held', origMsgId: user.msgId, from: core.inbox.socketPath }, + user.replyToken, + ); + expect( + await endpoint.awaitReceipt(user.msgId, { timeoutMs: 5_000 }), + ).toMatchObject({ + status: 'held', + previous: 'pending', + address: 'core-tui', + }); + }); + + it('settles a burst from one folded drop receipt written by a Qwen Code session', async () => { + const core = await startCoreSession(); + const receipts: sdk.PeerReceipt[] = []; + const endpoint = await startEndpoint({ + onReceipt: (receipt) => receipts.push(receipt), + }); + for (const content of ['one', 'two', 'three']) { + await endpoint.send({ to: 'core-tui', content }); + } + await vi.waitFor(() => expect(core.arrivals).toHaveLength(3)); + const [first, ...rest] = core.arrivals.map( + (arrival) => arrival.frame as coreFrames.PeerUserFrame, + ); + + await coreClient.sendDeliveryStatus( + first!.from!, + { + status: 'dropped', + origMsgId: first!.msgId, + from: core.inbox.socketPath, + dropReason: 'rate-limited', + droppedMsgIds: rest.map((frame) => frame.msgId), + }, + first!.replyToken, + ); + await vi.waitFor(() => expect(receipts).toHaveLength(3)); + expect( + receipts.every( + (r) => r.status === 'dropped' && r.dropReason === 'rate-limited', + ), + ).toBe(true); + expect(receipts.map((r) => r.msgId).sort()).toEqual( + [first!, ...rest].map((frame) => frame.msgId).sort(), + ); + }); + + it('receives what a Qwen Code session sends, and its receipt reads back there', async () => { + const core = await startCoreSession(); + const messages: sdk.PeerInboundMessage[] = []; + const endpoint = await startEndpoint({ + onMessage: (message) => { + messages.push(message); + }, + }); + + const outcome = await coreSend.sendToPeer({ + target: 'voice-bridge', + message: 'tests pass', + approvalMode: null, + slot: core.slot, + }); + expect(outcome.kind).toBe('sent'); + + await vi.waitFor(() => expect(core.arrivals).toHaveLength(1)); + expect(messages).toEqual([ + { + msgId: expect.any(String), + content: 'tests pass', + priority: 'next', + from: core.inbox.socketPath, + fromName: 'core-tui', + }, + ]); + expect(core.arrivals[0]).toMatchObject({ + auth: 'peer', + frame: { + type: 'control', + status: 'delivered', + origMsgId: messages[0]!.msgId, + from: endpoint.ipcPath, + }, + }); + }); + + it('is recognised as a trusted controller only with a token the user minted', async () => { + const grants = path.join(home, 'peer-controllers.json'); + const { token } = await coreControllers.addPeerController( + 'voice-bridge', + grants, + ); + const core = await startCoreSession({ + resolveController: (presented) => + coreControllers.resolveControllerToken(presented, grants), + }); + + const trusted = await startEndpoint({ controllerToken: token }); + // Holding the token is not presenting it. + await trusted.send({ to: 'core-tui', content: 'plain' }); + await vi.waitFor(() => expect(core.arrivals).toHaveLength(1)); + expect(core.arrivals[0]).toMatchObject({ auth: 'peer' }); + await trusted.send({ to: 'core-tui', content: 'go', controller: true }); + await vi.waitFor(() => expect(core.arrivals).toHaveLength(2)); + expect(core.arrivals[1]).toMatchObject({ + auth: 'controller', + controller: { label: 'voice-bridge' }, + }); + + const forged = await startEndpoint({ + name: 'forged', + controllerToken: `qpc_${'0'.repeat(64)}`, + }); + await forged.send({ to: 'core-tui', content: 'go', controller: true }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(core.arrivals).toHaveLength(2); + }); + + it('parses every line the way a Qwen Code session does', () => { + const user = (overrides: Record = {}) => + JSON.stringify({ + msgV: 1, + msgId: 'm-1', + type: 'user', + priority: 'next', + message: { role: 'user', content: 'x' }, + ...overrides, + }); + const control = (overrides: Record = {}) => + JSON.stringify({ + msgV: 1, + msgId: 'c-1', + type: 'control', + action: 'delivery_status', + status: 'held', + origMsgId: 'm-1', + ...overrides, + }); + const lines = [ + user(), + user({ + from: '/tmp/a.sock', + replyToken: 'r', + fromName: 'n', + fromMode: 'bypass', + toSessionId: 's', + priority: 'now', + extra: true, + }), + user({ priority: 7, fromMode: 'yolo', from: 5, replyToken: null }), + user({ msgV: 0 }), + user({ msgV: 2 }), + user({ msgV: '1' }), + user({ msgId: 'all' }), + user({ msgId: 'A-l-L' }), + user({ msgId: 'has space' }), + user({ msgId: '_lead' }), + user({ msgId: 'x'.repeat(64) }), + user({ msgId: 'x'.repeat(65) }), + user({ message: { role: 'user', content: '' } }), + user({ message: { role: 'system', content: 'x' } }), + user({ message: ['x'] }), + user({ type: 'auth' }), + control(), + control({ reason: 'why', from: '/tmp/b.sock' }), + control({ dropReason: 'duplicate', droppedMsgIds: ['a'] }), + control({ + status: 'dropped', + dropReason: 'queue-full', + droppedMsgIds: [ + 'ok', + 'all', + 'bad id', + 7, + ...Array.from({ length: 300 }, (_, i) => `id${i}`), + ], + }), + control({ status: 'dropped', dropReason: 'bored', droppedMsgIds: 'a' }), + control({ status: 'dropped', droppedMsgIds: [] }), + control({ status: 'read' }), + control({ origMsgId: '' }), + control({ origMsgId: 3 }), + control({ action: 'rename' }), + '[]', + 'null', + '"string"', + 'not json', + '{"msgV":1,"msgId":"m","type":"user"}', + ]; + for (const line of lines) { + // The line rides along so a failure names the input that diverged. + expect({ line, parsed: sdk.parsePeerFrame(line) }).toEqual({ + line, + parsed: coreFrames.parsePeerFrame(line), + }); + } + for (const line of [ + '{"msgV":1,"type":"auth","token":"t"}', + '{"msgV":2,"type":"auth","token":"t"}', + '{"msgV":1,"type":"auth","token":""}', + '{"msgV":1,"type":"auth"}', + '{"msgV":1,"type":"user","token":"t"}', + 'nope', + ]) { + expect({ line, token: sdk.parsePeerAuthLine(line) }).toEqual({ + line, + token: coreFrames.parsePeerAuthLine(line), + }); + } + expect(sdk.buildAuthLine('tok')).toBe(coreFrames.buildAuthLine('tok')); + }); + + it('builds frames a Qwen Code session builds, up to the fresh id and the prose', () => { + const fields = { + content: 'hello', + from: '/tmp/me.sock', + replyToken: 'r', + fromName: 'me', + fromMode: 'prompting' as const, + toSessionId: 's', + priority: 'now' as const, + }; + const withoutId = (frame: T) => ({ + ...frame, + msgId: 'id', + }); + expect(withoutId(sdk.buildUserFrame(fields))).toEqual( + withoutId(coreFrames.buildUserFrame(fields)), + ); + const receipt = { + status: 'dropped' as const, + origMsgId: 'm', + from: '/tmp/r.sock', + dropReason: 'duplicate' as const, + droppedMsgIds: ['a', 'b'], + }; + const withoutProse = ( + frame: T, + ) => ({ ...frame, msgId: 'id', reason: 'prose' }); + expect(withoutProse(sdk.buildDeliveryStatusFrame(receipt))).toEqual( + withoutProse(coreFrames.buildDeliveryStatusFrame(receipt)), + ); + }); + + it('judges the registry directory the way a Qwen Code session does', async () => { + const endpoint = await startEndpoint(); + const dir = path.join(home, 'sessions'); + const base = { + schemaVersion: 1, + pid: process.pid, + procStart: readProcStartToken(process.pid), + pidNs: readPidNamespaceId(), + cwd: '/w', + name: 'planted', + startedAt: 5, + qwenVersion: null, + }; + const dead = 2 ** 22 + 11; + const plant = (name: string, contents: Record) => + fs.writeFileSync(path.join(dir, name), JSON.stringify(contents)); + plant(`${process.pid}-0000000a.json`, { + ...base, + sessionId: 'live-minted', + kind: 'serve', + ipcPath: '/tmp/x.sock', + }); + plant(`${process.pid}-0000000b.json`, { + ...base, + sessionId: 'odd-kind', + kind: 'Not-Valid', + ipcToken: '', + }); + plant('999999.json', { ...base, sessionId: 'name-mismatch' }); + plant(`0${process.pid}.json`, { ...base, sessionId: 'padded' }); + plant('2026-notes.json', { ...base, sessionId: 'notes', pid: 2026 }); + plant(`${process.pid}-0000000c.json`, { + ...base, + sessionId: 'other-namespace', + pidNs: -1, + }); + plant(`${process.pid}-0000000d.json`, { + ...base, + sessionId: 'newer', + schemaVersion: 2, + }); + if (process.platform === 'linux') { + const [boot, ticks] = readProcStartToken(process.pid)!.split(':'); + plant(`${process.pid}-0000000e.json`, { + ...base, + sessionId: 'reused-pid', + procStart: `${boot}:${Number(ticks) + 1}`, + }); + plant(`${process.pid}-0000000f.json`, { + ...base, + sessionId: 'no-namespace', + pidNs: null, + }); + } + plant(`${dead}.json`, { + ...base, + sessionId: 'dead', + pid: dead, + procStart: null, + }); + + const bySession = (a: { sessionId: string }, b: { sessionId: string }) => + a.sessionId.localeCompare(b.sessionId); + const fromSdk = (await sdk.readLiveSessionRecords(dir)).sort(bySession); + // Read second: a Qwen Code session also clears away the dead record. + const fromCore = (await coreRegistry.listLiveSessions()).sort(bySession); + + expect(fromSdk).toEqual(fromCore); + expect(fromSdk.map((r) => r.sessionId)).toEqual( + [endpoint.sessionId, 'live-minted', 'odd-kind'].sort(), + ); + }); + + it('derives identity, names and refs the way a Qwen Code session does', async () => { + expect(readProcStartToken(process.pid)).toBe( + coreLiveness.readProcStartToken(process.pid), + ); + expect(readPidNamespaceId()).toBe(coreLiveness.readPidNamespaceId()); + expect(isSameProcess(process.pid, readProcStartToken(process.pid))).toBe( + coreLiveness.isSameProcess(process.pid, readProcStartToken(process.pid)), + ); + + const awkward = [ + 'plain', + ' two\nlines\t', + 'bidi\u202eoverride\u200bzero-width', + 'x'.repeat(250), + '\u{1F600}'.repeat(45), + '\u{1F600}'.repeat(250), + 'voice\u{E0041}\u{E0042}bridge', + 'a\u180eb\ufff9c', + '', + ]; + for (const value of awkward) { + expect(sdk.flattenPeerLabel(value)).toBe( + coreEnvelope.flattenPeerLabel(value), + ); + expect(sdk.peerRef(value)).toBe(coreDirectory.peerRef(value)); + expect(sdk.deriveSessionName(`/w/${value}`, 'id')).toBe( + coreRegistry.deriveSessionName(`/w/${value}`, 'id'), + ); + } + + const longName = `named ${'\u{1F600}'.repeat(50)}`; + const registration = await coreRegistry.registerSession({ + sessionId: 'named-session', + cwd: '/w', + name: longName, + slot: 'own', + }); + cleanups.push(() => coreRegistry.unregisterSession(registration.slot)); + const [record] = await coreRegistry.listLiveSessions(); + expect(record?.name).toBe(boundSessionName(longName)); + }); + + it('agrees on every limit the protocol page publishes', () => { + expect(sdk.PEER_FRAME_VERSION).toBe(coreFrames.PEER_FRAME_VERSION); + expect(sdk.MAX_FRAME_CHARS).toBe(coreFrames.MAX_FRAME_BYTES); + expect(sdk.MAX_DROPPED_MSG_IDS).toBe(coreFrames.MAX_DROPPED_MSG_IDS); + expect(sdk.SEND_TIMEOUT_MS).toBe(coreClient.SEND_TIMEOUT_MS); + expect(sdk.PROBE_TIMEOUT_MS).toBe(coreClient.PROBE_TIMEOUT_MS); + expect(sdk.MAX_CONCURRENT_SENDS).toBe(coreClient.MAX_CONCURRENT_SENDS); + expect(sdk.MAX_PEER_CONNECTIONS).toBe(coreInbox.MAX_PEER_CONNECTIONS); + expect(sdk.LINE_DEADLINE_MS).toBe(coreInbox.LINE_DEADLINE_MS); + expect(sdk.MAX_SOCKET_PATH_BYTES).toBe( + coreSocketPath.MAX_SOCKET_PATH_BYTES, + ); + expect(sdk.MAX_SESSION_NAME_CHARS).toBe( + coreRegistry.MAX_SESSION_NAME_CHARS, + ); + expect(sdk.SESSION_REGISTRY_SCHEMA_VERSION).toBe( + coreRegistry.SESSION_REGISTRY_SCHEMA_VERSION, + ); + const nonce = (candidates: string[]) => + candidates.map((candidate) => + candidate.replace(/qwen-socks-[0-9a-f]{16}/, 'qwen-socks-'), + ); + expect(nonce(sdk.resolveInboxCandidates(4242))).toEqual( + nonce(coreSocketPath.resolvePeerSocketCandidates(4242)), + ); + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/directory.test.ts b/packages/sdk-typescript/test/unit/peer/directory.test.ts new file mode 100644 index 00000000000..32ec797bd36 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/directory.test.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + advertisablePeerAddress, + reachableEntries, + resolvePeerTarget, + suggestPeerNames, + toDirectoryEntry, + type PeerDirectoryEntry, +} from '../../../src/peer/directory.js'; +import { peerRef } from '../../../src/peer/label.js'; +import type { SessionRecord } from '../../../src/peer/registry.js'; + +function record(overrides: Partial = {}): SessionRecord { + const sessionId = overrides.sessionId ?? 'session-a'; + return { + schemaVersion: 1, + pid: 100, + procStart: null, + pidNs: null, + sessionId, + cwd: '/w/a', + name: 'alpha', + startedAt: 1, + qwenVersion: null, + ipcPath: `/tmp/${sessionId}.sock`, + ipcToken: 'tok', + ...overrides, + }; +} + +function entry(name: string, sessionId: string): PeerDirectoryEntry { + return toDirectoryEntry(record({ name, sessionId }))!; +} + +describe('toDirectoryEntry', () => { + it('reads a record without a kind as tui, and flattens what it shows', () => { + const peer = toDirectoryEntry( + record({ name: 'al\npha', cwd: '/w/\u202ea' }), + ); + expect(peer).toMatchObject({ + name: 'al pha', + cwd: '/w/ a', + kind: 'tui', + ref: peerRef('session-a'), + }); + }); + + it('has nothing to address without an inbox or a name', () => { + expect(toDirectoryEntry(record({ ipcPath: undefined }))).toBeNull(); + expect(toDirectoryEntry(record({ name: '\n\t' }))).toBeNull(); + }); +}); + +describe('reachableEntries', () => { + it('dials each address once, and keeps only what answers', async () => { + const probe = vi.fn(async (socketPath: string) => + socketPath === '/tmp/shared.sock' + ? ('alive' as const) + : ('dead' as const), + ); + const peers = await reachableEntries( + [ + record({ sessionId: 's1', name: 'one', ipcPath: '/tmp/shared.sock' }), + record({ sessionId: 's2', name: 'two', ipcPath: '/tmp/shared.sock' }), + record({ sessionId: 's3', name: 'three', ipcPath: '/tmp/gone.sock' }), + ], + probe, + ); + expect(probe).toHaveBeenCalledTimes(2); + expect(peers.map((peer) => peer.name).sort()).toEqual(['one', 'two']); + }); + + it('does not advertise an address whose probe established nothing', async () => { + const peers = await reachableEntries([record()], async () => 'unknown'); + expect(peers).toEqual([]); + }); + + it('keeps the newest of two processes running one session under one name', async () => { + const peers = await reachableEntries( + [ + record({ ipcPath: '/tmp/old.sock', startedAt: 1 }), + record({ ipcPath: '/tmp/new.sock', startedAt: 2 }), + ], + async () => 'alive', + ); + expect(peers).toHaveLength(1); + expect(peers[0]?.ipcPath).toBe('/tmp/new.sock'); + }); +}); + +describe('resolvePeerTarget', () => { + const alpha = entry('alpha', 'session-a'); + const beta = entry('beta', 'session-b'); + const peers = [alpha, beta]; + + it('resolves a name, name [ref], [ref] and a bare ref', () => { + for (const target of [ + 'alpha', + ` alpha [${alpha.ref}] `, + `[${alpha.ref}]`, + alpha.ref.toUpperCase(), + ]) { + expect(resolvePeerTarget(peers, target)).toEqual({ + kind: 'one', + peer: alpha, + }); + } + expect(resolvePeerTarget(peers, 'gamma')).toEqual({ kind: 'none' }); + expect(resolvePeerTarget(peers, ' ')).toEqual({ kind: 'none' }); + }); + + it('refuses to pick between two sessions that share a name', () => { + const twin = entry('alpha', 'session-c'); + const result = resolvePeerTarget([alpha, twin], 'alpha'); + expect(result.kind).toBe('ambiguous'); + expect(resolvePeerTarget([alpha, twin], `alpha [${twin.ref}]`)).toEqual({ + kind: 'one', + peer: twin, + }); + }); + + it('treats a name that equals another session ref as a claim from both', () => { + const impostor = entry(alpha.ref, 'session-z'); + expect(resolvePeerTarget([alpha, impostor], alpha.ref).kind).toBe( + 'ambiguous', + ); + }); + + it('matches a literal bracketed name as itself', () => { + const bracketed = entry('notes [abcd]', 'session-n'); + expect(resolvePeerTarget([bracketed], 'notes [abcd]')).toEqual({ + kind: 'one', + peer: bracketed, + }); + }); +}); + +describe('advertisablePeerAddress and suggestPeerNames', () => { + it('prints the bare name when it is unique, and adds the ref when it is not', () => { + const alpha = entry('alpha', 'session-a'); + const twin = entry('alpha', 'session-c'); + expect(advertisablePeerAddress(alpha, [alpha])).toBe('alpha'); + expect(advertisablePeerAddress(alpha, [alpha, twin])).toBe( + `alpha [${alpha.ref}]`, + ); + }); + + it('suggests prefix matches first, from a bracketed typo too', () => { + const peers = [ + entry('my-app', 's1'), + entry('app-server', 's2'), + entry('docs', 's3'), + ]; + expect(suggestPeerNames(peers, 'app [12')).toEqual([ + 'app-server', + 'my-app', + ]); + expect(suggestPeerNames(peers, '')).toEqual([]); + }); +}); + +describe('directory — twins and contested names', () => { + it('adds the ref to a suggestion whose name another session shares', () => { + const first = entry('app', 's1'); + const second = entry('app', 's2'); + const peers = [first, second, entry('docs', 's3')]; + expect(suggestPeerNames(peers, 'ap').sort()).toEqual( + [`app [${first.ref}]`, `app [${second.ref}]`].sort(), + ); + }); + + it('drops a twin that does not answer before picking the newest, so a dead copy never shadows a live one', async () => { + const peers = await reachableEntries( + [ + record({ ipcPath: '/tmp/alive.sock', startedAt: 1 }), + record({ ipcPath: '/tmp/dead.sock', startedAt: 2 }), + ], + async (socketPath) => + socketPath === '/tmp/alive.sock' ? 'alive' : 'dead', + ); + expect(peers.map((peer) => peer.ipcPath)).toEqual(['/tmp/alive.sock']); + }); + + it('keeps both twins when asked not to collapse them, which makes the name ambiguous', async () => { + const peers = await reachableEntries( + [ + record({ ipcPath: '/tmp/a.sock', startedAt: 1 }), + record({ ipcPath: '/tmp/b.sock', startedAt: 2 }), + ], + async () => 'alive', + { collapseTwins: false }, + ); + expect(peers).toHaveLength(2); + expect(resolvePeerTarget(peers, 'alpha').kind).toBe('ambiguous'); + expect(peers.map((peer) => advertisablePeerAddress(peer, peers))).toEqual([ + undefined, + undefined, + ]); + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/endpoint-identity.test.ts b/packages/sdk-typescript/test/unit/peer/endpoint-identity.test.ts new file mode 100644 index 00000000000..9af0343e66f --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/endpoint-identity.test.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PeerEndpoint } from '../../../src/peer/endpoint.js'; +import * as identity from '../../../src/peer/identity.js'; +import { makeTempRoot } from './helpers.js'; + +vi.mock('../../../src/peer/identity.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readPidNamespaceId: vi.fn(actual.readPidNamespaceId), + readProcStartToken: vi.fn(actual.readProcStartToken), + }; +}); + +describe.skipIf(process.platform !== 'linux')( + 'PeerEndpoint on Linux without its identity', + () => { + const roots: string[] = []; + + afterEach(() => { + vi.mocked(identity.readPidNamespaceId).mockReset(); + vi.mocked(identity.readProcStartToken).mockReset(); + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ['PID namespace', () => vi.mocked(identity.readPidNamespaceId)], + ['start token', () => vi.mocked(identity.readProcStartToken)], + ] as const)( + 'refuses to publish a record without its %s', + async (_label, reader) => { + const root = makeTempRoot(); + roots.push(root); + const home = path.join(root, 'home'); + const socketPath = path.join(root, 'inbox.sock'); + reader().mockReturnValue(null); + + await expect( + PeerEndpoint.start({ + name: 'x', + qwenHome: home, + socketPath, + closeOnExit: false, + keepAlive: false, + }), + ).rejects.toMatchObject({ code: 'registry-unwritable' }); + expect(fs.existsSync(home)).toBe(false); + expect(fs.existsSync(socketPath)).toBe(false); + }, + ); + }, +); diff --git a/packages/sdk-typescript/test/unit/peer/endpoint.test.ts b/packages/sdk-typescript/test/unit/peer/endpoint.test.ts new file mode 100644 index 00000000000..d4b0ee28125 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/endpoint.test.ts @@ -0,0 +1,748 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PeerSendError, sendPeerFrame } from '../../../src/peer/client.js'; +import { + describeSendFailure, + MAX_REMEMBERED_MESSAGES, + MAX_TRACKED_SENDS, + PeerEndpoint, + type PeerEndpointOptions, + type PeerInboundMessage, + type PeerReceipt, + type PeerSendResult, +} from '../../../src/peer/endpoint.js'; +import { + buildDeliveryStatusFrame, + buildUserFrame, + MAX_FRAME_CHARS, + type BuildDeliveryStatusFields, + type PeerControlFrame, + type PeerFrame, +} from '../../../src/peer/frames.js'; +import { startPeerInbox, type PeerInbox } from '../../../src/peer/inbox.js'; +import { + readPidNamespaceId, + readProcStartToken, +} from '../../../src/peer/identity.js'; +import { flattenPeerLabel, MAX_LABEL_CHARS } from '../../../src/peer/label.js'; +import { + listenForLines, + makeTempRoot, + noUnixSockets, + publishRecord, +} from './helpers.js'; + +function msgIdOf(result: PeerSendResult): string { + if (result.kind !== 'sent') { + throw new Error(`expected a sent result, got ${JSON.stringify(result)}`); + } + return result.msgId; +} + +const CONTROLLER_TOKEN = `qpc_${'a'.repeat(64)}`; + +/** The tokens auth lines presented, in the order connections ended. */ +function authTokens(lines: readonly string[]): Array { + return lines + .map((line) => JSON.parse(line) as { type: string; token?: string }) + .filter((line) => line.type === 'auth') + .map((line) => line.token); +} + +describe('describeSendFailure', () => { + it('says what to do next for each kind of failure', () => { + expect(describeSendFailure(new PeerSendError('x', 'ECONNREFUSED'))).toMatch( + /stale/, + ); + expect(describeSendFailure(new PeerSendError('x', 'EAGAIN'))).toMatch( + /retry/, + ); + expect(describeSendFailure(new PeerSendError('x', 'ETIMEDOUT'))).toMatch( + /rather than re-sending/, + ); + expect(describeSendFailure(new PeerSendError('as is', 'EMSGSIZE'))).toBe( + 'as is', + ); + const cap = new PeerSendError('Already sending 64 frames', 'EBUSY', { + local: true, + }); + expect(describeSendFailure(cap)).toMatch(/nothing was written/); + expect(describeSendFailure(cap)).not.toMatch(/that session is alive/); + }); +}); + +describe.skipIf(noUnixSockets)('PeerEndpoint', () => { + let root: string; + let home: string; + let counter: number; + const endpoints: PeerEndpoint[] = []; + const inboxes: PeerInbox[] = []; + const servers: net.Server[] = []; + + beforeEach(() => { + root = makeTempRoot(); + home = path.join(root, 'home'); + counter = 0; + }); + + afterEach(async () => { + await Promise.all(endpoints.splice(0).map((endpoint) => endpoint.close())); + await Promise.all(inboxes.splice(0).map((inbox) => inbox.close())); + for (const server of servers.splice(0)) server.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + async function start( + name: string, + options: Partial = {}, + ): Promise { + counter += 1; + const endpoint = await PeerEndpoint.start({ + name, + qwenHome: home, + socketPath: path.join(root, `e${counter}.sock`), + closeOnExit: false, + keepAlive: false, + ...options, + }); + endpoints.push(endpoint); + return endpoint; + } + + /** An inbox that never answers, published as a session. */ + async function silentSession( + registryDir: string, + name = 'silent', + ): Promise<{ inbox: PeerInbox; frames: PeerFrame[] }> { + const frames: PeerFrame[] = []; + const inbox = await startPeerInbox({ + socketPath: path.join(root, `${name}.sock`), + requiredToken: 'silent-token', + onFrame: (frame) => frames.push(frame), + keepAlive: false, + }); + inboxes.push(inbox); + await publishRecord(registryDir, { + sessionId: `${name}-session`, + name, + ipcPath: inbox.socketPath, + ipcToken: 'silent-token', + }); + return { inbox, frames }; + } + + it('publishes a record that names its inbox', async () => { + const endpoint = await start(' voice\nbridge ', { version: '1.2.3' }); + const record = JSON.parse(fs.readFileSync(endpoint.recordPath, 'utf8')); + expect(record).toEqual({ + schemaVersion: 1, + pid: process.pid, + procStart: readProcStartToken(process.pid), + pidNs: readPidNamespaceId(), + sessionId: endpoint.sessionId, + cwd: process.cwd(), + name: 'voice bridge', + startedAt: endpoint.startedAt, + qwenVersion: '1.2.3', + kind: 'external', + ipcPath: endpoint.ipcPath, + ipcToken: endpoint.ipcToken, + }); + expect(endpoint.ipcToken).toMatch(/^[0-9a-f]{64}$/); + expect(path.dirname(endpoint.recordPath)).toBe(path.join(home, 'sessions')); + expect(fs.existsSync(endpoint.ipcPath)).toBe(true); + }); + + it('refuses options it cannot publish, and leaves nothing behind', async () => { + await expect( + PeerEndpoint.start({ name: ' \n ', qwenHome: home }), + ).rejects.toMatchObject({ + name: 'PeerEndpointError', + code: 'invalid-name', + }); + await expect( + PeerEndpoint.start({ name: 'x', kind: 'Voice', qwenHome: home }), + ).rejects.toMatchObject({ code: 'invalid-kind' }); + await expect( + PeerEndpoint.start({ name: 'x', sessionId: ' ', qwenHome: home }), + ).rejects.toMatchObject({ code: 'invalid-session-id' }); + await expect( + PeerEndpoint.start({ + name: 'x', + qwenHome: home, + socketPath: 'relative.sock', + }), + ).rejects.toMatchObject({ code: 'bind-failed' }); + expect(fs.existsSync(home)).toBe(false); + }); + + it('lists the other sessions, never itself', async () => { + const alpha = await start('alpha'); + const beta = await start('beta', { kind: 'voice-relay' }); + expect(await alpha.list()).toEqual([ + { + sessionId: beta.sessionId, + name: 'beta', + ref: beta.ref, + address: 'beta', + cwd: flattenPeerLabel(process.cwd()), + pid: process.pid, + kind: 'voice-relay', + startedAt: beta.startedAt, + }, + ]); + }); + + it('delivers a message by name and reports the receipt', async () => { + const receipts: PeerReceipt[] = []; + const messages: PeerInboundMessage[] = []; + const alpha = await start('alpha', { + onReceipt: (receipt) => receipts.push(receipt), + }); + const beta = await start('beta', { + onMessage: (message) => { + messages.push(message); + }, + }); + + const result = await alpha.send({ + to: 'beta', + content: 'status?', + priority: 'now', + }); + expect(result).toMatchObject({ + kind: 'sent', + peer: { name: 'beta', sessionId: beta.sessionId, address: 'beta' }, + }); + const msgId = msgIdOf(result); + + const receipt = await alpha.awaitReceipt(msgId, { timeoutMs: 5_000 }); + expect(receipt).toMatchObject({ + msgId, + address: 'beta', + status: 'delivered', + previous: 'pending', + }); + expect(receipts).toEqual([receipt]); + expect(messages).toEqual([ + { + msgId, + content: 'status?', + priority: 'now', + from: alpha.ipcPath, + fromName: 'alpha', + }, + ]); + }); + + it('refuses messages when nothing handles them', async () => { + const alpha = await start('alpha'); + await start('beta'); + const msgId = msgIdOf(await alpha.send({ to: 'beta', content: 'hi' })); + expect(await alpha.awaitReceipt(msgId, { timeoutMs: 5_000 })).toMatchObject( + { status: 'refused' }, + ); + }); + + it('names what an address could not resolve to', async () => { + const alpha = await start('alpha'); + const first = await start('beta'); + const second = await start('beta'); + await start('gamma'); + + expect(await alpha.send({ to: 'delta', content: 'x' })).toEqual({ + kind: 'not-found', + suggestions: [], + }); + const ambiguous = await alpha.send({ to: 'beta', content: 'x' }); + expect(ambiguous.kind).toBe('ambiguous'); + expect( + ambiguous.kind === 'ambiguous' ? [...ambiguous.matches].sort() : [], + ).toEqual([`beta [${first.ref}]`, `beta [${second.ref}]`].sort()); + expect(await alpha.send({ to: 'alpha', content: 'x' })).toEqual({ + kind: 'self', + }); + expect(await alpha.send({ to: `[${alpha.ref}]`, content: 'x' })).toEqual({ + kind: 'self', + }); + expect(await alpha.send({ to: 'gamma', content: '' })).toMatchObject({ + kind: 'failed', + peer: { name: 'gamma' }, + }); + }); + + it('answers a frame pinned to another session misaddressed, and a repeated id the same as before', async () => { + const onMessage = vi.fn(); + const beta = await start('beta', { onMessage }); + const replies: PeerFrame[] = []; + const sender = await startPeerInbox({ + socketPath: path.join(root, 'sender.sock'), + requiredToken: 'sender-token', + onFrame: (frame) => replies.push(frame), + keepAlive: false, + }); + inboxes.push(sender); + const deliver = (frame: PeerFrame) => + sendPeerFrame(beta.ipcPath, frame, { authToken: beta.ipcToken }); + + const elsewhere = buildUserFrame({ + content: 'x', + from: sender.socketPath, + replyToken: 'sender-token', + toSessionId: 'someone-else', + }); + await deliver(elsewhere); + await vi.waitFor(() => expect(replies).toHaveLength(1)); + expect(replies[0]).toMatchObject({ + type: 'control', + status: 'misaddressed', + origMsgId: elsewhere.msgId, + from: beta.ipcPath, + }); + + const once = buildUserFrame({ + content: 'once', + from: sender.socketPath, + replyToken: 'sender-token', + }); + await deliver(once); + await deliver(once); + await vi.waitFor(() => expect(replies).toHaveLength(3)); + expect( + replies.slice(1).map((frame) => (frame as PeerControlFrame).status), + ).toEqual(['delivered', 'delivered']); + expect(onMessage).toHaveBeenCalledTimes(1); + }); + + it('applies receipts as state transitions, and ignores the rest', async () => { + const receipts: PeerReceipt[] = []; + const alpha = await start('alpha', { + onReceipt: (receipt) => receipts.push(receipt), + }); + const silent = await silentSession(alpha.registryDir); + const ids: string[] = []; + for (const content of ['one', 'two', 'three', 'four']) { + ids.push(msgIdOf(await alpha.send({ to: 'silent', content }))); + } + expect(silent.frames).toHaveLength(4); + const inject = (fields: BuildDeliveryStatusFields) => + sendPeerFrame(alpha.ipcPath, buildDeliveryStatusFrame(fields), { + authToken: alpha.ipcToken, + }); + + await inject({ status: 'delivered', origMsgId: 'never-sent' }); + await inject({ + status: 'dropped', + origMsgId: ids[0]!, + dropReason: 'rate-limited', + droppedMsgIds: [ids[1]!], + }); + expect( + receipts.map((r) => [r.msgId, r.status, r.previous, r.dropReason]), + ).toEqual([ + [ids[0], 'dropped', 'pending', 'rate-limited'], + [ids[1], 'dropped', 'pending', 'rate-limited'], + ]); + + const decided = alpha.awaitReceipt(ids[2]!, { + final: true, + timeoutMs: 5_000, + }); + await inject({ status: 'held', origMsgId: ids[2]! }); + expect(await alpha.awaitReceipt(ids[2]!)).toMatchObject({ + status: 'held', + }); + await inject({ status: 'delivered', origMsgId: ids[2]! }); + expect(await decided).toMatchObject({ + status: 'delivered', + previous: 'held', + }); + + // A step backwards, and anything after a drop, are repeats. + await inject({ status: 'held', origMsgId: ids[2]! }); + await inject({ status: 'delivered', origMsgId: ids[0]! }); + expect(receipts).toHaveLength(4); + + expect( + await alpha.awaitReceipt(ids[3]!, { timeoutMs: 20 }), + ).toBeUndefined(); + expect(await alpha.awaitReceipt('never-sent')).toBeUndefined(); + const waiting = alpha.awaitReceipt(ids[3]!, { timeoutMs: 60_000 }); + await alpha.close(); + expect(await waiting).toBeUndefined(); + }); + + it('presents the controller token only on a send marked controller', async () => { + const lines: string[] = []; + const capturePath = path.join(root, 'capture.sock'); + servers.push(await listenForLines(capturePath, lines)); + const plain = await start('plain'); + const trusted = await start('trusted', { + controllerToken: CONTROLLER_TOKEN, + }); + await publishRecord(plain.registryDir, { + sessionId: 'capture-session', + name: 'capture', + ipcPath: capturePath, + ipcToken: 'record-token', + }); + + expect((await plain.send({ to: 'capture', content: 'x' })).kind).toBe( + 'sent', + ); + // Holding a token is not presenting it: an unmarked send from the same + // endpoint uses the record's own token like any other. + expect((await trusted.send({ to: 'capture', content: 'x' })).kind).toBe( + 'sent', + ); + expect( + (await trusted.send({ to: 'capture', content: 'x', controller: true })) + .kind, + ).toBe('sent'); + expect(authTokens(lines)).toEqual([ + 'record-token', + 'record-token', + CONTROLLER_TOKEN, + ]); + }); + + it('reports a handler that throws or rejects, and still answers delivered', async () => { + const errors: Error[] = []; + const alpha = await start('alpha'); + await start('beta', { + onMessage: (message) => { + if (message.content === 'sync') throw new Error('sync boom'); + return Promise.reject(new Error('async boom')); + }, + onError: (error) => errors.push(error), + }); + for (const content of ['sync', 'async']) { + const msgId = msgIdOf(await alpha.send({ to: 'beta', content })); + expect( + await alpha.awaitReceipt(msgId, { timeoutMs: 5_000 }), + ).toMatchObject({ status: 'delivered' }); + } + await vi.waitFor(() => expect(errors).toHaveLength(2)); + expect(errors.map((error) => error.message).sort()).toEqual([ + 'async boom', + 'sync boom', + ]); + }); + + it('removes its record and socket on close, and refuses to act afterwards', async () => { + const alpha = await start('alpha'); + const { recordPath, ipcPath } = alpha; + await alpha.close(); + expect(fs.existsSync(recordPath)).toBe(false); + expect(fs.existsSync(ipcPath)).toBe(false); + await expect(alpha.list()).rejects.toMatchObject({ code: 'closed' }); + await expect( + alpha.send({ to: 'anyone', content: 'x' }), + ).rejects.toMatchObject({ code: 'closed' }); + await expect(alpha.close()).resolves.toBeUndefined(); + }); + + it('cleans up from an exit handler, and unhooks it on close', async () => { + const before = process.listenerCount('exit'); + const exiting = await start('exiting', { closeOnExit: true }); + expect(process.listenerCount('exit')).toBe(before + 1); + const hook = process.listeners('exit').at(-1) as (code: number) => void; + hook(0); + expect(fs.existsSync(exiting.recordPath)).toBe(false); + expect(fs.existsSync(exiting.ipcPath)).toBe(false); + expect(process.listenerCount('exit')).toBe(before); + + const closing = await start('closing', { closeOnExit: true }); + expect(process.listenerCount('exit')).toBe(before + 1); + await closing.close(); + expect(process.listenerCount('exit')).toBe(before); + }); + it('refuses a controller token no session would accept, and a controller send without one', async () => { + for (const controllerToken of ['', ' ', 'record-token', 'qpc_']) { + await expect( + PeerEndpoint.start({ name: 'x', qwenHome: home, controllerToken }), + ).rejects.toMatchObject({ code: 'invalid-controller-token' }); + } + expect(fs.existsSync(home)).toBe(false); + const plain = await start('plain'); + await expect( + plain.send({ to: 'anyone', content: 'x', controller: true }), + ).rejects.toMatchObject({ code: 'invalid-controller-token' }); + }); + + it('will not hand the controller token to a copy of a session record', async () => { + const lines: string[] = []; + const originalPath = path.join(root, 'original.sock'); + const copyPath = path.join(root, 'copy.sock'); + servers.push(await listenForLines(originalPath, lines)); + servers.push(await listenForLines(copyPath, lines)); + const trusted = await start('trusted', { + controllerToken: CONTROLLER_TOKEN, + }); + for (const [ipcPath, ipcToken] of [ + [originalPath, 'original-token'], + [copyPath, 'copy-token'], + ] as const) { + await publishRecord(trusted.registryDir, { + sessionId: 'victim-session', + name: 'victim', + ipcPath, + ipcToken, + }); + } + + expect( + await trusted.send({ to: 'victim', content: 'x', controller: true }), + ).toEqual({ kind: 'ambiguous', matches: [] }); + expect(lines).toEqual([]); + + // An unmarked send keeps the usual collapse of one session seen twice, + // and presents the chosen record's own token. + expect((await trusted.send({ to: 'victim', content: 'x' })).kind).toBe( + 'sent', + ); + expect(authTokens(lines)).toHaveLength(1); + expect(authTokens(lines)[0]).not.toBe(CONTROLLER_TOKEN); + }); + + it('reaches another endpoint unmarked, and not with a controller send', async () => { + const messages: string[] = []; + const trusted = await start('trusted', { + controllerToken: CONTROLLER_TOKEN, + }); + await start('beta', { + onMessage: (message) => { + messages.push(message.content); + }, + }); + const plainId = msgIdOf( + await trusted.send({ to: 'beta', content: 'plain' }), + ); + expect( + await trusted.awaitReceipt(plainId, { timeoutMs: 5_000 }), + ).toMatchObject({ status: 'delivered' }); + + const marked = await trusted.send({ + to: 'beta', + content: 'controller', + controller: true, + }); + expect(['sent', 'failed']).toContain(marked.kind); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(messages).toEqual(['plain']); + }); + + it('offers only addresses that select one session when a name is ambiguous', async () => { + const alpha = await start('alpha'); + const blocked = await start('x'); + await start('x'); + // Literal names that take both of the blocked session's other addresses. + await start(`x [${blocked.ref}]`); + await start(`[${blocked.ref}]`); + + const result = await alpha.send({ to: 'x', content: 'hi' }); + expect(result.kind).toBe('ambiguous'); + const matches = result.kind === 'ambiguous' ? result.matches : []; + expect(matches).toHaveLength(1); + expect(matches).not.toContain(`x [${blocked.ref}]`); + expect((await alpha.send({ to: matches[0]!, content: 'hi' })).kind).toBe( + 'sent', + ); + }); + + it('refuses a send that overlaps close() rather than writing from a closed endpoint', async () => { + const onMessage = vi.fn(); + const alpha = await start('alpha'); + await start('beta', { onMessage }); + // Settled into a value at once: the rejection lands while close() is + // still being awaited, before anything else could observe it. + const sending = alpha + .send({ to: 'beta', content: 'late' }) + .catch((error: unknown) => error); + await alpha.close(); + expect(await sending).toMatchObject({ code: 'closed' }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('flattens the reason a recipient writes before handing it on', async () => { + const receipts: PeerReceipt[] = []; + const alpha = await start('alpha', { + onReceipt: (receipt) => receipts.push(receipt), + }); + await silentSession(alpha.registryDir); + const msgId = msgIdOf(await alpha.send({ to: 'silent', content: 'x' })); + await sendPeerFrame( + alpha.ipcPath, + buildDeliveryStatusFrame({ + status: 'denied', + origMsgId: msgId, + reason: `denied\u001b[31m\n\u202e${'r'.repeat(5_000)}`, + }), + { authToken: alpha.ipcToken }, + ); + expect(receipts).toHaveLength(1); + const reason = receipts[0]!.reason!; + expect(reason.includes('\u001b')).toBe(false); + expect(reason.includes('\n')).toBe(false); + expect(reason.includes('\u202e')).toBe(false); + expect(Array.from(reason).length).toBeLessThanOrEqual(MAX_LABEL_CHARS); + }); + + it('forgets the oldest sends past its bound, and keeps sending', async () => { + const alpha = await start('alpha'); + await silentSession(alpha.registryDir); + const ids: string[] = []; + for (let i = 0; i <= MAX_TRACKED_SENDS; i += 1) { + ids.push(msgIdOf(await alpha.send({ to: 'silent', content: `m${i}` }))); + } + // Forgotten, so the wait ends at once instead of running out its time. + const started = Date.now(); + expect( + await alpha.awaitReceipt(ids[0]!, { timeoutMs: 10_000 }), + ).toBeUndefined(); + expect(Date.now() - started).toBeLessThan(1_000); + expect( + await alpha.awaitReceipt(ids.at(-1)!, { timeoutMs: 20 }), + ).toBeUndefined(); + }); + + it('forgets the oldest answered ids past its bound, and keeps reading', async () => { + const onMessage = vi.fn(); + const beta = await start('beta', { onMessage }); + const frames = Array.from({ length: MAX_REMEMBERED_MESSAGES + 1 }, (_, i) => + buildUserFrame({ content: `m${i}` }), + ); + const deliver = (frame: PeerFrame) => + sendPeerFrame(beta.ipcPath, frame, { authToken: beta.ipcToken }); + for (const frame of frames) await deliver(frame); + expect(onMessage).toHaveBeenCalledTimes(MAX_REMEMBERED_MESSAGES + 1); + + await deliver(frames.at(-1)!); + expect(onMessage).toHaveBeenCalledTimes(MAX_REMEMBERED_MESSAGES + 1); + await deliver(frames[0]!); + expect(onMessage).toHaveBeenCalledTimes(MAX_REMEMBERED_MESSAGES + 2); + }); + + it('closes its inbox again when the record cannot be written', async () => { + const notAHome = path.join(root, 'not-a-home'); + fs.writeFileSync(notAHome, ''); + const socketPath = path.join(root, 'rollback.sock'); + await expect( + PeerEndpoint.start({ + name: 'x', + qwenHome: notAHome, + socketPath, + closeOnExit: false, + keepAlive: false, + }), + ).rejects.toMatchObject({ code: 'registry-unwritable' }); + expect(fs.existsSync(socketPath)).toBe(false); + }); + + it('reports a receipt callback that throws', async () => { + const errors: Error[] = []; + const alpha = await start('alpha', { + onReceipt: () => { + throw new Error('receipt boom'); + }, + onError: (error) => errors.push(error), + }); + await start('beta', { onMessage: () => {} }); + const msgId = msgIdOf(await alpha.send({ to: 'beta', content: 'x' })); + expect(await alpha.awaitReceipt(msgId, { timeoutMs: 5_000 })).toMatchObject( + { status: 'delivered' }, + ); + expect(errors.map((error) => error.message)).toEqual(['receipt boom']); + }); + + it('hooks process exit by default', async () => { + const before = process.listenerCount('exit'); + counter += 1; + const endpoint = await PeerEndpoint.start({ + name: 'defaults', + qwenHome: home, + socketPath: path.join(root, `e${counter}.sock`), + keepAlive: false, + }); + endpoints.push(endpoint); + expect(process.listenerCount('exit')).toBe(before + 1); + await endpoint.close(); + expect(process.listenerCount('exit')).toBe(before); + }); + + it('treats another record with its own session id as itself, not as a peer', async () => { + const alpha = await start('alpha', { sessionId: 'stable-id' }); + const elsewhere = await startPeerInbox({ + socketPath: path.join(root, 'elsewhere.sock'), + requiredToken: 'elsewhere-token', + onFrame: () => {}, + keepAlive: false, + }); + inboxes.push(elsewhere); + await publishRecord(alpha.registryDir, { + sessionId: 'stable-id', + name: 'alpha-elsewhere', + ipcPath: elsewhere.socketPath, + ipcToken: 'elsewhere-token', + }); + expect(await alpha.list()).toEqual([]); + expect(await alpha.send({ to: 'alpha-elsewhere', content: 'x' })).toEqual({ + kind: 'self', + }); + }); + + it('forgets a send that never left, and keeps one that may still be read', async () => { + const receipts: PeerReceipt[] = []; + const alpha = await start('alpha', { + onReceipt: (receipt) => receipts.push(receipt), + }); + await silentSession(alpha.registryDir); + const oversized = await alpha.send({ + to: 'silent', + content: 'x'.repeat(MAX_FRAME_CHARS), + }); + expect(oversized).toMatchObject({ kind: 'failed', code: 'EMSGSIZE' }); + expect(oversized).not.toHaveProperty('msgId'); + + const held: net.Socket[] = []; + const stuckPath = path.join(root, 'stuck.sock'); + const stuck = net.createServer({ allowHalfOpen: true }, (socket) => { + held.push(socket); + socket.on('error', () => {}); + }); + await new Promise((resolve) => stuck.listen(stuckPath, resolve)); + try { + await publishRecord(alpha.registryDir, { + sessionId: 'stuck-session', + name: 'stuck', + ipcPath: stuckPath, + ipcToken: 'stuck-token', + }); + const timedOut = await alpha.send({ to: 'stuck', content: 'x' }); + expect(timedOut).toMatchObject({ + kind: 'failed', + code: 'ETIMEDOUT', + msgId: expect.any(String), + }); + const msgId = timedOut.kind === 'failed' ? timedOut.msgId! : ''; + await sendPeerFrame( + alpha.ipcPath, + buildDeliveryStatusFrame({ status: 'delivered', origMsgId: msgId }), + { authToken: alpha.ipcToken }, + ); + expect(receipts.map((receipt) => receipt.status)).toEqual(['delivered']); + } finally { + for (const socket of held) socket.destroy(); + stuck.close(); + } + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/frames.test.ts b/packages/sdk-typescript/test/unit/peer/frames.test.ts new file mode 100644 index 00000000000..21c83bd694e --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/frames.test.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + buildAuthLine, + buildDeliveryStatusFrame, + buildUserFrame, + canonicalizeMsgId, + describeDeliveryStatus, + encodePeerFrame, + isPeerMsgId, + MAX_DROPPED_MSG_IDS, + parsePeerAuthLine, + parsePeerFrame, + PEER_DELIVERY_STATUSES, + type PeerControlFrame, + type PeerUserFrame, +} from '../../../src/peer/frames.js'; + +const ID = '5f1d0c9e-3b2a-4e8f-9c7d-1a2b3c4d5e6f'; + +function user(overrides: Record = {}): string { + return JSON.stringify({ + msgV: 1, + msgId: ID, + type: 'user', + priority: 'next', + message: { role: 'user', content: 'build finished' }, + ...overrides, + }); +} + +function control(overrides: Record = {}): string { + return JSON.stringify({ + msgV: 1, + msgId: ID, + type: 'control', + action: 'delivery_status', + status: 'delivered', + origMsgId: 'orig-1', + ...overrides, + }); +} + +describe('parsePeerFrame — user frames', () => { + it('reads every field it knows', () => { + const frame = parsePeerFrame( + user({ + from: '/run/user/1000/qwen-socks/1.sock', + replyToken: 'reply', + fromName: 'project-3f', + fromMode: 'prompting', + toSessionId: 'session-b', + priority: 'now', + }), + ) as PeerUserFrame; + expect(frame).toEqual({ + msgV: 1, + msgId: ID, + type: 'user', + from: '/run/user/1000/qwen-socks/1.sock', + replyToken: 'reply', + fromName: 'project-3f', + fromMode: 'prompting', + toSessionId: 'session-b', + priority: 'now', + message: { role: 'user', content: 'build finished' }, + }); + }); + + it('reads a priority it does not know as next, and ignores unknown fields', () => { + const frame = parsePeerFrame( + user({ priority: 'urgent', futureField: { nested: true } }), + ) as PeerUserFrame; + expect(frame.priority).toBe('next'); + expect(frame).not.toHaveProperty('futureField'); + }); + + it('keeps fromMode only when it is one of the two classes', () => { + expect( + (parsePeerFrame(user({ fromMode: 'yolo' })) as PeerUserFrame).fromMode, + ).toBeUndefined(); + expect( + (parsePeerFrame(user({ fromMode: 'bypass' })) as PeerUserFrame).fromMode, + ).toBe('bypass'); + }); + + it.each([ + ['text that is not JSON', 'not json'], + ['an array', '[]'], + ['a newer msgV', user({ msgV: 2 })], + ['a msgV that is not a number', user({ msgV: '1' })], + ['a msgId with whitespace', user({ msgId: 'a b' })], + ['a msgId starting with a dash', user({ msgId: '-abc' })], + ['a msgId of 65 characters', user({ msgId: 'a'.repeat(65) })], + ['a msgId that canonicalizes to all', user({ msgId: 'A-l-L' })], + ['an unknown type', user({ type: 'rename' })], + ['empty content', user({ message: { role: 'user', content: '' } })], + [ + 'a role other than user', + user({ message: { role: 'assistant', content: 'x' } }), + ], + ['a message that is not an object', user({ message: 'hi' })], + ])('drops %s', (_label, line) => { + expect(parsePeerFrame(line)).toBeNull(); + }); +}); + +describe('parsePeerFrame — control frames', () => { + it('reads a receipt', () => { + expect( + parsePeerFrame(control({ from: '/tmp/a.sock', reason: 'ok' })), + ).toEqual({ + msgV: 1, + msgId: ID, + type: 'control', + action: 'delivery_status', + status: 'delivered', + origMsgId: 'orig-1', + from: '/tmp/a.sock', + reason: 'ok', + }); + }); + + it('reads dropReason and droppedMsgIds only on a drop', () => { + const extras = { dropReason: 'duplicate', droppedMsgIds: ['m2', 'm3'] }; + const delivered = parsePeerFrame(control(extras)) as PeerControlFrame; + expect(delivered.dropReason).toBeUndefined(); + expect(delivered.droppedMsgIds).toBeUndefined(); + + const dropped = parsePeerFrame( + control({ status: 'dropped', ...extras }), + ) as PeerControlFrame; + expect(dropped.dropReason).toBe('duplicate'); + expect(dropped.droppedMsgIds).toEqual(['m2', 'm3']); + }); + + it('keeps only well-formed dropped ids, and at most the cap', () => { + const ids = [ + 'bad id', + 'all', + ...Array.from({ length: 300 }, (_, i) => `m${i}`), + ]; + const frame = parsePeerFrame( + control({ status: 'dropped', droppedMsgIds: ids }), + ) as PeerControlFrame; + expect(frame.droppedMsgIds).toHaveLength(MAX_DROPPED_MSG_IDS); + expect(frame.droppedMsgIds?.[0]).toBe('m0'); + }); + + it.each([ + ['an action it does not know', control({ action: 'rename' })], + ['a status it does not know', control({ status: 'read' })], + ['an empty origMsgId', control({ origMsgId: '' })], + ])('drops %s', (_label, line) => { + expect(parsePeerFrame(line)).toBeNull(); + }); +}); + +describe('frame builders', () => { + it('writes a user frame the parser reads back unchanged', () => { + const frame = buildUserFrame({ + content: 'hello', + from: '/tmp/me.sock', + replyToken: 'tok', + fromName: 'me', + toSessionId: 'you', + }); + const line = encodePeerFrame(frame); + expect(line.endsWith('\n')).toBe(true); + expect(line.indexOf('\n')).toBe(line.length - 1); + expect(parsePeerFrame(line.trimEnd())).toEqual(frame); + expect(frame.priority).toBe('next'); + expect(isPeerMsgId(frame.msgId)).toBe(true); + }); + + it('leaves drop details off a receipt that is not a drop, and an empty id list off one that is', () => { + const delivered = buildDeliveryStatusFrame({ + status: 'delivered', + origMsgId: 'm1', + dropReason: 'duplicate', + droppedMsgIds: ['m2'], + }); + expect(delivered).not.toHaveProperty('dropReason'); + expect(delivered).not.toHaveProperty('droppedMsgIds'); + expect(delivered.reason).toEqual(expect.any(String)); + + const dropped = buildDeliveryStatusFrame({ + status: 'dropped', + origMsgId: 'm1', + dropReason: 'rate-limited', + droppedMsgIds: [], + }); + expect(dropped.dropReason).toBe('rate-limited'); + expect(dropped).not.toHaveProperty('droppedMsgIds'); + }); + + it('round-trips the auth line, and reads nothing else as one', () => { + expect(parsePeerAuthLine(buildAuthLine('secret').trimEnd())).toBe('secret'); + expect(parsePeerAuthLine(user())).toBeNull(); + expect( + parsePeerAuthLine(JSON.stringify({ msgV: 1, type: 'auth', token: '' })), + ).toBeNull(); + expect( + parsePeerAuthLine(JSON.stringify({ msgV: 2, type: 'auth', token: 't' })), + ).toBeNull(); + }); + + it('compares ids with dashes stripped and case folded', () => { + expect(canonicalizeMsgId('AB-cd-EF')).toBe('abcdef'); + }); +}); + +describe('describeDeliveryStatus', () => { + it('says something different for every status, and tells a sender when not to re-send', () => { + const texts = PEER_DELIVERY_STATUSES.map(describeDeliveryStatus); + expect(new Set(texts).size).toBe(PEER_DELIVERY_STATUSES.length); + expect(describeDeliveryStatus('refused')).toMatch(/do not re-send/i); + expect(describeDeliveryStatus('denied')).toMatch(/declined/); + expect(describeDeliveryStatus('denied')).not.toMatch(/re-send/i); + expect(describeDeliveryStatus('held')).toMatch(/review/); + expect(describeDeliveryStatus('expired')).toMatch(/not delivered/); + expect(describeDeliveryStatus('delivered')).toMatch(/was delivered/); + expect(describeDeliveryStatus('misaddressed')).toMatch( + /look the recipient up again/i, + ); + expect(describeDeliveryStatus('dropped')).toMatch(/unsent/); + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/helpers.ts b/packages/sdk-typescript/test/unit/peer/helpers.ts new file mode 100644 index 00000000000..1c61f305378 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/helpers.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + readPidNamespaceId, + readProcStartToken, +} from '../../../src/peer/identity.js'; +import { writeOwnRecord } from '../../../src/peer/registry.js'; + +/** UNIX domain sockets are what these suites exercise. */ +export const noUnixSockets = process.platform === 'win32'; + +/** A fresh directory short enough that sockets inside it fit `sun_path`. */ +export function makeTempRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'qp-')); +} + +/** + * Connect, write `text`, and resolve once the connection closes with + * whatever the other side wrote back. Resolves on a reset too: a dropped + * connection is an outcome these tests look for, not an error. + */ +export function dial( + socketPath: string, + text: string, + options: { end?: boolean } = {}, +): Promise<{ received: string }> { + return new Promise((resolve) => { + const socket = net.connect({ path: socketPath }); + let received = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + received += chunk; + }); + socket.on('connect', () => { + if (options.end ?? true) socket.end(text); + else socket.write(text); + }); + socket.on('error', () => {}); + socket.on('close', () => resolve({ received })); + }); +} + +/** A plain listener that records every line any connection writes. */ +export async function listenForLines( + socketPath: string, + lines: string[], +): Promise { + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + buffer += chunk; + }); + socket.on('end', () => { + lines.push(...buffer.split('\n').filter((line) => line.length > 0)); + socket.end(); + }); + socket.on('error', () => {}); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + server.unref(); + return server; +} + +/** Publish a record for something this process is pretending to be. */ +export function publishRecord( + registryDir: string, + fields: { + sessionId: string; + name: string; + ipcPath: string; + ipcToken?: string; + kind?: string; + }, +): Promise { + return writeOwnRecord(registryDir, { + schemaVersion: 1, + pid: process.pid, + procStart: readProcStartToken(process.pid), + pidNs: readPidNamespaceId(), + sessionId: fields.sessionId, + cwd: '/w', + name: fields.name, + startedAt: Date.now(), + qwenVersion: null, + kind: fields.kind ?? 'tui', + ipcPath: fields.ipcPath, + ...(fields.ipcToken !== undefined ? { ipcToken: fields.ipcToken } : {}), + }); +} diff --git a/packages/sdk-typescript/test/unit/peer/inbox.test.ts b/packages/sdk-typescript/test/unit/peer/inbox.test.ts new file mode 100644 index 00000000000..ef031183124 --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/inbox.test.ts @@ -0,0 +1,324 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildAuthLine, + buildUserFrame, + encodePeerFrame, + MAX_FRAME_CHARS, + type PeerFrame, +} from '../../../src/peer/frames.js'; +import { + MAX_SOCKET_PATH_BYTES, + resolveInboxCandidates, + startPeerInbox, + type PeerInbox, +} from '../../../src/peer/inbox.js'; +import { dial, makeTempRoot, noUnixSockets } from './helpers.js'; + +const TOKEN = 'a'.repeat(64); + +describe('resolveInboxCandidates', () => { + it.runIf(process.platform !== 'win32')( + 'prefers the runtime directory, then nonce directories that fit sun_path', + () => { + const saved = process.env['XDG_RUNTIME_DIR']; + process.env['XDG_RUNTIME_DIR'] = '/run/user/1000'; + try { + const candidates = resolveInboxCandidates(4242); + expect(candidates[0]).toBe('/run/user/1000/qwen-socks/4242.sock'); + expect(candidates.at(-1)).toMatch( + /^\/tmp\/qwen-socks-[0-9a-f]{16}\/4242\.sock$/, + ); + for (const candidate of candidates) { + expect(Buffer.byteLength(candidate)).toBeLessThanOrEqual( + MAX_SOCKET_PATH_BYTES, + ); + } + } finally { + if (saved === undefined) delete process.env['XDG_RUNTIME_DIR']; + else process.env['XDG_RUNTIME_DIR'] = saved; + } + }, + ); +}); + +describe.skipIf(noUnixSockets)('startPeerInbox', () => { + let root: string; + let frames: PeerFrame[]; + const opened: PeerInbox[] = []; + const servers: net.Server[] = []; + + beforeEach(() => { + root = makeTempRoot(); + frames = []; + }); + + afterEach(async () => { + await Promise.all(opened.splice(0).map((inbox) => inbox.close())); + for (const server of servers.splice(0)) server.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + async function open( + name = 'inbox.sock', + options: { + lineDeadlineMs?: number; + onFrame?: (frame: PeerFrame) => void; + } = {}, + ): Promise { + const inbox = await startPeerInbox({ + socketPath: path.join(root, 'socks', name), + requiredToken: TOKEN, + onFrame: options.onFrame ?? ((frame) => frames.push(frame)), + keepAlive: false, + ...(options.lineDeadlineMs !== undefined + ? { lineDeadlineMs: options.lineDeadlineMs } + : {}), + }); + opened.push(inbox); + return inbox; + } + + it('binds an owner-only socket in an owner-only directory', async () => { + const inbox = await open(); + expect(inbox.socketPath).toBe(path.join(root, 'socks', 'inbox.sock')); + expect(fs.statSync(inbox.socketPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(inbox.socketPath)).mode & 0o777).toBe( + 0o700, + ); + }); + + it('delivers frames only after the right token', async () => { + const inbox = await open(); + const frame = buildUserFrame({ content: 'hello' }); + + await dial(inbox.socketPath, encodePeerFrame(frame)); + await dial( + inbox.socketPath, + buildAuthLine('b'.repeat(64)) + encodePeerFrame(frame), + ); + // A refusal is final: a correct auth line after a wrong one changes nothing. + await dial( + inbox.socketPath, + buildAuthLine('wrong') + buildAuthLine(TOKEN) + encodePeerFrame(frame), + ); + expect(frames).toEqual([]); + + await dial(inbox.socketPath, buildAuthLine(TOKEN) + encodePeerFrame(frame)); + expect(frames).toEqual([frame]); + }); + + it('skips lines that do not parse and keeps reading', async () => { + const inbox = await open(); + const frame = buildUserFrame({ content: 'after junk' }); + await dial( + inbox.socketPath, + `${buildAuthLine(TOKEN)}not json\n{"msgV":9}\n${encodePeerFrame(frame)}`, + ); + expect(frames).toEqual([frame]); + }); + + it('survives a callback that throws', async () => { + const onFrame = vi.fn(() => { + throw new Error('boom'); + }); + const inbox = await open('throwing.sock', { onFrame }); + const line = + buildAuthLine(TOKEN) + encodePeerFrame(buildUserFrame({ content: 'x' })); + await dial(inbox.socketPath, line); + await dial(inbox.socketPath, line); + expect(onFrame).toHaveBeenCalledTimes(2); + }); + + it('drops a connection that completes no line in time, or sends an over-long one', async () => { + const inbox = await open('slow.sock', { lineDeadlineMs: 50 }); + const started = Date.now(); + await dial(inbox.socketPath, '{"msgV":1', { end: false }); + expect(Date.now() - started).toBeLessThan(5_000); + + await dial( + inbox.socketPath, + buildAuthLine(TOKEN) + 'x'.repeat(MAX_FRAME_CHARS + 16), + { end: false }, + ); + expect(frames).toEqual([]); + }); + + it('binds beside a socket something is still listening on, instead of taking it', async () => { + const requested = path.join(root, 'socks', 'inbox.sock'); + fs.mkdirSync(path.dirname(requested), { recursive: true }); + const occupant = net.createServer(); + servers.push(occupant); + await new Promise((resolve) => occupant.listen(requested, resolve)); + + const inbox = await open(); + expect(path.basename(inbox.socketPath)).toMatch( + /^inbox-[0-9a-f]{8}\.sock$/, + ); + expect(fs.existsSync(requested)).toBe(true); + }); + + it.runIf(process.platform === 'linux')( + 'replaces a file nothing listens on', + async () => { + const requested = path.join(root, 'socks', 'inbox.sock'); + fs.mkdirSync(path.dirname(requested), { recursive: true }); + fs.writeFileSync(requested, ''); + const inbox = await open(); + expect(inbox.socketPath).toBe(requested); + }, + ); + + it('removes its socket on close, asynchronously or not', async () => { + const first = await open('first.sock'); + const second = await open('second.sock'); + await first.close(); + second.closeSync(); + expect(fs.existsSync(first.socketPath)).toBe(false); + expect(fs.existsSync(second.socketPath)).toBe(false); + await first.close(); + }); + + it('fails with bind-failed for a path it cannot use', async () => { + await expect( + startPeerInbox({ + socketPath: 'relative.sock', + requiredToken: TOKEN, + onFrame: () => {}, + }), + ).rejects.toMatchObject({ name: 'PeerEndpointError', code: 'bind-failed' }); + await expect( + startPeerInbox({ + socketPath: path.join( + root, + 'x'.repeat(MAX_SOCKET_PATH_BYTES), + 'i.sock', + ), + requiredToken: TOKEN, + onFrame: () => {}, + }), + ).rejects.toMatchObject({ code: 'bind-failed' }); + }); +}); + +describe.skipIf(noUnixSockets)('startPeerInbox — defensive paths', () => { + let root: string; + const opened: PeerInbox[] = []; + const servers: net.Server[] = []; + + beforeEach(() => { + root = makeTempRoot(); + }); + + afterEach(async () => { + await Promise.all(opened.splice(0).map((inbox) => inbox.close())); + for (const server of servers.splice(0)) server.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + async function open(socketPath: string, frames: PeerFrame[] = []) { + const inbox = await startPeerInbox({ + socketPath, + requiredToken: TOKEN, + onFrame: (frame) => frames.push(frame), + keepAlive: false, + }); + opened.push(inbox); + return inbox; + } + + it('leaves the permissions of a directory a caller chose alone', async () => { + const deploy = path.join(root, 'deploy'); + fs.mkdirSync(deploy); + fs.chmodSync(deploy, 0o755); + const inbox = await open(path.join(deploy, 'agent.sock')); + expect(fs.statSync(deploy).mode & 0o777).toBe(0o755); + expect(fs.statSync(inbox.socketPath).mode & 0o777).toBe(0o600); + }); + + it('tightens a socket directory that already existed', async () => { + const socks = path.join(root, 'qwen-socks'); + fs.mkdirSync(socks); + fs.chmodSync(socks, 0o755); + await open(path.join(socks, 'inbox.sock')); + expect(fs.statSync(socks).mode & 0o777).toBe(0o700); + }); + + it('refuses a directory that is a symlink', async () => { + const real = path.join(root, 'real'); + fs.mkdirSync(real); + const link = path.join(root, 'link'); + fs.symlinkSync(real, link); + await expect(open(path.join(link, 'inbox.sock'))).rejects.toMatchObject({ + code: 'bind-failed', + message: expect.stringContaining('not a directory'), + }); + }); + + it('fails rather than take a live name when no sibling name fits', async () => { + const dir = path.join(root, 's'); + fs.mkdirSync(dir); + const room = + MAX_SOCKET_PATH_BYTES - Buffer.byteLength(path.join(dir, '.sock')); + const requested = path.join(dir, `${'n'.repeat(room)}.sock`); + expect(Buffer.byteLength(requested)).toBe(MAX_SOCKET_PATH_BYTES); + const occupant = net.createServer(); + servers.push(occupant); + await new Promise((resolve) => occupant.listen(requested, resolve)); + await expect(open(requested)).rejects.toMatchObject({ + code: 'bind-failed', + message: expect.stringContaining('no sibling name fits'), + }); + }); + + it('drops an over-long line at once, not at the line deadline', async () => { + const frames: PeerFrame[] = []; + const inbox = await startPeerInbox({ + socketPath: path.join(root, 'long.sock'), + requiredToken: TOKEN, + onFrame: (frame) => frames.push(frame), + keepAlive: false, + }); + opened.push(inbox); + const started = Date.now(); + await dial( + inbox.socketPath, + buildAuthLine(TOKEN) + 'x'.repeat(MAX_FRAME_CHARS + 16), + { end: false }, + ); + expect(Date.now() - started).toBeLessThan(10_000); + expect(frames).toEqual([]); + }); + + it('closes promptly while a connection is still open', async () => { + const inbox = await open(path.join(root, 'idle.sock')); + const idle = net.connect({ path: inbox.socketPath }); + idle.on('error', () => {}); + await new Promise((resolve) => idle.on('connect', resolve)); + const started = Date.now(); + await inbox.close(); + expect(Date.now() - started).toBeLessThan(2_000); + idle.destroy(); + }); + + it('offers no candidate path on Windows', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(resolveInboxCandidates()).toEqual([]); + await expect( + startPeerInbox({ requiredToken: TOKEN, onFrame: () => {} }), + ).rejects.toMatchObject({ code: 'unsupported-platform' }); + } finally { + Object.defineProperty(process, 'platform', platform); + } + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/label.test.ts b/packages/sdk-typescript/test/unit/peer/label.test.ts new file mode 100644 index 00000000000..45fb47ee96c --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/label.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + boundSessionName, + deriveSessionName, + flattenPeerLabel, + MAX_LABEL_CHARS, + MAX_SESSION_NAME_CHARS, + peerRef, +} from '../../../src/peer/label.js'; + +describe('labels', () => { + it('flattens control, format and bidirectional characters to one line', () => { + expect(flattenPeerLabel(' voice\n\tbridge\u202e\u200b ')).toBe( + 'voice bridge', + ); + }); + + it('caps a flattened label with an ellipsis', () => { + const flattened = flattenPeerLabel('x'.repeat(500)); + expect(flattened).toHaveLength(MAX_LABEL_CHARS); + expect(flattened.endsWith('…')).toBe(true); + }); + + it('bounds a record name in code points, never splitting a surrogate pair', () => { + const name = boundSessionName('\u{1F600}'.repeat(60)); + const points = Array.from(name); + expect(points).toHaveLength(MAX_SESSION_NAME_CHARS); + expect(points.at(-2)).toBe('\u{1F600}'); + expect(boundSessionName(' \n ')).toBe(''); + }); + + it('derives a stable six-character ref from the session id', () => { + expect(peerRef('abc')).toMatch(/^[0-9a-f]{6}$/); + expect(peerRef('abc')).toBe(peerRef('abc')); + expect(peerRef('abc')).not.toBe(peerRef('abd')); + }); + + it('derives a default name from the directory and the session id', () => { + expect(deriveSessionName('/home/me/my project!', 'id')).toMatch( + /^my-project-[0-9a-f]{2}$/, + ); + expect(deriveSessionName('/', 'id')).toMatch(/^session-[0-9a-f]{2}$/); + }); +}); + +describe('labels — by category and by code point', () => { + it('collapses format characters by category, including blocks nobody lists', () => { + expect(flattenPeerLabel('voice\u{E0041}\u{E0042}bridge')).toBe( + 'voice bridge', + ); + expect(flattenPeerLabel('a\u180eb')).toBe('a b'); + expect(flattenPeerLabel('a\ufff9b')).toBe('a b'); + }); + + it('caps a label in code points, never splitting a surrogate pair', () => { + const points = Array.from(flattenPeerLabel('\u{1F600}'.repeat(250))); + expect(points).toHaveLength(MAX_LABEL_CHARS); + expect(points.at(-2)).toBe('\u{1F600}'); + }); +}); diff --git a/packages/sdk-typescript/test/unit/peer/registry.test.ts b/packages/sdk-typescript/test/unit/peer/registry.test.ts new file mode 100644 index 00000000000..c8116ade11f --- /dev/null +++ b/packages/sdk-typescript/test/unit/peer/registry.test.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + readLocalBootId, + readPidNamespaceId, + readProcStartToken, +} from '../../../src/peer/identity.js'; +import { + pidOfRecordFilename, + readLiveSessionRecords, + removeOwnRecord, + removeOwnRecordSync, + resolveQwenHome, + type SessionRecord, + writeOwnRecord, +} from '../../../src/peer/registry.js'; + +const isPosix = process.platform !== 'win32'; +const isLinux = process.platform === 'linux'; + +let dir: string; + +beforeEach(() => { + dir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'qpr-')), 'sessions'); +}); + +afterEach(() => { + fs.rmSync(path.dirname(dir), { recursive: true, force: true }); +}); + +function ownRecord(overrides: Partial = {}): SessionRecord { + return { + schemaVersion: 1, + pid: process.pid, + procStart: readProcStartToken(process.pid), + pidNs: readPidNamespaceId(), + sessionId: 'own', + cwd: '/w', + name: 'own', + startedAt: 10, + qwenVersion: null, + kind: 'external', + ipcPath: '/tmp/own.sock', + ipcToken: 'tok', + ...overrides, + }; +} + +function plant(name: string, contents: unknown): string { + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, name); + fs.writeFileSync( + filePath, + typeof contents === 'string' ? contents : JSON.stringify(contents), + ); + return filePath; +} + +describe('pidOfRecordFilename', () => { + it.each([ + ['123.json', 123], + ['123-0a1b2c3d.json', 123], + ['007.json', null], + ['2026-notes.json', null], + ['123-0A1B2C3D.json', null], + ['123-0a1b2c3.json', null], + ['123.json.0a1b2c3d4e5f.tmp', null], + ['0.json', null], + ])('%s -> %s', (name, pid) => { + expect(pidOfRecordFilename(name)).toBe(pid); + }); +}); + +describe('writeOwnRecord', () => { + it('publishes the PID-keyed record, owner-only, with no temporary left behind', async () => { + const written = await writeOwnRecord(dir, ownRecord()); + expect(path.basename(written)).toBe(`${process.pid}.json`); + expect(JSON.parse(fs.readFileSync(written, 'utf8'))).toEqual(ownRecord()); + expect(fs.readdirSync(dir)).toEqual([`${process.pid}.json`]); + if (isPosix) { + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + expect(fs.statSync(written).mode & 0o777).toBe(0o600); + } + }); + + it('takes a minted name when this process already holds the PID-keyed one', async () => { + const first = await writeOwnRecord(dir, ownRecord({ sessionId: 'one' })); + const second = await writeOwnRecord(dir, ownRecord({ sessionId: 'two' })); + expect(path.basename(second)).toMatch( + new RegExp(`^${process.pid}-[0-9a-f]{8}\\.json$`), + ); + expect(JSON.parse(fs.readFileSync(first, 'utf8')).sessionId).toBe('one'); + }); + + it('will not replace a newer schema, another namespace, or a symlink', async () => { + const shared = `${process.pid}.json`; + for (const setup of [ + () => plant(shared, { ...ownRecord(), schemaVersion: 2 }), + () => plant(shared, { ...ownRecord({ sessionId: 'x' }), pidNs: -1 }), + ...(isPosix + ? [ + () => { + fs.mkdirSync(dir, { recursive: true }); + fs.symlinkSync('/dev/null', path.join(dir, shared)); + }, + ] + : []), + ]) { + fs.rmSync(dir, { recursive: true, force: true }); + setup(); + const before = fs.lstatSync(path.join(dir, shared)); + const written = await writeOwnRecord(dir, ownRecord()); + expect(path.basename(written)).not.toBe(shared); + expect(fs.lstatSync(path.join(dir, shared)).ino).toBe(before.ino); + } + }); + + it.runIf(isLinux)( + 'replaces a record left by an earlier process that had this PID', + async () => { + const token = readProcStartToken(process.pid)!; + const [boot, ticks] = token.split(':'); + plant(`${process.pid}.json`, { + ...ownRecord({ sessionId: 'predecessor' }), + procStart: `${boot}:${Number(ticks) + 1}`, + }); + const written = await writeOwnRecord(dir, ownRecord()); + expect(path.basename(written)).toBe(`${process.pid}.json`); + }, + ); + + it('replaces a torn file at the PID-keyed name', async () => { + plant(`${process.pid}.json`, '{"schemaVersion":1,"pid"'); + const written = await writeOwnRecord(dir, ownRecord()); + expect(path.basename(written)).toBe(`${process.pid}.json`); + }); +}); + +describe('removeOwnRecord', () => { + it('removes only the record for its own session id', async () => { + const written = await writeOwnRecord(dir, ownRecord()); + await removeOwnRecord(written, 'someone-else'); + expect(fs.existsSync(written)).toBe(true); + removeOwnRecordSync(written, 'someone-else'); + expect(fs.existsSync(written)).toBe(true); + await removeOwnRecord(written, 'own'); + expect(fs.existsSync(written)).toBe(false); + await expect(removeOwnRecord(written, 'own')).resolves.toBeUndefined(); + }); +}); + +describe('readLiveSessionRecords', () => { + it('lists live records newest first, and deletes nothing it skips', async () => { + const dead = 2 ** 22 + 7; + await writeOwnRecord(dir, ownRecord({ sessionId: 'older', startedAt: 1 })); + await writeOwnRecord(dir, ownRecord({ sessionId: 'newer', startedAt: 2 })); + plant('999999.json', ownRecord({ sessionId: 'name-mismatch' })); + plant(`00${process.pid}.json`, ownRecord({ sessionId: 'padded' })); + plant('2026-notes.json', ownRecord({ sessionId: 'notes', pid: 2026 })); + plant(`${dead}.json`, { + ...ownRecord({ sessionId: 'dead', pid: dead }), + procStart: null, + }); + plant(`${process.pid}-aaaaaaaa.json`, { + ...ownRecord({ sessionId: 'other-namespace' }), + pidNs: -1, + }); + plant(`${process.pid}-bbbbbbbb.json`, { + ...ownRecord({ sessionId: 'newer-schema' }), + schemaVersion: 2, + }); + plant( + `${process.pid}-cccccccc.json`, + JSON.stringify({ + ...ownRecord({ sessionId: 'huge' }), + pad: 'x'.repeat(70_000), + }), + ); + if (isLinux) { + plant(`${process.pid}-dddddddd.json`, { + ...ownRecord({ sessionId: 'other-boot' }), + procStart: `not-${readLocalBootId()}:1`, + }); + } + const before = fs.readdirSync(dir).sort(); + + const live = await readLiveSessionRecords(dir); + + expect(live.map((r) => r.sessionId)).toEqual(['newer', 'older']); + expect(fs.readdirSync(dir).sort()).toEqual(before); + }); + + it('drops a malformed kind and an empty inbox, and keeps a kind it does not know', async () => { + plant(`${process.pid}-11111111.json`, { + ...ownRecord({ sessionId: 'bad-kind' }), + kind: 'Not A Kind', + ipcPath: '', + ipcToken: '', + }); + plant(`${process.pid}-22222222.json`, { + ...ownRecord({ sessionId: 'future-kind' }), + kind: 'voice-relay', + }); + const live = await readLiveSessionRecords(dir); + const bad = live.find((r) => r.sessionId === 'bad-kind')!; + expect(bad).not.toHaveProperty('kind'); + expect(bad).not.toHaveProperty('ipcPath'); + expect(bad).not.toHaveProperty('ipcToken'); + expect(live.find((r) => r.sessionId === 'future-kind')?.kind).toBe( + 'voice-relay', + ); + }); + + it('reads a missing directory as nobody', async () => { + await expect(readLiveSessionRecords(dir)).resolves.toEqual([]); + }); +}); + +describe('resolveQwenHome', () => { + const saved = process.env['QWEN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = saved; + }); + + it('prefers an explicit home, then QWEN_HOME, then ~/.qwen', () => { + process.env['QWEN_HOME'] = '/from/env'; + expect(resolveQwenHome('/explicit')).toBe(path.resolve('/explicit')); + expect(resolveQwenHome()).toBe(path.resolve('/from/env')); + delete process.env['QWEN_HOME']; + expect(resolveQwenHome()).toBe(path.join(os.homedir(), '.qwen')); + }); + + it('expands a leading ~ and resolves a relative path against the working directory', () => { + expect(resolveQwenHome('~/alt/home')).toBe( + path.join(os.homedir(), 'alt', 'home'), + ); + expect(resolveQwenHome('rel/home')).toBe(path.resolve('rel/home')); + }); +}); + +describe('writeOwnRecord — defensive paths', () => { + it('gives overlapping registrations in one process a record each', async () => { + const [a, b] = await Promise.all([ + writeOwnRecord(dir, ownRecord({ sessionId: 'a' })), + writeOwnRecord(dir, ownRecord({ sessionId: 'b' })), + ]); + expect(a).not.toBe(b); + expect([path.basename(a), path.basename(b)]).toContain( + `${process.pid}.json`, + ); + expect(JSON.parse(fs.readFileSync(a, 'utf8')).sessionId).toBe('a'); + expect(JSON.parse(fs.readFileSync(b, 'utf8')).sessionId).toBe('b'); + expect( + (await readLiveSessionRecords(dir)).map((r) => r.sessionId).sort(), + ).toEqual(['a', 'b']); + }); + + it.runIf(isPosix)( + 'tightens a registry directory that already existed', + async () => { + fs.mkdirSync(dir, { recursive: true }); + fs.chmodSync(dir, 0o755); + await writeOwnRecord(dir, ownRecord()); + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + }, + ); + + it.runIf(isPosix && process.getuid?.() !== 0)( + 'does not replace a record it merely failed to read', + async () => { + const shared = plant( + `${process.pid}.json`, + ownRecord({ sessionId: 'unreadable' }), + ); + fs.chmodSync(shared, 0o000); + let written: string; + try { + written = await writeOwnRecord(dir, ownRecord()); + } finally { + fs.chmodSync(shared, 0o600); + } + expect(path.basename(written)).not.toBe(`${process.pid}.json`); + expect(JSON.parse(fs.readFileSync(shared, 'utf8')).sessionId).toBe( + 'unreadable', + ); + }, + ); + + it.runIf(isLinux)( + 'skips a live PID whose start token does not match, and a record without a namespace', + async () => { + const [boot, ticks] = readProcStartToken(process.pid)!.split(':'); + plant(`${process.pid}-eeeeeeee.json`, { + ...ownRecord({ sessionId: 'reused-pid' }), + procStart: `${boot}:${Number(ticks) + 1}`, + }); + plant(`${process.pid}-ffffffff.json`, { + ...ownRecord({ sessionId: 'no-namespace' }), + pidNs: null, + }); + await writeOwnRecord(dir, ownRecord({ sessionId: 'own' })); + expect( + (await readLiveSessionRecords(dir)).map((r) => r.sessionId), + ).toEqual(['own']); + }, + ); +}); diff --git a/packages/sdk-typescript/tsconfig.test-fence.json b/packages/sdk-typescript/tsconfig.test-fence.json index 94cd2c8e34b..e180209389d 100644 --- a/packages/sdk-typescript/tsconfig.test-fence.json +++ b/packages/sdk-typescript/tsconfig.test-fence.json @@ -1,5 +1,8 @@ { "extends": "./tsconfig.json", - "include": ["test/unit/daemon-public-surface.test.ts"], + "include": [ + "test/unit/daemon-public-surface.test.ts", + "test/unit/peer-surface.test.ts" + ], "exclude": ["node_modules", "dist"] }