diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index b2b62a2..824d00c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -95,7 +95,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt + components: rustfmt, clippy - run: pnpm install --frozen-lockfile @@ -103,12 +103,16 @@ jobs: run: | cargo fmt --manifest-path packages/core/native/windows-job-supervisor/Cargo.toml --check cargo test --locked --manifest-path packages/core/native/windows-job-supervisor/Cargo.toml + cargo fmt --manifest-path packages/core/native/windows-peer-broker/Cargo.toml --check + cargo test --locked --manifest-path packages/core/native/windows-peer-broker/Cargo.toml + cargo clippy --locked --all-targets --manifest-path packages/core/native/windows-peer-broker/Cargo.toml -- -D warnings - - name: Exercise a freshly built x64 helper + - name: Exercise freshly built x64 helpers run: | pnpm build pnpm build:native - pnpm exec vitest run packages/core/tests/windows-job-provider.test.ts packages/core/tests/windows-supervisor-protocol.test.ts packages/core/tests/pty-provider.test.ts + & packages/core/dist/native/windows/x64/xc-peer-broker.exe self-test --protocol 2 + pnpm exec vitest run packages/core/tests/windows-job-provider.test.ts packages/core/tests/windows-supervisor-protocol.test.ts packages/core/tests/pty-provider.test.ts packages/core/tests/windows-peer-broker-protocol.test.ts packages/core/tests/windows-peer-transport.test.ts packages/core/tests/windows-native-artifacts.test.ts package: name: Package Check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 787fcfc..0e3c3b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,16 +37,18 @@ jobs: with: tool: cargo-xwin - - name: Build strict Windows Job Object supervisor - run: >- - cargo xwin build --release --locked - --target ${{ matrix.target }} - --manifest-path packages/core/native/windows-job-supervisor/Cargo.toml + - name: Build Windows native helpers + run: | + cargo xwin build --release --locked --target ${{ matrix.target }} --manifest-path packages/core/native/windows-job-supervisor/Cargo.toml + cargo xwin build --release --locked --target ${{ matrix.target }} --manifest-path packages/core/native/windows-peer-broker/Cargo.toml + mkdir -p native-output/${{ matrix.arch }} + cp packages/core/native/windows-job-supervisor/target/${{ matrix.target }}/release/xc-shell-supervisor.exe native-output/${{ matrix.arch }}/ + cp packages/core/native/windows-peer-broker/target/${{ matrix.target }}/release/xc-peer-broker.exe native-output/${{ matrix.arch }}/ - uses: actions/upload-artifact@v7 with: - name: windows-shell-supervisor-${{ matrix.arch }} - path: packages/core/native/windows-job-supervisor/target/${{ matrix.target }}/release/xc-shell-supervisor.exe + name: windows-native-${{ matrix.arch }} + path: native-output/${{ matrix.arch }}/*.exe if-no-files-found: error verify-windows-runtime: @@ -67,23 +69,24 @@ jobs: - uses: actions/download-artifact@v8 with: - name: windows-shell-supervisor-x64 + name: windows-native-x64 path: packages/core/native/prebuilt/windows/x64 - uses: actions/download-artifact@v8 with: - name: windows-shell-supervisor-arm64 + name: windows-native-arm64 path: packages/core/native/prebuilt/windows/arm64 - name: Generate native artifact hash manifest - run: node packages/core/scripts/write-native-manifest.mjs native/prebuilt/windows + run: node packages/core/scripts/write-native-manifest.mjs native/prebuilt/windows --all-current - run: pnpm install --frozen-lockfile - - name: Exercise release x64 Job and ConPTY helpers + - name: Exercise release x64 native helpers run: | + & packages/core/native/prebuilt/windows/x64/xc-peer-broker.exe self-test --protocol 2 pnpm build - pnpm exec vitest run packages/core/tests/windows-job-provider.test.ts packages/core/tests/windows-supervisor-protocol.test.ts packages/core/tests/pty-provider.test.ts + pnpm exec vitest run packages/core/tests/windows-job-provider.test.ts packages/core/tests/windows-supervisor-protocol.test.ts packages/core/tests/pty-provider.test.ts packages/core/tests/windows-peer-broker-protocol.test.ts packages/core/tests/windows-peer-transport.test.ts packages/core/tests/windows-native-artifacts.test.ts - name: Check packaged Windows runtime run: pnpm check:package @@ -109,16 +112,16 @@ jobs: - uses: actions/download-artifact@v8 with: - name: windows-shell-supervisor-x64 + name: windows-native-x64 path: packages/core/native/prebuilt/windows/x64 - uses: actions/download-artifact@v8 with: - name: windows-shell-supervisor-arm64 + name: windows-native-arm64 path: packages/core/native/prebuilt/windows/arm64 - name: Generate native artifact hash manifest - run: node packages/core/scripts/write-native-manifest.mjs native/prebuilt/windows + run: node packages/core/scripts/write-native-manifest.mjs native/prebuilt/windows --all-current - run: pnpm install --frozen-lockfile @@ -153,6 +156,8 @@ jobs: grep -q 'package/dist/native/windows/manifest.json' <<< "$listing" grep -q 'package/dist/native/windows/x64/xc-shell-supervisor.exe' <<< "$listing" grep -q 'package/dist/native/windows/arm64/xc-shell-supervisor.exe' <<< "$listing" + grep -q 'package/dist/native/windows/x64/xc-peer-broker.exe' <<< "$listing" + grep -q 'package/dist/native/windows/arm64/xc-peer-broker.exe' <<< "$listing" done smoke_dir="$(mktemp -d)" trap 'rm -rf "$smoke_dir"' EXIT diff --git a/.prettierignore b/.prettierignore index 00f1e2e..0f6c262 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ dist coverage pnpm-lock.yaml packages/core/native/windows-job-supervisor/target +packages/core/native/windows-peer-broker/target diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ce534..16a206a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ ### Features +- support secure same-account peer messaging on Windows x64 through a bundled Named Pipe broker, with an arm64 artifact packaged as a preview pending native-device acceptance - support ChatGPT subscription sign-in for OpenAI models with browser/device OAuth, automatic token refresh, and strict API-key mutual exclusion - unify foreground and background shell execution into managed sessions with automatic 10-second yielding, `/ps`, and `/stop` - use a bundled, hash-verified Windows Job Object supervisor for reliable process-tree cleanup on Windows x64 and arm64; normal Node.js builds do not require Rust diff --git a/README.md b/README.md index ec725eb..c1f7162 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ xc -m sonnet "Refactor the formatDate function" # Specify a model - **Plan mode** — `--plan` or `/plan` enters read-only exploration; the agent designs a plan, then executes after approval - **Durable goal loops** — `/goal` runs execute → verify → repair cycles until passing or hitting a stop condition - **Model-directed Git worktrees** — when repository state and verification risk warrant it, the agent can use ordinary Git commands to create and clean up a temporary worktree instead of risking the active checkout -- **Cross-session messaging** — named local sessions can discover one another and exchange peer-authorized work (macOS / Linux; see [docs](./docs/peer-messaging.en.md)) +- **Cross-session messaging** — named local sessions can discover one another and exchange peer-authorized work (macOS / Linux / Windows x64; a Windows arm64 artifact is packaged as a preview pending native-device acceptance; see [docs](./docs/peer-messaging.en.md)) - **File attachments** — `@path` or bare absolute paths auto-ingest text / code / PDF / Office docs (docx / xlsx / pptx / odt / ods / odp) / images / audio - **Local PDF processing** — selectable text is extracted page by page; scanned or visual pages become images for the active vision model or local OCR for a text model. Large visual PDFs are loaded progressively with `readFile` page ranges. Original PDF bytes are never uploaded - **Local audio transcription** — MP3 / WAV / FLAC / OGG Vorbis attachments (up to 25 MiB and 20 minutes) are always transcribed locally via Whisper (whisper.cpp) in an isolated process; only timestamped text reaches the model. Before any model download, the native runtime is probed and a streaming decoder enforces the limit against actual decoded PCM frames. Queue wait and transcription share a hard timeout. First-use model downloads are revision-pinned and SHA-256 verified before being cached under `~/.x-code/whisper-models/` (default `tiny`; set `X_CODE_WHISPER_MODEL` to pick another, e.g. `base`) diff --git a/README.zh-CN.md b/README.zh-CN.md index 14b6b66..3915bc0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -170,7 +170,7 @@ xc -m sonnet "重构 formatDate 函数" # 指定模型 - **Plan 模式** — `--plan` 或 `/plan` 进入只读探索,Agent 先制定方案、批准后再执行 - **持续目标循环** — `/goal` 自动执行→验证→修复,直到验证通过或触发停止条件 - **模型自主 Git worktree** — 当仓库状态和验证风险确有需要时,Agent 可自主使用普通 Git 命令创建并清理临时 worktree,避免冒险改动当前工作区 -- **跨会话消息** — 命名后的本机 Session 可以互相发现,并在权限边界内移交工作(macOS / Linux;详见[文档](./docs/peer-messaging.md)) +- **跨会话消息** — 命名后的本机 Session 可以互相发现,并在权限边界内移交工作(macOS / Linux / Windows x64;Windows arm64 artifact 已随包提供,但在完成 arm64 真机验收前属于预览支持;详见[文档](./docs/peer-messaging.md)) - **文件附件** — `@path` 或裸绝对路径引用文件,自动识别 text / code / PDF / Office 文档(docx / xlsx / pptx / odt / ods / odp)/ 图片 / 音频 - **本地 PDF 处理** — 按页提取可选文本;扫描页或视觉页交给当前视觉模型,纯文本模型则使用本地 OCR。大型视觉 PDF 通过 `readFile` 页范围渐进读取,原始 PDF 字节不会上传 - **本地音频转写** — MP3 / WAV / FLAC / OGG Vorbis 附件(最大 25 MiB、20 分钟)始终由隔离进程中的 Whisper(whisper.cpp)在本地转写,只有带时间戳的文字会交给模型。模型下载前会探测 native runtime,并由隔离进程中的流式解码器按实际 PCM 帧执行硬上限;排队等待与转写共用总超时。首次模型下载固定 revision 并通过 SHA-256 校验后才缓存于 `~/.x-code/whisper-models/`(默认 `tiny`,可通过 `X_CODE_WHISPER_MODEL` 换成其他型号,如 `base`) diff --git a/docs/peer-messaging.en.md b/docs/peer-messaging.en.md index 8862973..571faa7 100644 --- a/docs/peer-messaging.en.md +++ b/docs/peer-messaging.en.md @@ -2,7 +2,7 @@ Cross-session messaging lets interactive X-Code sessions on the same machine discover one another and exchange plain-text work requests. Each participating root session keeps its own model, conversation, working directory, and local permission boundary. -> This release supports peer messaging on macOS and Linux. Windows sessions remain usable, but peer messaging reports `PEER_UNSUPPORTED_PLATFORM` until a Windows transport is available. Print mode (`--print`) does not register a peer. +> This release supports macOS, Linux, and Windows x64. A Windows arm64 broker artifact is packaged as a preview but has not completed native-device acceptance. Print mode (`--print`) does not register a peer; Windows ia32 is unsupported. ## Start named sessions @@ -79,7 +79,9 @@ A peer message is data, not user authority: - Peer-influenced events do not invoke plugin hooks. This isolation remains in effect even when the receiving session uses `--trust`. - `/clear-peer-context` can delete the first peer-influenced message and every derived response after it, then restore normal authority. It refuses to run while peer messages are still queued. -Peer transport is local-only: it uses a runtime registry and Unix-domain sockets under the X-Code user directory, not a network listener. Authentication tokens and delivery ledgers are implementation details and are never model-visible. +Peer transport is local-only: macOS/Linux use Unix-domain sockets, while Windows uses a hash- and PE-verified Rust Named Pipe broker bundled with the npm package. Windows authenticates in layers with the current account SID, an exact integrity-level match, a protected DACL, `PIPE_REJECT_REMOTE_CLIENTS`, and a random per-session token. It does not listen on TCP or UDP. Authentication tokens, SIDs, and delivery ledgers are never model-visible. + +On Windows, `X_CODE_HOME` must be on a local volume with persistent ACLs. UNC paths, mapped network drives, reparse/junction/symlink paths, and directories replaceable by another ordinary account are rejected. If security cannot be proven, the helper is missing or damaged, or the architecture is unsupported, only peer messaging fails closed; normal chat and other tools remain available. Normal installation, builds, and use do not require a Rust toolchain. ## Delivery results @@ -95,7 +97,9 @@ Messages arriving while the receiver is busy are queued and processed without in ## Troubleshooting - **This session is not a named agent** — restart it with `xc --name `. -- **No other reachable sessions** — verify that both sessions are named, run on macOS/Linux, and share the same `X_CODE_HOME`. +- **No other reachable sessions** — verify that both sessions are named and share the same `X_CODE_HOME`; on Windows they must also use the same account and a compatible integrity level. +- **Windows peer runtime directory is not private** — move `X_CODE_HOME` to a current-account-controlled directory on local NTFS/ReFS; do not use UNC paths, mapped network drives, or junctions/symlinks. +- **Windows peer broker is missing/hash mismatch** — reinstall x-code-cli. X-Code will not search `PATH` or download a fallback helper. - **Name is ambiguous** — copy the exact `peer:` address from `/list-agents`. - **A message stays held** — accept or refuse it in the receiving terminal before `dialogExpiryMs` elapses. - **Need diagnostics** — launch with `DEBUG_STDOUT=1`; details go to `~/.x-code/logs/debug.log`. diff --git a/docs/peer-messaging.md b/docs/peer-messaging.md index 72a23aa..1dc1d90 100644 --- a/docs/peer-messaging.md +++ b/docs/peer-messaging.md @@ -2,7 +2,7 @@ 跨会话消息允许同一台机器上的多个交互式 X-Code Session 互相发现并交换纯文本工作请求。每个参与通信的根 Session 仍拥有独立的模型、对话、工作目录和本地权限边界。 -> 当前版本仅在 macOS 和 Linux 上支持 Peer 消息。Windows 下 CLI 其他功能可正常使用,但 Peer 消息会返回 `PEER_UNSUPPORTED_PLATFORM`,直到 Windows 原生传输实现完成。非交互模式(`--print`)不会注册为 Peer。 +> 当前版本支持 macOS、Linux 和 Windows x64。Windows arm64 broker 产物已随包提供,但尚未完成目标设备实机验收,当前属于预览支持。非交互模式(`--print`)不会注册为 Peer;Windows ia32 不受支持。 ## 启动命名 Session @@ -79,7 +79,9 @@ Peer 消息只是数据,不会自动获得本地用户权限: - 受 Peer 影响的事件不会触发插件 Hook;即使接收 Session 使用 `--trust`,这一隔离仍然生效。 - `/clear-peer-context` 经确认后可删除第一条受 Peer 影响的消息以及其后的所有派生回复,并恢复普通权限;还有 Peer 消息排队时不会执行。 -Peer 传输仅限本机:使用 X-Code 用户目录下的运行时注册表和 Unix Domain Socket,不会监听网络端口。认证 token 和投递账本属于内部实现,不会暴露给模型。 +Peer 传输仅限本机:macOS/Linux 使用 Unix Domain Socket;Windows 使用随 npm 包分发并经过 hash/PE 校验的 Rust Named Pipe broker。Windows Pipe 通过当前账户 SID、完全相同的 integrity level、受保护 DACL、`PIPE_REJECT_REMOTE_CLIENTS` 和每 Session 随机 token 分层认证,不监听 TCP/UDP 端口。认证 token、SID 和投递账本不会暴露给模型。 + +Windows 的 `X_CODE_HOME` 必须位于支持 persistent ACL 的本机 volume,不能是 UNC、映射网络驱动器、reparse/junction/symlink 路径或可被其他普通账户替换的不安全目录。安全条件无法证明、helper 缺失/损坏或架构不受支持时,仅 Peer Messaging fail closed;普通聊天和其他工具仍可使用。普通安装、构建和运行不需要 Rust 工具链。 ## 投递结果 @@ -95,7 +97,9 @@ Peer 传输仅限本机:使用 X-Code 用户目录下的运行时注册表和 ## 故障排查 - **This session is not a named agent**:使用 `xc --name <名称>` 重启。 -- **No other reachable sessions**:确认两端均已命名、运行于 macOS/Linux,并使用相同的 `X_CODE_HOME`。 +- **No other reachable sessions**:确认两端均已命名、使用相同的 `X_CODE_HOME`;Windows 上还需使用同一账户和兼容的 integrity level。 +- **Windows peer runtime directory is not private**:将 `X_CODE_HOME` 移到本机 NTFS/ReFS 上仅当前账户可控制的目录;不要使用 UNC、映射网络驱动器或 junction/symlink。 +- **Windows peer broker is missing/hash mismatch**:重新安装 x-code-cli;不会从 PATH 查找或自动下载替代 helper。 - **Name is ambiguous**:从 `/list-agents` 复制精确的 `peer:` 地址。 - **消息一直处于 held**:在 `dialogExpiryMs` 到期前到接收终端选择 Accept 或 Refuse。 - **需要诊断日志**:设置 `DEBUG_STDOUT=1` 启动;日志写入 `~/.x-code/logs/debug.log`。 diff --git a/packages/cli/esbuild.config.js b/packages/cli/esbuild.config.js index fbccb1b..399d27f 100644 --- a/packages/cli/esbuild.config.js +++ b/packages/cli/esbuild.config.js @@ -5,7 +5,8 @@ import { builtinModules } from 'node:module' import { fileURLToPath } from 'node:url' const OUT_DIR = fileURLToPath(new URL('./dist/', import.meta.url)) -const CORE_NATIVE_DIR = fileURLToPath(new URL('../core/dist/native/', import.meta.url)) +const CORE_WINDOWS_NATIVE_DIR = fileURLToPath(new URL('../core/dist/native/windows/', import.meta.url)) +const CLI_WINDOWS_NATIVE_DIR = fileURLToPath(new URL('./dist/native/windows/', import.meta.url)) // ESM polyfills — provide __dirname, __filename, and require() for CJS compat const ESM_POLYFILLS = ` @@ -145,7 +146,7 @@ await esbuild.build({ }) try { - await cp(CORE_NATIVE_DIR, fileURLToPath(new URL('./dist/native/', import.meta.url)), { recursive: true }) + await cp(CORE_WINDOWS_NATIVE_DIR, CLI_WINDOWS_NATIVE_DIR, { recursive: true }) } catch (error) { if (error?.code !== 'ENOENT') throw error } diff --git a/packages/cli/src/ui/agent/authority-approval.ts b/packages/cli/src/ui/agent/authority-approval.ts index 8017d3c..dd6c697 100644 --- a/packages/cli/src/ui/agent/authority-approval.ts +++ b/packages/cli/src/ui/agent/authority-approval.ts @@ -3,11 +3,9 @@ import type { AuthorityApproval, AuthorityApprovalPreview } from '@x-code-cli/co export function authorityApproval( preview: AuthorityApprovalPreview, decision: 'allow-once' | 'deny', - viewedComplete: boolean, ): AuthorityApproval { return { decision, - viewedComplete, authorityHash: preview.authorityHash, canonicalCallSha256: preview.canonicalCallSha256, ...(preview.outboundPayload ? { canonicalPayloadSha256: preview.outboundPayload.sha256 } : {}), diff --git a/packages/cli/src/ui/agent/types.ts b/packages/cli/src/ui/agent/types.ts index 2a76ea3..16faaa0 100644 --- a/packages/cli/src/ui/agent/types.ts +++ b/packages/cli/src/ui/agent/types.ts @@ -43,6 +43,7 @@ export interface PendingPermission { } export interface PendingAuthority { + requestId: number toolCallId: string toolName: string input: Record diff --git a/packages/cli/src/ui/agent/use-agent.ts b/packages/cli/src/ui/agent/use-agent.ts index 6ca2b11..b1881d6 100644 --- a/packages/cli/src/ui/agent/use-agent.ts +++ b/packages/cli/src/ui/agent/use-agent.ts @@ -168,6 +168,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini const fileIngestSequenceRef = useRef(0) const planApprovalTimerRef = useRef | null>(null) const pendingAuthorityRef = useRef(null) + const authorityRequestSequenceRef = useRef(0) /** Pending tool calls keyed by toolCallId. A single slot can't survive * parallel tool calls in one turn — the SDK emits tool-call A, tool-call * B, tool-result A, tool-result B, so a shared slot gets overwritten and @@ -514,7 +515,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini }, onAskAuthority: (request) => { return new Promise((resolve) => { - const pending: PendingAuthority = { ...request, resolve } + const pending: PendingAuthority = { ...request, requestId: authorityRequestSequenceRef.current++, resolve } pendingAuthorityRef.current = pending void options.peerService?.updateLocalState({ status: 'waiting' }).catch(() => {}) setState((prev) => ({ ...prev, authorityRequest: pending })) @@ -971,7 +972,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini }, []) const resolveAuthority = useCallback( - (allow: boolean, viewedComplete: boolean) => { + (allow: boolean) => { const pending = pendingAuthorityRef.current pendingAuthorityRef.current = null setState((prev) => ({ ...prev, authorityRequest: null })) @@ -981,9 +982,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini void options.peerService.updateLocalState({ status: 'busy', busyKind }).catch(() => {}) } if (pending) { - queueMicrotask(() => - pending.resolve(authorityApproval(pending.preview, allow ? 'allow-once' : 'deny', viewedComplete)), - ) + queueMicrotask(() => pending.resolve(authorityApproval(pending.preview, allow ? 'allow-once' : 'deny'))) } }, [options.peerService], @@ -1090,6 +1089,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini abortControllerRef, pendingQuestionRef, pendingAuthorityRef, + authorityRequestSequenceRef, permissionResolversRef, turnCoordinatorRef, appendMessage, diff --git a/packages/cli/src/ui/agent/use-goal-controller.ts b/packages/cli/src/ui/agent/use-goal-controller.ts index 0656cda..d580fbc 100644 --- a/packages/cli/src/ui/agent/use-goal-controller.ts +++ b/packages/cli/src/ui/agent/use-goal-controller.ts @@ -60,6 +60,7 @@ interface UseGoalControllerOptions { abortControllerRef: MutableRef pendingQuestionRef: MutableRef pendingAuthorityRef: MutableRef + authorityRequestSequenceRef: MutableRef permissionResolversRef: MutableRef void>> turnCoordinatorRef: MutableRef appendMessage: (message: DisplayMessage) => void @@ -82,6 +83,7 @@ export function useGoalController({ abortControllerRef, pendingQuestionRef, pendingAuthorityRef, + authorityRequestSequenceRef, permissionResolversRef, turnCoordinatorRef, appendMessage, @@ -118,7 +120,7 @@ export function useGoalController({ }, onAskAuthority: (request) => { return new Promise((resolve) => { - const pending: PendingAuthority = { ...request, resolve } + const pending: PendingAuthority = { ...request, requestId: authorityRequestSequenceRef.current++, resolve } pendingAuthorityRef.current = pending void agentOptions.peerService?.updateLocalState({ status: 'waiting' }).catch(() => {}) setState((previous) => ({ ...previous, authorityRequest: pending })) @@ -165,6 +167,7 @@ export function useGoalController({ goalToolLifecycleCallbacks, handleStreamRetry, pendingAuthorityRef, + authorityRequestSequenceRef, pendingQuestionRef, permissionModeRef, permissionResolversRef, @@ -281,7 +284,7 @@ export function useGoalController({ const pendingAuthority = pendingAuthorityRef.current pendingAuthorityRef.current = null - if (pendingAuthority) pendingAuthority.resolve(authorityApproval(pendingAuthority.preview, 'deny', false)) + if (pendingAuthority) pendingAuthority.resolve(authorityApproval(pendingAuthority.preview, 'deny')) const pendingQuestion = pendingQuestionRef.current pendingQuestionRef.current = null diff --git a/packages/cli/src/ui/app/App.tsx b/packages/cli/src/ui/app/App.tsx index 0291b36..83b8f91 100644 --- a/packages/cli/src/ui/app/App.tsx +++ b/packages/cli/src/ui/app/App.tsx @@ -2069,6 +2069,7 @@ export function App({ authorityRequest={ authorityRequest ? { + requestId: authorityRequest.requestId, toolName: authorityRequest.toolName, preview: authorityRequest.preview, onResolve: resolveAuthority, diff --git a/packages/cli/src/ui/chat-input/ChatInput.tsx b/packages/cli/src/ui/chat-input/ChatInput.tsx index d4a7ee3..6fc8aee 100644 --- a/packages/cli/src/ui/chat-input/ChatInput.tsx +++ b/packages/cli/src/ui/chat-input/ChatInput.tsx @@ -361,7 +361,6 @@ export function ChatInput({ * lands here instead of resolving the dialog. */ const [permissionFeedback, setPermissionFeedback] = useState<{ text: string; cursor: number } | null>(null) const [authoritySelected, setAuthoritySelected] = useState(1) - const [authorityViewedComplete, setAuthorityViewedComplete] = useState(false) const [authorityPage, setAuthorityPage] = useState(0) const [lastPermissionKey, setLastPermissionKey] = useState(null) const permissionKey = permission ? `${permission.toolName}:${JSON.stringify(permission.input)}` : null @@ -371,11 +370,12 @@ export function ChatInput({ setPermissionFeedback(null) } const [lastAuthorityKey, setLastAuthorityKey] = useState(null) - const authorityKey = authorityRequest?.preview.canonicalCallSha256 ?? null + const authorityKey = authorityRequest + ? `${authorityRequest.requestId}:${authorityRequest.preview.canonicalCallSha256}` + : null if (authorityKey !== lastAuthorityKey) { setLastAuthorityKey(authorityKey) setAuthoritySelected(1) - setAuthorityViewedComplete(false) setAuthorityPage(0) } @@ -844,18 +844,14 @@ export function ChatInput({ const dialogSlashMode = textRef.current.trimStart().startsWith('/') if (authorityRequest) { if (key === 'escape') { - authorityRequest.onResolve(false, authorityViewedComplete) + authorityRequest.onResolve(false) return } - if (!authorityViewedComplete && (key === 'down' || key === 'right' || key === 'pagedown' || key === 'return')) { - setAuthorityPage((page) => { - const next = Math.min(authorityPageCount - 1, page + 1) - if (next === authorityPageCount - 1) setAuthorityViewedComplete(true) - return next - }) + if (key === 'right' || key === 'pagedown') { + setAuthorityPage((page) => Math.min(authorityPageCount - 1, page + 1)) return } - if (!authorityViewedComplete && (key === 'up' || key === 'left' || key === 'pageup')) { + if (key === 'left' || key === 'pageup') { setAuthorityPage((page) => Math.max(0, page - 1)) return } @@ -864,7 +860,7 @@ export function ChatInput({ return } if (key === 'return') { - authorityRequest.onResolve(authoritySelected === 0, true) + authorityRequest.onResolve(authoritySelected === 0) return } return @@ -1828,10 +1824,8 @@ export function ChatInput({ } frame.push( textToCells( - authorityViewedComplete - ? ` Complete payload viewed · SHA-256 ${preview.outboundPayload?.sha256 ?? preview.canonicalCallSha256}` - : ` Payload page ${authorityPage + 1}/${authorityPageCount} · Enter/→ for next page; approval locked.`, - authorityViewedComplete ? S_SUCCESS : S_WARNING, + ` Payload page ${authorityPage + 1}/${authorityPageCount} · ←/→ view pages · SHA-256 ${preview.outboundPayload?.sha256 ?? preview.canonicalCallSha256}`, + S_WARNING, ), ) diff --git a/packages/cli/src/ui/chat-input/types.ts b/packages/cli/src/ui/chat-input/types.ts index af16c60..51a21ba 100644 --- a/packages/cli/src/ui/chat-input/types.ts +++ b/packages/cli/src/ui/chat-input/types.ts @@ -88,9 +88,10 @@ interface PermissionRequest { } interface AuthorityRequest { + requestId: number toolName: string preview: AuthorityApprovalPreview - onResolve: (allow: boolean, viewedComplete: boolean) => void + onResolve: (allow: boolean) => void } interface SelectRequest { diff --git a/packages/cli/tests/package/install-smoke.test.ts b/packages/cli/tests/package/install-smoke.test.ts index 0ac7840..d6fa527 100644 --- a/packages/cli/tests/package/install-smoke.test.ts +++ b/packages/cli/tests/package/install-smoke.test.ts @@ -340,30 +340,46 @@ describe('published CLI tarball', () => { await fs.writeFile(pdfPath, makePdfBuffer([{ text: 'Installed package PDF worker smoke test' }])) const script = ` const fs = require('node:fs') + const { fork } = require('node:child_process') const { Worker } = require('node:worker_threads') - const worker = new Worker(${JSON.stringify(workerPath)}) + const childMode = process.platform === 'win32' + const worker = childMode + ? fork(${JSON.stringify(workerPath)}, [], { + execArgv: ['--max-old-space-size=512'], + serialization: 'advanced', + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + }) + : new Worker(${JSON.stringify(workerPath)}) + const postMessage = (message, transferList = []) => { + if (childMode) worker.send(message) + else worker.postMessage(message, transferList) + } const timer = setTimeout(() => { console.error('worker timeout'); process.exit(1) }, 30000) const fail = (error) => { clearTimeout(timer); console.error(error?.stack || error); process.exit(1) } + let destroyAcknowledged = false worker.on('error', fail) + worker.on('exit', (code) => { + clearTimeout(timer) + if (!destroyAcknowledged || code !== 0) return fail(new Error('PDF worker did not exit cleanly after destroy')) + process.stdout.write('package-pdf-rendered', () => process.exit(0)) + }) worker.on('message', (response) => { if (!response.ok) return fail(new Error(response.error)) if (response.result.type === 'init') { if (response.result.totalPages !== 1) return fail(new Error('wrong page count')) - worker.postMessage({ id: 2, type: 'render', pageNumber: 1, desiredWidth: 320, maxPixels: 1000000 }) + postMessage({ id: 2, type: 'render', pageNumber: 1, desiredWidth: 320, maxPixels: 1000000 }) return } if (response.result.type === 'render') { const png = Buffer.from(response.result.data) if (!png.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return fail(new Error('not PNG')) - worker.postMessage({ id: 3, type: 'destroy' }) + postMessage({ id: 3, type: 'destroy' }) return } - clearTimeout(timer) - process.stdout.write('package-pdf-rendered') - worker.terminate().then(() => process.exit(0), fail) + destroyAcknowledged = true }) const bytes = Uint8Array.from(fs.readFileSync(${JSON.stringify(pdfPath)})) - worker.postMessage({ id: 1, type: 'init', data: bytes.buffer }, [bytes.buffer]) + postMessage({ id: 1, type: 'init', data: bytes.buffer }, [bytes.buffer]) ` const result = await command(process.execPath, ['-e', script], { cwd: suiteRoot, timeoutMs: 40_000 }) if (result.exitCode !== 0) throw new Error(`Installed PDF worker failed:\n${result.stdout}\n${result.stderr}`) @@ -392,13 +408,17 @@ describe('published CLI tarball', () => { }) const timer = setTimeout(() => { console.error('worker timeout'); process.exit(1) }, 30000) worker.on('error', (error) => { clearTimeout(timer); console.error(error); process.exit(1) }) - worker.on('message', (response) => { + let resultReceived = false + worker.on('exit', (code) => { clearTimeout(timer) + if (!resultReceived || code !== 0) process.exit(1) + process.stdout.write('package-image-normalized', () => process.exit(0)) + }) + worker.on('message', (response) => { if (!response.ok) { console.error(response.error); process.exit(1) } const png = Buffer.from(response.result.data) if (response.result.mimeType !== 'image/png' || !png.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) process.exit(1) - process.stdout.write('package-image-normalized') - worker.terminate().then(() => process.exit(0), () => process.exit(1)) + resultReceived = true }) ` const result = await command(process.execPath, ['-e', script], { cwd: suiteRoot, timeoutMs: 40_000 }) @@ -462,16 +482,26 @@ describe('published CLI tarball', () => { const manifestBytes = byName.get('package/dist/native/windows/manifest.json') expect(manifestBytes).toBeDefined() const manifest = JSON.parse(manifestBytes!.toString('utf-8')) as { - protocolVersion: number - artifacts: Record + manifestVersion: number + artifacts: Record< + string, + Record + > } - expect(manifest.protocolVersion).toBe(2) + expect(manifest.manifestVersion).toBe(2) for (const arch of ['x64', 'arm64']) { - const artifact = manifest.artifacts[arch]! - const bytes = byName.get(`package/dist/native/windows/${artifact.file}`) - expect(bytes, `missing ${arch} Windows helper`).toBeDefined() - expect(createHash('sha256').update(bytes!).digest('hex')).toBe(artifact.sha256) + for (const [artifactName, protocolVersion] of [ + ['shellSupervisor', 2], + ['peerBroker', 2], + ] as const) { + const artifact = manifest.artifacts[arch]![artifactName]! + const bytes = byName.get(`package/dist/native/windows/${artifact.file}`) + expect(bytes, `missing ${arch} Windows ${artifactName}`).toBeDefined() + expect(artifact.protocolVersion).toBe(protocolVersion) + expect(artifact.sourceSha256).toMatch(/^[a-f0-9]{64}$/) + expect(createHash('sha256').update(bytes!).digest('hex')).toBe(artifact.sha256) + } } }) diff --git a/packages/cli/tests/pty/harness.ts b/packages/cli/tests/pty/harness.ts index a5b6198..7e46ff1 100644 --- a/packages/cli/tests/pty/harness.ts +++ b/packages/cli/tests/pty/harness.ts @@ -222,14 +222,17 @@ export async function createTuiHarness(options: { ? `& ${values.map(powershellQuote).join(' ')}; Write-Output "${EXIT_MARKER}$LASTEXITCODE"\r` : `${values.map(posixQuote).join(' ')}; __x_code_status=$?; printf '\\n${EXIT_MARKER}%s\\n' "$__x_code_status"\n` processUnderTest.write(command) - await waitFor(() => raw.includes('test-model'), 'CLI header', 10_000) + // A named Windows session secures its runtime and starts the native broker before rendering the header. + const startupTimeoutMs = + isWindows && args.some((arg) => arg === '--name' || arg.startsWith('--name=')) ? 20_000 : 10_000 + await waitFor(() => raw.includes('test-model'), 'CLI header', startupTimeoutMs) await waitFor( async () => { await waitForRendered() return lastPromptLine(terminalScreen(terminal)) !== '' }, 'interactive input prompt', - 10_000, + startupTimeoutMs, ) await waitForRendered() await waitForTerminalQuiet() diff --git a/packages/cli/tests/pty/tui-interrupt.test.ts b/packages/cli/tests/pty/tui-interrupt.test.ts index 1b38185..c5ff3b6 100644 --- a/packages/cli/tests/pty/tui-interrupt.test.ts +++ b/packages/cli/tests/pty/tui-interrupt.test.ts @@ -210,6 +210,17 @@ describe('TUI interruption', () => { await submitInput(harness, 'hi') await harness.waitForText('session-recovery-ok') + await waitFor( + async () => { + try { + return JSON.stringify(await readSessionJsonl(workspace.cwd)).includes('session-recovery-ok') + } catch { + return false + } + }, + 'recovery persisted to session JSONL', + 10_000, + ) const recovered = await readSessionJsonl(workspace.cwd) expect(JSON.stringify(recovered)).toContain('session-recovery-ok') await typeInput(harness, 'session-still-editable') diff --git a/packages/cli/tests/pty/tui-peer-messaging.test.ts b/packages/cli/tests/pty/tui-peer-messaging.test.ts index 25858cb..3698759 100644 --- a/packages/cli/tests/pty/tui-peer-messaging.test.ts +++ b/packages/cli/tests/pty/tui-peer-messaging.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { GLYPH_SELECT_POINTER } from '../../src/ui/render/terminal-glyphs.js' @@ -9,7 +11,21 @@ import { startFakeProvider } from '../fixtures/fake-provider-server.js' import { createTuiHarness } from './harness.js' import { exitTui, submitInput } from './test-context.js' -describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () => { +async function createPeerTestWorkspace(prefix: string): Promise>> { + const workspace = await createTestWorkspace(prefix) + if (process.platform !== 'win32') return workspace + const xcodeHome = path.join(os.homedir(), '.x-code', 'peer-test-runtime', randomUUID()) + await fs.mkdir(xcodeHome, { recursive: true }) + return { + ...workspace, + xcodeHome, + async cleanup() { + await Promise.all([workspace.cleanup(), fs.rm(xcodeHome, { recursive: true, force: true })]) + }, + } +} + +describe('TUI cross-session messaging', () => { it('registers named agents and lets two locally trusted sessions exchange without authority dialogs', async () => { const alphaProvider = await startFakeProvider([ { @@ -27,7 +43,7 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = finalText: 'receiver processed handoff', }, ]) - const workspace = await createTestWorkspace('xc-pty-peer-double-') + const workspace = await createPeerTestWorkspace('xc-pty-peer-double-') const alpha = await createTuiHarness({ workspace, provider: alphaProvider }) const beta = await createTuiHarness({ workspace, provider: betaProvider }) try { @@ -61,6 +77,81 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = } }) + it('routes a message around three independent named terminal sessions', async () => { + const alphaProvider = await startFakeProvider([ + { + type: 'tool-call', + name: 'sendMessage', + input: { to: 'beta', message: 'ring alpha to beta' }, + finalText: 'alpha started ring', + }, + { type: 'completion', text: 'alpha received completed ring' }, + ]) + const betaProvider = await startFakeProvider([ + { + type: 'tool-call', + name: 'sendMessage', + input: { to: 'gamma', message: 'ring beta to gamma' }, + finalText: 'beta forwarded ring', + }, + ]) + const gammaProvider = await startFakeProvider([ + { + type: 'tool-call', + name: 'sendMessage', + input: { to: 'alpha', message: 'ring gamma to alpha' }, + finalText: 'gamma closed ring', + }, + ]) + const workspace = await createPeerTestWorkspace('xc-pty-peer-ring-') + const alpha = await createTuiHarness({ workspace, provider: alphaProvider }) + const beta = await createTuiHarness({ workspace, provider: betaProvider }) + const gamma = await createTuiHarness({ workspace, provider: gammaProvider }) + try { + await alpha.startCli(['-t', '--name', 'alpha']) + await beta.startCli(['-t', '--name', 'beta']) + await gamma.startCli(['-t', '--name', 'gamma']) + + await vi.waitFor( + async () => { + const registrations = await fs.readdir(path.join(workspace.xcodeHome, 'runtime', 'peers')) + expect(registrations.filter((name) => name.endsWith('.json'))).toHaveLength(3) + }, + { timeout: 10_000 }, + ) + await submitInput(alpha, '/list-agents') + await alpha.waitForText(/beta .*peer:[0-9a-f-]{36} .*idle/) + await alpha.waitForText(/gamma .*peer:[0-9a-f-]{36} .*idle/) + await submitInput(alpha, 'start the three-session ring') + + await beta.waitForText('ring alpha to beta', 10_000) + await gamma.waitForText('ring beta to gamma', 10_000) + await alpha.waitForText('ring gamma to alpha', 10_000) + + const alphaRequests = await alphaProvider.waitForMainRequests(3, 10_000) + const betaRequests = await betaProvider.waitForMainRequests(2, 10_000) + const gammaRequests = await gammaProvider.waitForMainRequests(2, 10_000) + expect(alphaRequests[2]?.rawBody).toContain('ring gamma to alpha') + expect(betaRequests[0]?.rawBody).toContain('ring alpha to beta') + expect(gammaRequests[0]?.rawBody).toContain('ring beta to gamma') + await alpha.waitForText('alpha received completed ring') + await beta.waitForText('beta forwarded ring') + await gamma.waitForText('gamma closed ring') + + await exitTui(alpha) + await exitTui(beta) + await exitTui(gamma) + } finally { + await alpha.dispose() + await beta.dispose() + await gamma.dispose() + await alphaProvider.close() + await betaProvider.close() + await gammaProvider.close() + await workspace.cleanup() + } + }) + it('renders authority metadata and payload injection as inert visible escapes', async () => { const metadataInjection = `unsafe\x1b]52;c;bWV0YWRhdGE=\x07\u202e.txt` const payloadInjection = @@ -75,6 +166,12 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = ]) const betaProvider = await startFakeProvider([ { type: 'tool-call', name: 'readFile', id: 'call_metadata_injection', input: { filePath: metadataInjection } }, + { + type: 'tool-call', + name: 'readFile', + id: 'call_metadata_injection_repeat', + input: { filePath: metadataInjection }, + }, { type: 'tool-call', name: 'shell', @@ -83,7 +180,7 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = finalText: 'authority injection safely denied', }, ]) - const workspace = await createTestWorkspace('xc-pty-peer-authority-injection-') + const workspace = await createPeerTestWorkspace('xc-pty-peer-authority-injection-') const alpha = await createTuiHarness({ workspace, provider: alphaProvider, columns: 160 }) const beta = await createTuiHarness({ workspace, provider: betaProvider, columns: 160 }) const sideEffectPath = path.join(workspace.cwd, 'authority-pwned.txt') @@ -97,6 +194,20 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = expect(beta.raw()).not.toContain('\x1b]52;c;bWV0YWRhdGE=\x07') expect(beta.raw()).not.toContain('\x07') expect(beta.raw()).not.toContain('\u202e') + beta.key('up') + await beta.waitForScreen( + (screen) => screen.includes(`${GLYPH_SELECT_POINTER} Allow once`), + 'peer authority allow option selected immediately', + ) + beta.key('escape') + + await betaProvider.waitForMainRequests(2, 10_000) + await beta.waitForScreen( + (screen) => + screen.includes('unsafe\\u001B]52;c;bWV0YWRhdGE=\\u0007\\u202E.txt') && + screen.includes(`${GLYPH_SELECT_POINTER} Deny`), + 'repeated peer authority request reset to deny', + ) beta.key('escape') await beta.waitForText(/Payload: canonical-json · \d+ original UTF-8 bytes/) @@ -135,7 +246,7 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = }, ]) const betaProvider = await startFakeProvider([{ type: 'completion', text: 'accepted held payload' }]) - const workspace = await createTestWorkspace(`xc-pty-peer-held-${decision.toLowerCase()}-`) + const workspace = await createPeerTestWorkspace(`xc-pty-peer-held-${decision.toLowerCase()}-`) const alpha = await createTuiHarness({ workspace, provider: alphaProvider }) const beta = await createTuiHarness({ workspace, provider: betaProvider }) try { @@ -168,7 +279,11 @@ describe.runIf(process.platform !== 'win32')('TUI cross-session messaging', () = expect(request?.rawBody).toContain(' screen.includes('accepted held payload') && !screen.includes('esc to interrupt'), + 'idle beta after accepted held payload', + 10_000, + ) } else { await alpha.waitForText('denied by beta') expect(betaProvider.mainRequests()).toHaveLength(0) diff --git a/packages/cli/tests/shutdown-coordinator.test.ts b/packages/cli/tests/shutdown-coordinator.test.ts index 64eff96..ba8e4b9 100644 --- a/packages/cli/tests/shutdown-coordinator.test.ts +++ b/packages/cli/tests/shutdown-coordinator.test.ts @@ -9,71 +9,83 @@ const timing = { describe('CLI shutdown coordinator', () => { it('runs shell cleanup before ordinary drains and shares one absolute deadline with emergency cleanup', async () => { - const order: string[] = [] - let emergencyDeadline = 0 - const startedAt = performance.now() - const result = await runShutdownPhases({ - controller: { - quiesce: async () => { - order.push('quiesce') - }, - terminateShells: async () => { - order.push('shell') - return null - }, - drain: async () => { - order.push('drain') + vi.useFakeTimers() + try { + const order: string[] = [] + let emergencyDeadline = 0 + const startedAt = performance.now() + const result = await runShutdownPhases({ + controller: { + quiesce: async () => { + order.push('quiesce') + }, + terminateShells: async () => { + order.push('shell') + return null + }, + drain: async () => { + order.push('drain') + }, }, - }, - reason: 'cli-shutdown', - ordinaryFinalizers: [ - async () => { - order.push('ordinary') + reason: 'cli-shutdown', + ordinaryFinalizers: [ + async () => { + order.push('ordinary') + }, + ], + timing, + startedAt, + forceSync: (reason, deadline) => { + order.push('emergency') + emergencyDeadline = deadline + return { reason, requested: 0, results: [] } }, - ], - timing, - startedAt, - forceSync: (reason, deadline) => { - order.push('emergency') - emergencyDeadline = deadline - return { reason, requested: 0, results: [] } - }, - }) + }) - expect(order.slice(0, 2)).toEqual(['quiesce', 'shell']) - expect(order.at(-1)).toBe('emergency') - expect(emergencyDeadline).toBe(result.absoluteDeadline) - expect(result.absoluteDeadline).toBe(startedAt + timing.hardCapMs) - expect(result.shellPhaseTimedOut).toBe(false) - expect(result.ordinaryPhaseTimedOut).toBe(false) + expect(order.slice(0, 2)).toEqual(['quiesce', 'shell']) + expect(order.at(-1)).toBe('emergency') + expect(emergencyDeadline).toBe(result.absoluteDeadline) + expect(result.absoluteDeadline).toBe(startedAt + timing.hardCapMs) + expect(result.shellPhaseTimedOut).toBe(false) + expect(result.ordinaryPhaseTimedOut).toBe(false) + } finally { + vi.useRealTimers() + } }) it('enters emergency reserve without waiting forever for a stuck shell provider', async () => { - const startedAt = performance.now() - const result = await runShutdownPhases({ - controller: { - terminateShells: () => new Promise(() => {}), - drain: async () => {}, - }, - reason: 'sighup', - ordinaryFinalizers: [], - timing, - startedAt, - forceSync: (reason, deadline) => ({ - reason, - requested: 1, - results: [ - { - managerInstanceId: 'manager', - shellId: 'bg_1', - disposition: deadline > 0 ? 'force-sent-unconfirmed' : 'failed', - }, - ], - }), - }) + vi.useFakeTimers() + try { + const startedAt = performance.now() + const resultPromise = runShutdownPhases({ + controller: { + terminateShells: () => new Promise(() => {}), + drain: async () => {}, + }, + reason: 'sighup', + ordinaryFinalizers: [], + timing, + startedAt, + forceSync: (reason, deadline) => ({ + reason, + requested: 1, + results: [ + { + managerInstanceId: 'manager', + shellId: 'bg_1', + disposition: deadline > 0 ? 'force-sent-unconfirmed' : 'failed', + }, + ], + }), + }) - expect(result.shellPhaseTimedOut).toBe(true) - expect(result.emergency.requested).toBe(1) - expect(performance.now()).toBeLessThanOrEqual(result.absoluteDeadline + 20) + await vi.advanceTimersByTimeAsync(timing.hardCapMs - timing.emergencyReserveMs) + const result = await resultPromise + expect(result.shellPhaseTimedOut).toBe(true) + expect(result.emergency.requested).toBe(1) + expect(performance.now()).toBeLessThanOrEqual(result.absoluteDeadline) + } finally { + vi.useRealTimers() + } }) }) diff --git a/packages/core/native/prebuilt/windows/arm64/xc-peer-broker.exe b/packages/core/native/prebuilt/windows/arm64/xc-peer-broker.exe new file mode 100644 index 0000000..0a7d727 Binary files /dev/null and b/packages/core/native/prebuilt/windows/arm64/xc-peer-broker.exe differ diff --git a/packages/core/native/prebuilt/windows/manifest.json b/packages/core/native/prebuilt/windows/manifest.json index 25020f7..382e86d 100644 --- a/packages/core/native/prebuilt/windows/manifest.json +++ b/packages/core/native/prebuilt/windows/manifest.json @@ -1,14 +1,33 @@ { - "protocolVersion": 2, - "sourceSha256": "5fa5990bade94702bf0d828a27e081d83657dcf770fc37af6674d78c20971c41", + "manifestVersion": 2, "artifacts": { "x64": { - "file": "x64/xc-shell-supervisor.exe", - "sha256": "e4a99d21759551712f4ea7e60bd11b5595a9b2442569f074ebcb671f05fa2cb2" + "shellSupervisor": { + "file": "x64/xc-shell-supervisor.exe", + "protocolVersion": 2, + "sha256": "e4a99d21759551712f4ea7e60bd11b5595a9b2442569f074ebcb671f05fa2cb2", + "sourceSha256": "5fa5990bade94702bf0d828a27e081d83657dcf770fc37af6674d78c20971c41" + }, + "peerBroker": { + "file": "x64/xc-peer-broker.exe", + "protocolVersion": 2, + "sha256": "b125674ceb4d86f38706613cfcd8ebe2c269f63cac8d7bda3ecac3574c9c0bdf", + "sourceSha256": "7eab0e913510d668a77363bde81d483e3586bc05953a8eed93166626ab06c016" + } }, "arm64": { - "file": "arm64/xc-shell-supervisor.exe", - "sha256": "463eb57ccd64ffb438dff3f0a93548e403b6122e05431939f72f91f6427c2e3f" + "shellSupervisor": { + "file": "arm64/xc-shell-supervisor.exe", + "protocolVersion": 2, + "sha256": "463eb57ccd64ffb438dff3f0a93548e403b6122e05431939f72f91f6427c2e3f", + "sourceSha256": "5fa5990bade94702bf0d828a27e081d83657dcf770fc37af6674d78c20971c41" + }, + "peerBroker": { + "file": "arm64/xc-peer-broker.exe", + "protocolVersion": 2, + "sha256": "60be817ed197d44d21cd54c7f8dff0e29c09ff1706cf376ab0dbb5a77f56ad69", + "sourceSha256": "7eab0e913510d668a77363bde81d483e3586bc05953a8eed93166626ab06c016" + } } } } diff --git a/packages/core/native/prebuilt/windows/x64/xc-peer-broker.exe b/packages/core/native/prebuilt/windows/x64/xc-peer-broker.exe new file mode 100644 index 0000000..34912fc Binary files /dev/null and b/packages/core/native/prebuilt/windows/x64/xc-peer-broker.exe differ diff --git a/packages/core/native/windows-peer-broker/.gitignore b/packages/core/native/windows-peer-broker/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/packages/core/native/windows-peer-broker/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/packages/core/native/windows-peer-broker/Cargo.lock b/packages/core/native/windows-peer-broker/Cargo.lock new file mode 100644 index 0000000..a119f1b --- /dev/null +++ b/packages/core/native/windows-peer-broker/Cargo.lock @@ -0,0 +1,163 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xc-peer-broker" +version = "0.1.0" +dependencies = [ + "sha2", + "tokio", + "windows-sys", +] diff --git a/packages/core/native/windows-peer-broker/Cargo.toml b/packages/core/native/windows-peer-broker/Cargo.toml new file mode 100644 index 0000000..47c04c4 --- /dev/null +++ b/packages/core/native/windows-peer-broker/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "xc-peer-broker" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +sha2 = "0.10" +tokio = { version = "1", features = ["io-util", "net", "rt", "rt-multi-thread", "time"] } +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", +] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/packages/core/native/windows-peer-broker/src/lifecycle.rs b/packages/core/native/windows-peer-broker/src/lifecycle.rs new file mode 100644 index 0000000..8da4553 --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/lifecycle.rs @@ -0,0 +1,643 @@ +use std::collections::{HashMap, HashSet}; +use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use tokio::runtime::Builder as RuntimeBuilder; + +use crate::pipe::{ + InboundHandler, OutboundPipeRequest, PipeError, PipeErrorCode, PipeServer, ServerConfig, + outbound_request_async, +}; +use crate::process_peer::current_process_identity; +use crate::protocol::{ + CANCEL_OPERATION, Frame, INBOUND_REQUEST, INBOUND_RESPONSE, INBOX_TOKEN_BYTES, + MAX_ACTIVE_OPERATIONS, OPERATION_ERROR, OUTBOUND_REQUEST, OUTBOUND_RESPONSE, SERVER_FATAL, + SERVER_READY, SHUTDOWN, SHUTDOWN_COMPLETE, START_SERVER, encode_error, encode_inbound_request, + encode_one_string, encode_peer_frame, parse_outbound_request, parse_peer_frame_payload, + parse_start_server, valid_pipe_name_shape, +}; +use crate::security::{Event, ProcessIdentity}; + +const OUTBOUND_RUNTIME_THREADS: usize = 4; + +fn take_operation_id(next: &AtomicU32) -> Option { + next.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .ok() + .filter(|operation_id| *operation_id != 0) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegisterError { + Duplicate, + Capacity, +} + +#[derive(Debug)] +pub struct OperationBook { + active: HashSet, + capacity: usize, +} + +impl OperationBook { + pub fn new(capacity: usize) -> Self { + Self { + active: HashSet::new(), + capacity, + } + } + + pub fn register(&mut self, operation_id: u32) -> Result<(), RegisterError> { + if self.active.contains(&operation_id) { + return Err(RegisterError::Duplicate); + } + if operation_id == 0 || self.active.len() >= self.capacity { + return Err(RegisterError::Capacity); + } + self.active.insert(operation_id); + Ok(()) + } + + pub fn contains(&self, operation_id: u32) -> bool { + self.active.contains(&operation_id) + } + + pub fn complete(&mut self, operation_id: u32) -> bool { + self.active.remove(&operation_id) + } + + #[cfg(test)] + pub fn active_len(&self) -> usize { + self.active.len() + } +} + +pub struct ControlOutput { + writer: Mutex>, +} + +impl ControlOutput { + pub fn new(writer: W) -> Self { + Self { + writer: Mutex::new(Box::new(writer)), + } + } + + pub fn send(&self, kind: u8, operation_id: u32, payload: Vec) -> io::Result<()> { + let bytes = Frame { + kind, + operation_id, + payload, + } + .encode() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let mut writer = self + .writer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + writer.write_all(&bytes)?; + writer.flush() + } +} + +struct OutboundState { + book: OperationBook, + cancel_events: HashMap, +} + +struct InboundState { + book: OperationBook, + responders: HashMap>>, +} + +struct BrokerInner { + output: Arc, + identity: ProcessIdentity, + force_shutdown: Event, + stopping: AtomicBool, + fatal: AtomicBool, + server: Mutex>, + namespace_id: Mutex>, + outbound: Mutex, + inbound: Mutex, + next_inbound_id: AtomicU32, + worker_count: AtomicUsize, + outbound_runtime: tokio::runtime::Runtime, +} + +#[derive(Clone)] +pub struct Broker { + inner: Arc, +} + +impl Broker { + pub fn new(output: Arc) -> io::Result { + let identity = current_process_identity()?; + let force_shutdown = Event::manual_reset()?; + let outbound_runtime = RuntimeBuilder::new_multi_thread() + .worker_threads(OUTBOUND_RUNTIME_THREADS) + .thread_name("xc-peer-outbound") + .enable_all() + .build()?; + Ok(Self { + inner: Arc::new(BrokerInner { + output, + identity, + force_shutdown, + stopping: AtomicBool::new(false), + fatal: AtomicBool::new(false), + server: Mutex::new(None), + namespace_id: Mutex::new(None), + outbound: Mutex::new(OutboundState { + book: OperationBook::new(MAX_ACTIVE_OPERATIONS), + cancel_events: HashMap::new(), + }), + inbound: Mutex::new(InboundState { + book: OperationBook::new(MAX_ACTIVE_OPERATIONS), + responders: HashMap::new(), + }), + next_inbound_id: AtomicU32::new(0x8000_0000), + worker_count: AtomicUsize::new(0), + outbound_runtime, + }), + }) + } + + pub fn handle_frame(&self, frame: Frame) -> Result { + if self.inner.stopping.load(Ordering::Acquire) && frame.kind != SHUTDOWN { + return Err("request received while broker is stopping"); + } + match frame.kind { + START_SERVER => { + self.start_server(frame.operation_id, &frame.payload)?; + Ok(false) + } + OUTBOUND_REQUEST => { + self.start_outbound(frame.operation_id, &frame.payload)?; + Ok(false) + } + INBOUND_RESPONSE => { + self.finish_inbound(frame.operation_id, &frame.payload)?; + Ok(false) + } + CANCEL_OPERATION => { + self.cancel_outbound(frame.operation_id); + Ok(false) + } + SHUTDOWN => { + self.shutdown(true); + let _ = self.inner.output.send(SHUTDOWN_COMPLETE, 0, Vec::new()); + Ok(true) + } + _ => Err("unexpected control frame kind"), + } + } + + pub fn is_fatal(&self) -> bool { + self.inner.fatal.load(Ordering::Acquire) + } + + pub fn protocol_fatal(&self, message: &'static str) { + self.inner + .report_fatal("PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH", message); + } + + pub fn force_shutdown(&self) { + self.shutdown(false); + } + + fn start_server(&self, operation_id: u32, payload: &[u8]) -> Result<(), &'static str> { + let request = parse_start_server(payload).map_err(|_| "START_SERVER payload is invalid")?; + let mut server_slot = self + .inner + .server + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if server_slot.is_some() { + return Err("server is already active"); + } + let inbox_token: [u8; INBOX_TOKEN_BYTES] = request + .inbox_token + .as_bytes() + .try_into() + .map_err(|_| "inbox token length is invalid")?; + let config = ServerConfig { + namespace_id: request.namespace_id.clone(), + inbox_token, + identity: self.inner.identity.clone(), + force_shutdown: self.inner.force_shutdown.clone(), + }; + let handler: Arc = self.inner.clone(); + match PipeServer::start(config, handler) { + Ok(server) => { + let address = server.address.clone(); + *self + .inner + .namespace_id + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(request.namespace_id); + *server_slot = Some(server); + let payload = + encode_one_string(&address).map_err(|_| "SERVER_READY encoding failed")?; + self.inner + .output + .send(SERVER_READY, 0, payload) + .map_err(|_| "control output failed")?; + } + Err(error) => { + self.send_operation_error( + operation_id, + "PEER_WINDOWS_PIPE_CREATE_FAILED", + &error.sanitized_message(), + )?; + } + } + Ok(()) + } + + fn start_outbound(&self, operation_id: u32, payload: &[u8]) -> Result<(), &'static str> { + let request = + parse_outbound_request(payload).map_err(|_| "OUTBOUND_REQUEST payload is invalid")?; + let namespace = self + .inner + .namespace_id + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let Some(namespace) = namespace else { + self.send_operation_error( + operation_id, + "PEER_WINDOWS_PIPE_CREATE_FAILED", + "peer server is not active", + )?; + return Ok(()); + }; + if !valid_pipe_name_shape(&request.address, Some(&namespace)) { + return Err("outbound pipe namespace is invalid"); + } + + let cancel = Event::manual_reset().map_err(|_| "cancel event creation failed")?; + { + let mut outbound = self + .inner + .outbound + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match outbound.book.register(operation_id) { + Ok(()) => {} + Err(RegisterError::Duplicate) => return Err("duplicate active operation id"), + Err(RegisterError::Capacity) => { + drop(outbound); + self.send_operation_error( + operation_id, + "PEER_WINDOWS_OPERATION_CAPACITY", + "peer broker operation capacity is exhausted", + )?; + return Ok(()); + } + } + outbound.cancel_events.insert(operation_id, cancel.clone()); + } + + let target_token: [u8; INBOX_TOKEN_BYTES] = request + .target_token + .as_bytes() + .try_into() + .map_err(|_| "target token length is invalid")?; + let inner = self.inner.clone(); + let runtime = inner.outbound_runtime.handle().clone(); + let deadline = Instant::now() + Duration::from_millis(request.timeout_ms as u64); + inner.worker_count.fetch_add(1, Ordering::AcqRel); + runtime.spawn(async move { + let _guard = WorkerGuard(inner.clone()); + let result = outbound_request_async(OutboundPipeRequest { + address: &request.address, + target_token: &target_token, + sender_instance_id: &request.sender_instance_id, + peer_frame: &request.peer_frame, + identity: &inner.identity, + deadline, + cancel: &cancel, + force_shutdown: &inner.force_shutdown, + }) + .await; + inner.finish_outbound(operation_id, result); + }); + Ok(()) + } + + fn cancel_outbound(&self, operation_id: u32) { + let outbound = self + .inner + .outbound + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if outbound.book.contains(operation_id) + && let Some(cancel) = outbound.cancel_events.get(&operation_id) + { + let _ = cancel.signal(); + } + } + + fn finish_inbound(&self, operation_id: u32, payload: &[u8]) -> Result<(), &'static str> { + let peer_frame = + parse_peer_frame_payload(payload).map_err(|_| "INBOUND_RESPONSE payload is invalid")?; + let responder = { + let mut inbound = self + .inner + .inbound + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let responder = inbound.responders.remove(&operation_id); + inbound.book.complete(operation_id); + responder + }; + if let Some(responder) = responder { + let _ = responder.send(peer_frame); + } + Ok(()) + } + + fn send_operation_error( + &self, + operation_id: u32, + code: &str, + message: &str, + ) -> Result<(), &'static str> { + let payload = encode_error(code, &sanitize_message(message)) + .map_err(|_| "operation error encoding failed")?; + self.inner + .output + .send(OPERATION_ERROR, operation_id, payload) + .map_err(|_| "control output failed") + } + + fn shutdown(&self, graceful: bool) { + if self.inner.stopping.swap(true, Ordering::AcqRel) && graceful { + return; + } + let mut server = self + .inner + .server + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(server) = server.as_mut() { + server.stop_accepting(); + server.release_listener(); + } + if graceful { + let deadline = Instant::now() + Duration::from_millis(500); + while Instant::now() < deadline + && (self.inner.worker_count.load(Ordering::Acquire) != 0 + || server + .as_ref() + .is_some_and(|server| server.active_connections() != 0)) + { + thread::sleep(Duration::from_millis(10)); + } + } + let _ = self.inner.force_shutdown.signal(); + drop(server); + let deadline = Instant::now() + Duration::from_secs(2); + while graceful + && Instant::now() < deadline + && self.inner.worker_count.load(Ordering::Acquire) != 0 + { + thread::sleep(Duration::from_millis(10)); + } + } +} + +impl BrokerInner { + fn finish_outbound(&self, operation_id: u32, result: Result, PipeError>) { + let send_result = match result { + Ok(peer_frame) => encode_peer_frame(&peer_frame) + .map_err(|_| io::Error::other("response encoding failed")) + .and_then(|payload| self.output.send(OUTBOUND_RESPONSE, operation_id, payload)), + Err(error) => { + let (code, message) = pipe_operation_error(&error); + encode_error(code, &sanitize_message(&message)) + .map_err(|_| io::Error::other("error encoding failed")) + .and_then(|payload| self.output.send(OPERATION_ERROR, operation_id, payload)) + } + }; + let mut outbound = self + .outbound + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + outbound.cancel_events.remove(&operation_id); + if !outbound.book.complete(operation_id) { + drop(outbound); + self.report_fatal( + "PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH", + "outbound operation ownership was lost", + ); + return; + } + drop(outbound); + if send_result.is_err() { + self.fatal.store(true, Ordering::Release); + let _ = self.force_shutdown.signal(); + } + } + + fn report_fatal(&self, code: &str, message: &str) { + if self.fatal.swap(true, Ordering::AcqRel) { + return; + } + self.stopping.store(true, Ordering::Release); + let payload = encode_error(code, &sanitize_message(message)).unwrap_or_default(); + let _ = self.output.send(SERVER_FATAL, 0, payload); + let _ = self.force_shutdown.signal(); + } + + fn allocate_inbound(&self, responder: mpsc::SyncSender>) -> Result { + for _ in 0..MAX_ACTIVE_OPERATIONS * 2 { + let Some(operation_id) = take_operation_id(&self.next_inbound_id) else { + self.report_fatal( + "PEER_WINDOWS_OPERATION_CAPACITY", + "inbound operation ids are exhausted", + ); + return Err(PipeError::new( + PipeErrorCode::Capacity, + "inbound operation ids are exhausted", + )); + }; + let mut inbound = self + .inbound + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match inbound.book.register(operation_id) { + Ok(()) => { + inbound.responders.insert(operation_id, responder); + return Ok(operation_id); + } + Err(RegisterError::Duplicate) => continue, + Err(RegisterError::Capacity) => { + return Err(PipeError::new( + PipeErrorCode::Capacity, + "inbound operation capacity is exhausted", + )); + } + } + } + Err(PipeError::new( + PipeErrorCode::Capacity, + "inbound operation id allocation failed", + )) + } + + fn expire_inbound(&self, operation_id: u32) { + let mut inbound = self + .inbound + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + inbound.responders.remove(&operation_id); + inbound.book.complete(operation_id); + } +} + +impl InboundHandler for BrokerInner { + fn handle_inbound( + &self, + sender_instance_id: String, + peer_frame: Vec, + deadline: Instant, + ) -> Result, PipeError> { + if self.stopping.load(Ordering::Acquire) { + return Err(PipeError::new( + PipeErrorCode::ShuttingDown, + "peer broker is shutting down", + )); + } + let (response_sender, response_receiver) = mpsc::sync_channel(1); + let operation_id = self.allocate_inbound(response_sender)?; + let payload = encode_inbound_request(&sender_instance_id, &peer_frame).map_err(|_| { + PipeError::new( + PipeErrorCode::ProtocolMismatch, + "inbound request encoding failed", + ) + })?; + if self + .output + .send(INBOUND_REQUEST, operation_id, payload) + .is_err() + { + self.expire_inbound(operation_id); + return Err(PipeError::new(PipeErrorCode::Io, "control output failed")); + } + + loop { + if self.force_shutdown.is_signaled() { + self.expire_inbound(operation_id); + return Err(PipeError::new( + PipeErrorCode::ShuttingDown, + "peer broker is shutting down", + )); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.expire_inbound(operation_id); + return Err(PipeError::new( + PipeErrorCode::Timeout, + "inbound peer request timed out", + )); + } + match response_receiver.recv_timeout(remaining.min(Duration::from_millis(50))) { + Ok(response) => return Ok(response), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + self.expire_inbound(operation_id); + return Err(PipeError::new( + PipeErrorCode::Io, + "inbound response channel closed", + )); + } + } + } + } + + fn server_fatal(&self, error: PipeError) { + let code = if error.code == PipeErrorCode::SecurityContextLost { + "PEER_WINDOWS_BROKER_SECURITY_STATE" + } else { + "PEER_WINDOWS_PIPE_CREATE_FAILED" + }; + self.report_fatal(code, &error.sanitized_message()); + } +} + +struct WorkerGuard(Arc); + +impl Drop for WorkerGuard { + fn drop(&mut self) { + self.0.worker_count.fetch_sub(1, Ordering::AcqRel); + } +} + +fn pipe_operation_error(error: &PipeError) -> (&'static str, String) { + let code = match error.code { + PipeErrorCode::Timeout => "PEER_WINDOWS_REQUEST_TIMEOUT", + PipeErrorCode::Canceled => "PEER_WINDOWS_OPERATION_CANCELED", + PipeErrorCode::TargetUnavailable => "PEER_WINDOWS_TARGET_UNAVAILABLE", + PipeErrorCode::IdentityUnverified => "PEER_WINDOWS_PEER_IDENTITY_UNVERIFIED", + PipeErrorCode::AuthenticationFailed => "PEER_WINDOWS_AUTHENTICATION_FAILED", + PipeErrorCode::ProtocolMismatch => "PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH", + PipeErrorCode::Capacity => "PEER_WINDOWS_OPERATION_CAPACITY", + PipeErrorCode::ShuttingDown => "PEER_WINDOWS_BROKER_EXITED", + PipeErrorCode::SecurityContextLost => "PEER_WINDOWS_BROKER_SECURITY_STATE", + PipeErrorCode::Io => "PEER_WINDOWS_PIPE_IO_FAILED", + }; + (code, error.sanitized_message()) +} + +pub fn sanitize_message(message: &str) -> String { + message + .chars() + .filter(|character| !character.is_control()) + .take(512) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_capacity_is_released_on_completion() { + let mut book = OperationBook::new(2); + book.register(1).unwrap(); + assert_eq!(book.register(1), Err(RegisterError::Duplicate)); + book.register(2).unwrap(); + assert_eq!(book.register(3), Err(RegisterError::Capacity)); + assert!(book.contains(1)); + assert!(book.complete(1)); + book.register(3).unwrap(); + assert!(!book.complete(1)); + assert!(book.complete(2)); + assert!(book.complete(3)); + assert_eq!(book.active_len(), 0); + } + + #[test] + fn rapid_successes_do_not_exhaust_active_capacity() { + let mut book = OperationBook::new(256); + for operation_id in 1..=300 { + book.register(operation_id).unwrap(); + assert!(book.complete(operation_id)); + } + assert_eq!(book.active_len(), 0); + } + + #[test] + fn operation_ids_stop_before_wrapping() { + let next = AtomicU32::new(u32::MAX - 1); + assert_eq!(take_operation_id(&next), Some(u32::MAX - 1)); + assert_eq!(take_operation_id(&next), None); + assert_eq!(take_operation_id(&next), None); + } +} diff --git a/packages/core/native/windows-peer-broker/src/main.rs b/packages/core/native/windows-peer-broker/src/main.rs new file mode 100644 index 0000000..b0fba68 --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/main.rs @@ -0,0 +1,237 @@ +#![cfg_attr(not(windows), allow(dead_code, unused_imports))] + +#[cfg(windows)] +mod lifecycle; +#[cfg(windows)] +mod pipe; +#[cfg(windows)] +mod process_peer; +#[cfg(windows)] +mod protocol; +#[cfg(windows)] +mod runtime_acl; +#[cfg(windows)] +mod security; + +#[cfg(not(windows))] +fn main() { + eprintln!("xc-peer-broker is only supported on Windows"); + std::process::exit(1); +} + +#[cfg(windows)] +mod windows_broker { + use std::io::{self, Read}; + use std::sync::{Arc, mpsc}; + use std::thread; + use std::time::Duration; + + use crate::lifecycle::{Broker, ControlOutput, sanitize_message}; + use crate::process_peer::current_process_identity; + use crate::protocol::{ + Frame, FrameDecoder, OPERATION_ERROR, PROTOCOL_VERSION, SECURE_RUNTIME_RESULT, + encode_error, encode_one_string, parse_secure_runtime, validate_node_frame, + }; + use crate::runtime_acl::secure_runtime; + + pub fn run() -> io::Result<()> { + let mut arguments = std::env::args().skip(1); + let mode = arguments.next().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "broker mode is required") + })?; + let protocol_flag = arguments.next(); + let protocol = arguments.next().and_then(|value| value.parse::().ok()); + if protocol_flag.as_deref() != Some("--protocol") + || protocol != Some(PROTOCOL_VERSION) + || arguments.next().is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("expected --protocol {PROTOCOL_VERSION}"), + )); + } + match mode.as_str() { + "secure-runtime" => run_secure_runtime(), + "broker" => run_broker(), + "self-test" => run_self_test(), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unknown broker mode", + )), + } + } + + fn run_secure_runtime() -> io::Result<()> { + let frame = read_one_frame()?; + validate_node_frame(&frame, true) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let output = ControlOutput::new(io::stdout()); + let result = parse_secure_runtime(&frame.payload) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "secure-runtime payload is invalid", + ) + }) + .and_then(|request| { + let identity = current_process_identity()?; + secure_runtime(&request.root, &identity) + }); + match result { + Ok(namespace_id) => { + let payload = encode_one_string(&namespace_id) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + output.send(SECURE_RUNTIME_RESULT, frame.operation_id, payload) + } + Err(error) => { + let message = sanitize_message(&format!( + "Windows peer runtime security check failed: {error}{}", + error + .raw_os_error() + .map(|code| format!(" (Windows error {code})")) + .unwrap_or_default() + )); + let payload = encode_error("PEER_WINDOWS_RUNTIME_UNSAFE", &message) + .map_err(|codec| io::Error::new(io::ErrorKind::InvalidData, codec))?; + output.send(OPERATION_ERROR, frame.operation_id, payload)?; + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Windows peer runtime is unsafe", + )) + } + } + } + + fn read_one_frame() -> io::Result { + let mut decoder = FrameDecoder::new(); + let mut input = io::stdin().lock(); + let mut buffer = [0u8; 8192]; + loop { + let read = input.read(&mut buffer)?; + if read == 0 { + decoder + .finish() + .map_err(|error| io::Error::new(io::ErrorKind::UnexpectedEof, error))?; + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "stdin closed before a request frame", + )); + } + let frames = decoder + .push(&buffer[..read]) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if frames.len() > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "secure-runtime accepts exactly one frame", + )); + } + if let Some(frame) = frames.into_iter().next() { + decoder + .finish() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + return Ok(frame); + } + } + } + + enum InputEvent { + Frame(Frame), + Eof, + ProtocolError, + } + + fn spawn_control_reader() -> io::Result> { + let (sender, receiver) = mpsc::sync_channel(32); + thread::Builder::new() + .name("xc-peer-control-reader".to_owned()) + .spawn(move || { + let mut decoder = FrameDecoder::new(); + let mut input = io::stdin().lock(); + let mut buffer = [0u8; 8192]; + loop { + match input.read(&mut buffer) { + Ok(0) => { + let event = if decoder.finish().is_ok() { + InputEvent::Eof + } else { + InputEvent::ProtocolError + }; + let _ = sender.send(event); + return; + } + Ok(read) => match decoder.push(&buffer[..read]) { + Ok(frames) => { + for frame in frames { + if sender.send(InputEvent::Frame(frame)).is_err() { + return; + } + } + } + Err(_) => { + let _ = sender.send(InputEvent::ProtocolError); + return; + } + }, + Err(_) => { + let _ = sender.send(InputEvent::Eof); + return; + } + } + } + }) + .map(|_| receiver) + } + + fn run_self_test() -> io::Result<()> { + let identity = current_process_identity()?; + crate::pipe::self_test(&identity).map_err(io::Error::other) + } + + fn run_broker() -> io::Result<()> { + let output = Arc::new(ControlOutput::new(io::stdout())); + let broker = Broker::new(output)?; + let input = spawn_control_reader()?; + loop { + if broker.is_fatal() { + broker.force_shutdown(); + return Err(io::Error::other("peer broker entered a fatal state")); + } + match input.recv_timeout(Duration::from_millis(50)) { + Ok(InputEvent::Frame(frame)) => { + if validate_node_frame(&frame, false).is_err() { + broker.protocol_fatal("invalid Node control frame"); + continue; + } + match broker.handle_frame(frame) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(message) => broker.protocol_fatal(message), + } + } + Ok(InputEvent::Eof) | Err(mpsc::RecvTimeoutError::Disconnected) => { + broker.force_shutdown(); + return Ok(()); + } + Ok(InputEvent::ProtocolError) => { + broker.protocol_fatal("malformed or truncated Node control frame"); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } + } +} + +#[cfg(windows)] +fn main() { + if let Err(error) = windows_broker::run() { + let message: String = error + .to_string() + .chars() + .filter(|character| !character.is_control()) + .take(512) + .collect(); + eprintln!("xc-peer-broker: {message}"); + std::process::exit(1); + } +} diff --git a/packages/core/native/windows-peer-broker/src/pipe.rs b/packages/core/native/windows-peer-broker/src/pipe.rs new file mode 100644 index 0000000..273468c --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/pipe.rs @@ -0,0 +1,953 @@ +use std::ffi::c_void; +use std::fmt; +use std::future::Future; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::windows::named_pipe::{ + ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions, +}; +use tokio::runtime::Builder; +use tokio::time::{sleep, timeout}; +use windows_sys::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_NO_DATA, ERROR_PIPE_BUSY, ERROR_PIPE_NOT_CONNECTED, HANDLE, +}; +use windows_sys::Win32::Storage::FileSystem::SECURITY_IDENTIFICATION; + +use crate::process_peer::{ + ClientVerificationError, verify_named_pipe_client, verify_named_pipe_server, +}; +use crate::protocol::{ + INBOX_TOKEN_BYTES, MAX_PEER_FRAME_BYTES, PROTOCOL_VERSION, valid_inbox_token, + valid_instance_id, valid_namespace_id, valid_pipe_name_shape, +}; +use crate::security::{ + Event, PrivateSecurityDescriptor, ProcessIdentity, constant_time_eq_43, random_bytes, + verify_private_handle, +}; + +const PIPE_MAGIC: &[u8; 4] = b"XCPP"; +const PIPE_HEADER_BYTES: usize = 12; +const REQUEST: u8 = 0x01; +const RESPONSE: u8 = 0x02; +const REQUEST_PAYLOAD_OVERHEAD: usize = 36 + INBOX_TOKEN_BYTES; +const MAX_PIPE_PAYLOAD: usize = REQUEST_PAYLOAD_OVERHEAD + MAX_PEER_FRAME_BYTES; +const INBOUND_REQUEST_DEADLINE: Duration = Duration::from_secs(30); +const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(5); +const CLIENT_RETRY_INTERVAL: Duration = Duration::from_millis(20); +const MAX_CONNECTIONS: usize = 64; +const PIPE_BUFFER_BYTES: u32 = 16 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipeErrorCode { + Timeout, + Canceled, + TargetUnavailable, + IdentityUnverified, + AuthenticationFailed, + ProtocolMismatch, + Capacity, + ShuttingDown, + SecurityContextLost, + Io, +} + +#[derive(Debug, Clone)] +pub struct PipeError { + pub code: PipeErrorCode, + message: &'static str, + os_code: Option, +} + +impl PipeError { + pub(crate) fn new(code: PipeErrorCode, message: &'static str) -> Self { + Self { + code, + message, + os_code: None, + } + } + + fn os(code: PipeErrorCode, message: &'static str, error: io::Error) -> Self { + Self { + code, + message, + os_code: error.raw_os_error(), + } + } + + pub fn sanitized_message(&self) -> String { + match self.os_code { + Some(code) => format!("{} (Windows error {code})", self.message), + None => self.message.to_owned(), + } + } +} + +impl fmt::Display for PipeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.sanitized_message()) + } +} + +impl std::error::Error for PipeError {} + +pub trait InboundHandler: Send + Sync + 'static { + fn handle_inbound( + &self, + sender_instance_id: String, + peer_frame: Vec, + deadline: Instant, + ) -> Result, PipeError>; + + fn server_fatal(&self, error: PipeError); +} + +#[derive(Clone)] +pub struct ServerConfig { + pub namespace_id: String, + pub inbox_token: [u8; INBOX_TOKEN_BYTES], + pub identity: ProcessIdentity, + pub force_shutdown: Event, +} + +pub struct PipeServer { + pub address: String, + stop_accepting: Event, + active_connections: Arc, + listener: Option>, +} + +impl PipeServer { + pub fn start( + config: ServerConfig, + handler: Arc, + ) -> Result { + let address = generate_pipe_name(&config.namespace_id)?; + let descriptor = Arc::new( + PrivateSecurityDescriptor::new(&config.identity.account_sid, false).map_err( + |error| { + PipeError::os( + PipeErrorCode::Io, + "pipe security descriptor creation failed", + error, + ) + }, + )?, + ); + let stop_accepting = Event::manual_reset().map_err(|error| { + PipeError::os( + PipeErrorCode::Io, + "listener stop event creation failed", + error, + ) + })?; + let active_connections = Arc::new(AtomicUsize::new(0)); + let (ready_sender, ready_receiver) = mpsc::sync_channel(1); + let listener_address = address.clone(); + let listener_stop = stop_accepting.clone(); + let listener_active = active_connections.clone(); + let listener = thread::Builder::new() + .name("xc-peer-pipe-listener".to_owned()) + .spawn(move || { + run_listener( + listener_address, + descriptor, + config, + listener_stop, + listener_active, + handler, + ready_sender, + ); + }) + .map_err(|error| { + PipeError::os(PipeErrorCode::Io, "listener thread creation failed", error) + })?; + + let ready = ready_receiver.recv().map_err(|_| { + PipeError::new( + PipeErrorCode::Io, + "listener initialization ended unexpectedly", + ) + })?; + if let Err(error) = ready { + let _ = listener.join(); + return Err(error); + } + Ok(Self { + address, + stop_accepting, + active_connections, + listener: Some(listener), + }) + } + + pub fn stop_accepting(&self) { + let _ = self.stop_accepting.signal(); + } + + pub fn active_connections(&self) -> usize { + self.active_connections.load(Ordering::Acquire) + } + + pub fn release_listener(&mut self) { + self.listener.take(); + } +} + +impl Drop for PipeServer { + fn drop(&mut self) { + self.stop_accepting(); + self.release_listener(); + } +} + +fn run_listener( + address: String, + descriptor: Arc, + config: ServerConfig, + stop_accepting: Event, + active_connections: Arc, + handler: Arc, + ready: mpsc::SyncSender>, +) { + let runtime = match Builder::new_current_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(error) => { + let _ = ready.send(Err(PipeError::os( + PipeErrorCode::Io, + "listener runtime creation failed", + error, + ))); + return; + } + }; + let first = match runtime + .block_on(async { create_server_instance(&address, true, &descriptor, &config.identity) }) + { + Ok(first) => first, + Err(error) => { + let _ = ready.send(Err(error)); + return; + } + }; + if ready.send(Ok(())).is_err() { + return; + } + + let result = runtime.block_on(run_accept_loop( + first, + &address, + descriptor, + config.clone(), + stop_accepting.clone(), + active_connections, + handler.clone(), + )); + if let Err(error) = result + && !stop_accepting.is_signaled() + && !config.force_shutdown.is_signaled() + { + handler.server_fatal(error); + } +} + +async fn run_accept_loop( + first: NamedPipeServer, + address: &str, + descriptor: Arc, + config: ServerConfig, + stop_accepting: Event, + active_connections: Arc, + handler: Arc, +) -> Result<(), PipeError> { + let mut pending = Some(first); + loop { + if stop_accepting.is_signaled() || config.force_shutdown.is_signaled() { + break; + } + let mut server = match pending.take() { + Some(server) => server, + None => create_server_instance(address, false, &descriptor, &config.identity)?, + }; + match accept_connection(&server, &stop_accepting, &config.force_shutdown).await { + Ok(()) => {} + Err(error) + if matches!( + error.code, + PipeErrorCode::Canceled | PipeErrorCode::ShuttingDown + ) => + { + break; + } + Err(error) if abandoned_accept(&error) => { + tokio::task::yield_now().await; + continue; + } + Err(error) => return Err(error), + } + if stop_accepting.is_signaled() || config.force_shutdown.is_signaled() { + let _ = server.disconnect(); + break; + } + if active_connections + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < MAX_CONNECTIONS).then_some(active + 1) + }) + .is_err() + { + let _ = server.disconnect(); + tokio::task::yield_now().await; + continue; + } + + let connection_config = config.clone(); + let connection_active = active_connections.clone(); + let connection_handler = handler.clone(); + tokio::spawn(async move { + let _guard = ActiveConnectionGuard(connection_active); + let fatal_handler = connection_handler.clone(); + if let Err(error) = + handle_server_connection(&mut server, connection_config, connection_handler).await + { + if error.code == PipeErrorCode::SecurityContextLost { + fatal_handler.server_fatal(error); + } else { + eprintln!("xc-peer-broker: inbound {:?}: {error}", error.code); + } + } + }); + tokio::task::yield_now().await; + } + + while active_connections.load(Ordering::Acquire) != 0 { + sleep(CONTROL_POLL_INTERVAL).await; + } + Ok(()) +} + +async fn accept_connection( + server: &NamedPipeServer, + stop_accepting: &Event, + force_shutdown: &Event, +) -> Result<(), PipeError> { + let mut connect = std::pin::pin!(server.connect()); + loop { + if stop_accepting.is_signaled() { + return Err(PipeError::new( + PipeErrorCode::Canceled, + "peer listener stopped", + )); + } + if force_shutdown.is_signaled() { + return Err(PipeError::new( + PipeErrorCode::ShuttingDown, + "peer broker is shutting down", + )); + } + if let Ok(result) = timeout(CONTROL_POLL_INTERVAL, connect.as_mut()).await { + return result.map_err(|error| { + PipeError::os(PipeErrorCode::Io, "named pipe accept failed", error) + }); + } + } +} + +fn abandoned_accept(error: &PipeError) -> bool { + matches!( + error.os_code, + Some(code) if code == ERROR_NO_DATA as i32 || code == ERROR_PIPE_NOT_CONNECTED as i32 + ) +} + +struct ActiveConnectionGuard(Arc); + +impl Drop for ActiveConnectionGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +fn create_server_instance( + address: &str, + first_instance: bool, + descriptor: &PrivateSecurityDescriptor, + identity: &ProcessIdentity, +) -> Result { + let mut attributes = descriptor.attributes(); + let mut options = ServerOptions::new(); + options + .first_pipe_instance(first_instance) + .reject_remote_clients(true) + .out_buffer_size(PIPE_BUFFER_BYTES) + .in_buffer_size(PIPE_BUFFER_BYTES); + let server = unsafe { + options.create_with_security_attributes_raw( + address, + std::ptr::from_mut(&mut attributes).cast::(), + ) + } + .map_err(|error| { + PipeError::os( + PipeErrorCode::Io, + "secure named pipe creation failed", + error, + ) + })?; + verify_private_handle(server.as_raw_handle() as HANDLE, &identity.account_sid).map_err( + |error| { + PipeError::os( + PipeErrorCode::Io, + "named pipe DACL verification failed", + error, + ) + }, + )?; + Ok(server) +} + +async fn handle_server_connection( + server: &mut NamedPipeServer, + config: ServerConfig, + handler: Arc, +) -> Result<(), PipeError> { + let request_deadline = Instant::now() + INBOUND_REQUEST_DEADLINE; + let request = read_pipe_frame( + server, + MAX_PIPE_PAYLOAD, + request_deadline, + None, + &config.force_shutdown, + ) + .await?; + if request.kind != REQUEST { + return Err(PipeError::new( + PipeErrorCode::AuthenticationFailed, + "peer authentication failed", + )); + } + let request = parse_request_payload(&request.payload).map_err(|_| { + PipeError::new( + PipeErrorCode::AuthenticationFailed, + "peer authentication failed", + ) + })?; + + verify_named_pipe_client(server.as_raw_handle() as HANDLE, &config.identity).map_err( + |error| match error { + ClientVerificationError::Identity(error) => PipeError::os( + PipeErrorCode::IdentityUnverified, + "peer client identity could not be verified", + error, + ), + ClientVerificationError::Revert(error) => PipeError::os( + PipeErrorCode::SecurityContextLost, + "peer listener could not restore its process security context", + error, + ), + }, + )?; + if !constant_time_eq_43(&config.inbox_token, &request.inbox_token) { + return Err(PipeError::new( + PipeErrorCode::AuthenticationFailed, + "peer authentication failed", + )); + } + + let response = tokio::task::spawn_blocking(move || { + handler.handle_inbound( + request.sender_instance_id, + request.peer_frame, + request_deadline, + ) + }) + .await + .map_err(|_| PipeError::new(PipeErrorCode::Io, "inbound handler stopped unexpectedly"))??; + validate_business_frame(&response)?; + write_pipe_frame( + server, + RESPONSE, + &response, + request_deadline, + None, + &config.force_shutdown, + ) + .await?; + let _ = server.disconnect(); + Ok(()) +} + +pub struct OutboundPipeRequest<'a> { + pub address: &'a str, + pub target_token: &'a [u8; INBOX_TOKEN_BYTES], + pub sender_instance_id: &'a str, + pub peer_frame: &'a [u8], + pub identity: &'a ProcessIdentity, + pub deadline: Instant, + pub cancel: &'a Event, + pub force_shutdown: &'a Event, +} + +pub fn self_test(identity: &ProcessIdentity) -> Result<(), PipeError> { + let descriptor = + PrivateSecurityDescriptor::new(&identity.account_sid, false).map_err(|error| { + PipeError::os( + PipeErrorCode::Io, + "pipe security descriptor self-test failed", + error, + ) + })?; + let address = generate_pipe_name("012345abcdef")?; + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + PipeError::os( + PipeErrorCode::Io, + "pipe self-test runtime creation failed", + error, + ) + })?; + runtime.block_on(async { + let first = create_server_instance(&address, true, &descriptor, identity)?; + let second = create_server_instance(&address, false, &descriptor, identity)?; + drop((first, second)); + Ok(()) + }) +} + +pub async fn outbound_request_async( + request: OutboundPipeRequest<'_>, +) -> Result, PipeError> { + if !valid_pipe_name_shape(request.address, None) + || !valid_instance_id(request.sender_instance_id) + || request.peer_frame.is_empty() + || request.peer_frame.len() > MAX_PEER_FRAME_BYTES + { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "outbound request is invalid", + )); + } + let mut pipe = connect_client( + request.address, + request.deadline, + request.cancel, + request.force_shutdown, + ) + .await?; + verify_named_pipe_server(pipe.as_raw_handle() as HANDLE, request.identity).map_err(|_| { + PipeError::new( + PipeErrorCode::IdentityUnverified, + "peer server identity could not be verified", + ) + })?; + + let request_payload = encode_request_payload( + request.sender_instance_id, + request.target_token, + request.peer_frame, + )?; + write_pipe_frame( + &mut pipe, + REQUEST, + &request_payload, + request.deadline, + Some(request.cancel), + request.force_shutdown, + ) + .await?; + let response = read_pipe_frame( + &mut pipe, + MAX_PEER_FRAME_BYTES, + request.deadline, + Some(request.cancel), + request.force_shutdown, + ) + .await?; + if response.kind != RESPONSE { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer response phase is invalid", + )); + } + validate_business_frame(&response.payload)?; + Ok(response.payload) +} + +async fn connect_client( + address: &str, + deadline: Instant, + cancel: &Event, + force_shutdown: &Event, +) -> Result { + loop { + check_request_controls(deadline, Some(cancel), force_shutdown)?; + let mut options = ClientOptions::new(); + options.security_qos_flags(SECURITY_IDENTIFICATION); + match options.open(address) { + Ok(client) => return Ok(client), + Err(error) + if matches!( + error.raw_os_error(), + Some(code) + if code == ERROR_PIPE_BUSY as i32 || code == ERROR_FILE_NOT_FOUND as i32 + ) => + { + wait_cancelable_delay(CLIENT_RETRY_INTERVAL, deadline, cancel, force_shutdown) + .await?; + } + Err(error) => { + return Err(PipeError::os( + PipeErrorCode::TargetUnavailable, + "peer pipe connection failed", + error, + )); + } + } + } +} + +async fn wait_cancelable_delay( + delay: Duration, + deadline: Instant, + cancel: &Event, + force_shutdown: &Event, +) -> Result<(), PipeError> { + let wake_at = Instant::now() + delay; + loop { + check_request_controls(deadline, Some(cancel), force_shutdown)?; + let remaining = wake_at.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + sleep(CONTROL_POLL_INTERVAL.min(remaining)).await; + } +} + +#[derive(Debug, PartialEq, Eq)] +struct PipeFrame { + kind: u8, + payload: Vec, +} + +fn encode_pipe_frame(kind: u8, payload: &[u8]) -> Result, PipeError> { + if !matches!(kind, REQUEST | RESPONSE) || payload.len() > MAX_PIPE_PAYLOAD { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer pipe frame is invalid", + )); + } + let mut bytes = Vec::with_capacity(PIPE_HEADER_BYTES + payload.len()); + bytes.extend_from_slice(PIPE_MAGIC); + bytes.push(PROTOCOL_VERSION); + bytes.push(kind); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + bytes.extend_from_slice(payload); + Ok(bytes) +} + +fn decode_pipe_header(header: &[u8], maximum: usize) -> Result<(u8, usize), PipeError> { + if header.len() != PIPE_HEADER_BYTES + || &header[..4] != PIPE_MAGIC + || header[4] != PROTOCOL_VERSION + || u16::from_le_bytes(header[6..8].try_into().expect("fixed pipe header")) != 0 + { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer pipe header is invalid", + )); + } + let kind = header[5]; + if !matches!(kind, REQUEST | RESPONSE) { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer pipe kind is invalid", + )); + } + let length = u32::from_le_bytes(header[8..12].try_into().expect("fixed pipe header")) as usize; + if length > maximum || length > MAX_PIPE_PAYLOAD { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer pipe payload exceeds limit", + )); + } + Ok((kind, length)) +} + +async fn read_pipe_frame( + reader: &mut R, + maximum: usize, + deadline: Instant, + cancel: Option<&Event>, + force_shutdown: &Event, +) -> Result { + let mut header = [0u8; PIPE_HEADER_BYTES]; + controlled_io( + reader.read_exact(&mut header), + deadline, + cancel, + force_shutdown, + "peer pipe read failed", + ) + .await?; + let (kind, length) = decode_pipe_header(&header, maximum)?; + let mut payload = vec![0u8; length]; + controlled_io( + reader.read_exact(&mut payload), + deadline, + cancel, + force_shutdown, + "peer pipe read failed", + ) + .await?; + Ok(PipeFrame { kind, payload }) +} + +async fn write_pipe_frame( + writer: &mut W, + kind: u8, + payload: &[u8], + deadline: Instant, + cancel: Option<&Event>, + force_shutdown: &Event, +) -> Result<(), PipeError> { + let bytes = encode_pipe_frame(kind, payload)?; + controlled_io( + writer.write_all(&bytes), + deadline, + cancel, + force_shutdown, + "peer pipe write failed", + ) + .await +} + +async fn controlled_io( + operation: F, + deadline: Instant, + cancel: Option<&Event>, + force_shutdown: &Event, + message: &'static str, +) -> Result +where + F: Future>, +{ + let mut operation = std::pin::pin!(operation); + loop { + check_request_controls(deadline, cancel, force_shutdown)?; + let remaining = deadline.saturating_duration_since(Instant::now()); + if let Ok(result) = timeout(CONTROL_POLL_INTERVAL.min(remaining), operation.as_mut()).await + { + return result.map_err(|error| PipeError::os(PipeErrorCode::Io, message, error)); + } + } +} + +fn check_request_controls( + deadline: Instant, + cancel: Option<&Event>, + force_shutdown: &Event, +) -> Result<(), PipeError> { + if Instant::now() >= deadline { + return Err(PipeError::new( + PipeErrorCode::Timeout, + "peer request timed out", + )); + } + if cancel.is_some_and(Event::is_signaled) { + return Err(PipeError::new( + PipeErrorCode::Canceled, + "peer request was canceled", + )); + } + if force_shutdown.is_signaled() { + return Err(PipeError::new( + PipeErrorCode::ShuttingDown, + "peer broker is shutting down", + )); + } + Ok(()) +} + +struct RequestPayload { + sender_instance_id: String, + inbox_token: [u8; INBOX_TOKEN_BYTES], + peer_frame: Vec, +} + +fn encode_request_payload( + sender_instance_id: &str, + inbox_token: &[u8; INBOX_TOKEN_BYTES], + peer_frame: &[u8], +) -> Result, PipeError> { + if !valid_instance_id(sender_instance_id) + || !valid_inbox_token(std::str::from_utf8(inbox_token).unwrap_or("")) + { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer authentication payload is invalid", + )); + } + validate_business_frame(peer_frame)?; + let mut payload = Vec::with_capacity(REQUEST_PAYLOAD_OVERHEAD + peer_frame.len()); + payload.extend_from_slice(sender_instance_id.as_bytes()); + payload.extend_from_slice(inbox_token); + payload.extend_from_slice(peer_frame); + Ok(payload) +} + +fn parse_request_payload(payload: &[u8]) -> Result { + if payload.len() <= REQUEST_PAYLOAD_OVERHEAD || payload.len() > MAX_PIPE_PAYLOAD { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer authentication payload is invalid", + )); + } + let sender_instance_id = std::str::from_utf8(&payload[..36]) + .map_err(|_| { + PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer sender identity is invalid", + ) + })? + .to_owned(); + if !valid_instance_id(&sender_instance_id) { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer sender identity is invalid", + )); + } + let inbox_token = payload[36..REQUEST_PAYLOAD_OVERHEAD] + .try_into() + .expect("fixed request token"); + let peer_frame = payload[REQUEST_PAYLOAD_OVERHEAD..].to_vec(); + validate_business_frame(&peer_frame)?; + Ok(RequestPayload { + sender_instance_id, + inbox_token, + peer_frame, + }) +} + +fn validate_business_frame(peer_frame: &[u8]) -> Result<(), PipeError> { + if peer_frame.is_empty() || peer_frame.len() > MAX_PEER_FRAME_BYTES { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "peer business frame length is invalid", + )); + } + Ok(()) +} + +pub fn generate_pipe_name(namespace_id: &str) -> Result { + if !valid_namespace_id(namespace_id) { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "namespace id is invalid", + )); + } + let mut random = [0u8; 24]; + random_bytes(&mut random) + .map_err(|error| PipeError::os(PipeErrorCode::Io, "pipe name generation failed", error))?; + let encoded = base64url_192(&random); + let address = format!(r"\\.\pipe\x-code-peer-v2-{namespace_id}-{encoded}"); + if !valid_pipe_name_shape(&address, Some(namespace_id)) { + return Err(PipeError::new( + PipeErrorCode::ProtocolMismatch, + "generated pipe name is invalid", + )); + } + Ok(address) +} + +fn base64url_192(bytes: &[u8; 24]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut output = [0u8; 32]; + for (chunk_index, chunk) in bytes.as_chunks::<3>().0.iter().enumerate() { + let value = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32; + let offset = chunk_index * 4; + output[offset] = ALPHABET[((value >> 18) & 0x3f) as usize]; + output[offset + 1] = ALPHABET[((value >> 12) & 0x3f) as usize]; + output[offset + 2] = ALPHABET[((value >> 6) & 0x3f) as usize]; + output[offset + 3] = ALPHABET[(value & 0x3f) as usize]; + } + String::from_utf8(output.to_vec()).expect("base64url alphabet is UTF-8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pipe_name_has_192_bit_random_shape() { + let first = generate_pipe_name("012345abcdef").unwrap(); + let second = generate_pipe_name("012345abcdef").unwrap(); + assert!(valid_pipe_name_shape(&first, Some("012345abcdef"))); + assert_eq!(first.len(), r"\\.\pipe\x-code-peer-v2-".len() + 12 + 1 + 32); + assert_ne!(first, second); + } + + #[test] + fn secure_server_supports_first_and_subsequent_instances() { + let identity = crate::process_peer::current_process_identity().unwrap(); + self_test(&identity).unwrap(); + } + + #[test] + fn disconnected_accept_instances_are_recoverable() { + for code in [ERROR_NO_DATA, ERROR_PIPE_NOT_CONNECTED] { + let error = PipeError::os( + PipeErrorCode::Io, + "named pipe accept failed", + io::Error::from_raw_os_error(code as i32), + ); + assert!(abandoned_accept(&error)); + } + let fatal = PipeError::os( + PipeErrorCode::Io, + "named pipe accept failed", + io::Error::from_raw_os_error(ERROR_FILE_NOT_FOUND as i32), + ); + assert!(!abandoned_accept(&fatal)); + } + + #[test] + fn pipe_codec_rejects_bad_flags_kind_length_and_business_bounds() { + let encoded = encode_pipe_frame(REQUEST, b"test").unwrap(); + assert_eq!( + decode_pipe_header(&encoded[..PIPE_HEADER_BYTES], 4).unwrap(), + (REQUEST, 4) + ); + + let mut flags = encoded.clone(); + flags[6] = 1; + assert!(decode_pipe_header(&flags[..PIPE_HEADER_BYTES], 4).is_err()); + let mut kind = encoded.clone(); + kind[5] = 99; + assert!(decode_pipe_header(&kind[..PIPE_HEADER_BYTES], 4).is_err()); + assert!(decode_pipe_header(&encoded[..PIPE_HEADER_BYTES], 3).is_err()); + + assert!(validate_business_frame(&vec![0; MAX_PEER_FRAME_BYTES]).is_ok()); + assert!(validate_business_frame(&vec![0; MAX_PEER_FRAME_BYTES + 1]).is_err()); + } + + #[test] + fn request_codec_binds_sender_token_and_business_frame() { + let token: [u8; INBOX_TOKEN_BYTES] = [b'A'; INBOX_TOKEN_BYTES]; + let sender = "550e8400-e29b-41d4-a716-446655440000"; + let encoded = encode_request_payload(sender, &token, b"frame").unwrap(); + assert_eq!(encoded.len(), REQUEST_PAYLOAD_OVERHEAD + 5); + let decoded = parse_request_payload(&encoded).unwrap(); + assert_eq!(decoded.sender_instance_id, sender); + assert_eq!(decoded.inbox_token, token); + assert_eq!(decoded.peer_frame, b"frame"); + assert!(parse_request_payload(&encoded[..REQUEST_PAYLOAD_OVERHEAD]).is_err()); + } +} diff --git a/packages/core/native/windows-peer-broker/src/process_peer.rs b/packages/core/native/windows-peer-broker/src/process_peer.rs new file mode 100644 index 0000000..9b46715 --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/process_peer.rs @@ -0,0 +1,146 @@ +use std::io; +use std::ptr::null_mut; + +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Security::{RevertToSelf, TOKEN_QUERY}; +use windows_sys::Win32::System::Pipes::{GetNamedPipeServerProcessId, ImpersonateNamedPipeClient}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, OpenProcess, OpenProcessToken, OpenThreadToken, + PROCESS_QUERY_LIMITED_INFORMATION, +}; + +use crate::security::{OwnedHandle, ProcessIdentity, identities_match, token_identity}; + +pub enum ClientVerificationError { + Identity(io::Error), + Revert(io::Error), +} + +pub fn current_process_identity() -> io::Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = OwnedHandle::new(token)?; + let identity = token_identity(token.raw())?; + if identity.is_app_container { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "AppContainer broker tokens are unsupported", + )); + } + Ok(identity) +} + +pub fn verify_named_pipe_server(pipe: HANDLE, current: &ProcessIdentity) -> io::Result<()> { + let mut process_id = 0u32; + if unsafe { GetNamedPipeServerProcessId(pipe, &mut process_id) } == 0 || process_id == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "server process identity is unavailable", + )); + } + let process = + OwnedHandle::new(unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }) + .map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "server process cannot be inspected", + ) + })?; + let mut token = null_mut(); + if unsafe { OpenProcessToken(process.raw(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "server token cannot be inspected", + )); + } + let token = OwnedHandle::new(token)?; + let peer = token_identity(token.raw()).map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "server token identity is invalid", + ) + })?; + if identities_match(current, &peer) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "server process identity is incompatible", + )) + } +} + +pub fn verify_named_pipe_client( + pipe: HANDLE, + current: &ProcessIdentity, +) -> Result<(), ClientVerificationError> { + if unsafe { ImpersonateNamedPipeClient(pipe) } == 0 { + return Err(ClientVerificationError::Identity(io::Error::new( + io::ErrorKind::PermissionDenied, + "client impersonation failed", + ))); + } + let guard = ImpersonationGuard { active: true }; + let result = inspect_thread_identity(current); + match guard.revert() { + Ok(()) => result.map_err(ClientVerificationError::Identity), + Err(error) => Err(ClientVerificationError::Revert(error)), + } +} + +fn inspect_thread_identity(current: &ProcessIdentity) -> io::Result<()> { + let mut token = null_mut(); + if unsafe { OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, 1, &mut token) } == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "client token cannot be inspected", + )); + } + let token = OwnedHandle::new(token)?; + let peer = token_identity(token.raw()).map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "client token identity is invalid", + ) + })?; + if identities_match(current, &peer) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "client process identity is incompatible", + )) + } +} + +struct ImpersonationGuard { + active: bool, +} + +impl ImpersonationGuard { + fn revert(mut self) -> io::Result<()> { + if unsafe { RevertToSelf() } == 0 { + let first_error = io::Error::last_os_error(); + if unsafe { RevertToSelf() } == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("client impersonation revert failed: {first_error}"), + )); + } + } + self.active = false; + Ok(()) + } +} + +impl Drop for ImpersonationGuard { + fn drop(&mut self) { + if self.active { + unsafe { + RevertToSelf(); + } + } + } +} diff --git a/packages/core/native/windows-peer-broker/src/protocol.rs b/packages/core/native/windows-peer-broker/src/protocol.rs new file mode 100644 index 0000000..e4694cb --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/protocol.rs @@ -0,0 +1,535 @@ +use std::fmt; + +pub const PROTOCOL_VERSION: u8 = 2; +pub const HEADER_BYTES: usize = 16; +pub const MAX_CONTROL_PAYLOAD: usize = 139_264; +pub const MAX_PEER_FRAME_BYTES: usize = 131_072; +pub const MAX_ACTIVE_OPERATIONS: usize = 256; +pub const MAX_RUNTIME_ROOT_BYTES: usize = u16::MAX as usize; +pub const MAX_TIMEOUT_MS: u32 = 120_000; +pub const INBOX_TOKEN_BYTES: usize = 43; + +const MAGIC: &[u8; 4] = b"XCPB"; + +pub const SECURE_RUNTIME: u8 = 0x01; +pub const START_SERVER: u8 = 0x02; +pub const OUTBOUND_REQUEST: u8 = 0x03; +pub const INBOUND_RESPONSE: u8 = 0x04; +pub const CANCEL_OPERATION: u8 = 0x05; +pub const SHUTDOWN: u8 = 0x06; + +pub const SECURE_RUNTIME_RESULT: u8 = 0x81; +pub const SERVER_READY: u8 = 0x82; +pub const INBOUND_REQUEST: u8 = 0x83; +pub const OUTBOUND_RESPONSE: u8 = 0x84; +pub const OPERATION_ERROR: u8 = 0x86; +pub const SERVER_FATAL: u8 = 0x87; +pub const SHUTDOWN_COMPLETE: u8 = 0x88; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolError(&'static str); + +impl ProtocolError { + pub const fn new(message: &'static str) -> Self { + Self(message) + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for ProtocolError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub kind: u8, + pub operation_id: u32, + pub payload: Vec, +} + +impl Frame { + pub fn encode(&self) -> Result, ProtocolError> { + if !is_known_kind(self.kind) { + return Err(ProtocolError::new("unknown frame kind")); + } + if self.payload.len() > MAX_CONTROL_PAYLOAD { + return Err(ProtocolError::new("control payload exceeds limit")); + } + let mut bytes = Vec::with_capacity(HEADER_BYTES + self.payload.len()); + bytes.extend_from_slice(MAGIC); + bytes.push(PROTOCOL_VERSION); + bytes.push(self.kind); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&self.operation_id.to_le_bytes()); + bytes.extend_from_slice(&(self.payload.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&self.payload); + Ok(bytes) + } +} + +pub struct FrameDecoder { + buffer: Vec, + expected: Option, +} + +impl FrameDecoder { + pub fn new() -> Self { + Self { + buffer: Vec::with_capacity(HEADER_BYTES), + expected: None, + } + } + + pub fn push(&mut self, chunk: &[u8]) -> Result, ProtocolError> { + let mut frames = Vec::new(); + for &byte in chunk { + self.buffer.push(byte); + if self.buffer.len() == HEADER_BYTES { + self.expected = Some(parse_header(&self.buffer)?); + } + if self.expected == Some(self.buffer.len()) { + frames.push(decode_complete_frame(&self.buffer)?); + self.buffer.clear(); + self.expected = None; + } + } + Ok(frames) + } + + pub fn finish(&self) -> Result<(), ProtocolError> { + if self.buffer.is_empty() { + Ok(()) + } else { + Err(ProtocolError::new("truncated control frame")) + } + } +} + +fn parse_header(header: &[u8]) -> Result { + if header.len() != HEADER_BYTES || &header[0..4] != MAGIC { + return Err(ProtocolError::new("invalid control magic")); + } + if header[4] != PROTOCOL_VERSION { + return Err(ProtocolError::new("unsupported control version")); + } + if !is_known_kind(header[5]) { + return Err(ProtocolError::new("unknown frame kind")); + } + if u16::from_le_bytes(header[6..8].try_into().expect("fixed header")) != 0 { + return Err(ProtocolError::new("unsupported control flags")); + } + let payload_length = + u32::from_le_bytes(header[12..16].try_into().expect("fixed header")) as usize; + if payload_length > MAX_CONTROL_PAYLOAD { + return Err(ProtocolError::new("control payload exceeds limit")); + } + Ok(HEADER_BYTES + payload_length) +} + +fn decode_complete_frame(bytes: &[u8]) -> Result { + let expected = parse_header(&bytes[..HEADER_BYTES])?; + if expected != bytes.len() { + return Err(ProtocolError::new("invalid complete frame length")); + } + Ok(Frame { + kind: bytes[5], + operation_id: u32::from_le_bytes(bytes[8..12].try_into().expect("fixed header")), + payload: bytes[HEADER_BYTES..].to_vec(), + }) +} + +fn is_known_kind(kind: u8) -> bool { + matches!( + kind, + SECURE_RUNTIME + | START_SERVER + | OUTBOUND_REQUEST + | INBOUND_RESPONSE + | CANCEL_OPERATION + | SHUTDOWN + | SECURE_RUNTIME_RESULT + | SERVER_READY + | INBOUND_REQUEST + | OUTBOUND_RESPONSE + | OPERATION_ERROR + | SERVER_FATAL + | SHUTDOWN_COMPLETE + ) +} + +pub fn validate_node_frame(frame: &Frame, secure_runtime_mode: bool) -> Result<(), ProtocolError> { + if frame.operation_id == 0 { + return Err(ProtocolError::new("request operation id must be nonzero")); + } + let allowed = if secure_runtime_mode { + frame.kind == SECURE_RUNTIME + } else { + matches!( + frame.kind, + START_SERVER | OUTBOUND_REQUEST | INBOUND_RESPONSE | CANCEL_OPERATION | SHUTDOWN + ) + }; + if !allowed { + return Err(ProtocolError::new("frame kind is invalid in this mode")); + } + if matches!(frame.kind, CANCEL_OPERATION | SHUTDOWN) && !frame.payload.is_empty() { + return Err(ProtocolError::new("control request payload must be empty")); + } + Ok(()) +} + +struct PayloadCursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> PayloadCursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], ProtocolError> { + let end = self + .offset + .checked_add(length) + .ok_or_else(|| ProtocolError::new("payload length overflow"))?; + if end > self.bytes.len() { + return Err(ProtocolError::new("truncated control payload")); + } + let value = &self.bytes[self.offset..end]; + self.offset = end; + Ok(value) + } + + fn string(&mut self) -> Result { + let length = u16::from_le_bytes( + self.take(2)? + .try_into() + .expect("u16 prefix has fixed length"), + ) as usize; + let bytes = self.take(length)?; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| ProtocolError::new("control string is not UTF-8")) + } + + fn bytes(&mut self, maximum: usize) -> Result, ProtocolError> { + let length = u32::from_le_bytes( + self.take(4)? + .try_into() + .expect("u32 prefix has fixed length"), + ) as usize; + if length > maximum { + return Err(ProtocolError::new("control byte array exceeds limit")); + } + Ok(self.take(length)?.to_vec()) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes( + self.take(4)?.try_into().expect("u32 has fixed length"), + )) + } + + fn finish(self) -> Result<(), ProtocolError> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(ProtocolError::new("unexpected control payload suffix")) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecureRuntimeRequest { + pub root: String, +} + +pub fn parse_secure_runtime(payload: &[u8]) -> Result { + let mut cursor = PayloadCursor::new(payload); + let root = cursor.string()?; + if root.is_empty() || root.len() > MAX_RUNTIME_ROOT_BYTES { + return Err(ProtocolError::new("runtime root length is invalid")); + } + cursor.finish()?; + Ok(SecureRuntimeRequest { root }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartServerRequest { + pub namespace_id: String, + pub instance_id: String, + pub inbox_token: String, +} + +pub fn parse_start_server(payload: &[u8]) -> Result { + let mut cursor = PayloadCursor::new(payload); + let request = StartServerRequest { + namespace_id: cursor.string()?, + instance_id: cursor.string()?, + inbox_token: cursor.string()?, + }; + cursor.finish()?; + if !valid_namespace_id(&request.namespace_id) { + return Err(ProtocolError::new("namespace id is invalid")); + } + if !valid_instance_id(&request.instance_id) { + return Err(ProtocolError::new("instance id is invalid")); + } + if !valid_inbox_token(&request.inbox_token) { + return Err(ProtocolError::new("inbox token is invalid")); + } + Ok(request) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutboundRequest { + pub address: String, + pub target_token: String, + pub sender_instance_id: String, + pub timeout_ms: u32, + pub peer_frame: Vec, +} + +pub fn parse_outbound_request(payload: &[u8]) -> Result { + let mut cursor = PayloadCursor::new(payload); + let request = OutboundRequest { + address: cursor.string()?, + target_token: cursor.string()?, + sender_instance_id: cursor.string()?, + timeout_ms: cursor.u32()?, + peer_frame: cursor.bytes(MAX_PEER_FRAME_BYTES)?, + }; + cursor.finish()?; + if !valid_pipe_name_shape(&request.address, None) { + return Err(ProtocolError::new("pipe address is invalid")); + } + if !valid_inbox_token(&request.target_token) { + return Err(ProtocolError::new("target token is invalid")); + } + if !valid_instance_id(&request.sender_instance_id) { + return Err(ProtocolError::new("sender instance id is invalid")); + } + if request.timeout_ms == 0 || request.timeout_ms > MAX_TIMEOUT_MS { + return Err(ProtocolError::new("outbound timeout is invalid")); + } + if request.peer_frame.is_empty() { + return Err(ProtocolError::new("peer frame is empty")); + } + Ok(request) +} + +pub fn parse_peer_frame_payload(payload: &[u8]) -> Result, ProtocolError> { + let mut cursor = PayloadCursor::new(payload); + let frame = cursor.bytes(MAX_PEER_FRAME_BYTES)?; + cursor.finish()?; + if frame.is_empty() { + return Err(ProtocolError::new("peer frame is empty")); + } + Ok(frame) +} + +fn push_string(output: &mut Vec, value: &str) -> Result<(), ProtocolError> { + let length = u16::try_from(value.len()) + .map_err(|_| ProtocolError::new("control string exceeds u16 limit"))?; + output.extend_from_slice(&length.to_le_bytes()); + output.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn push_bytes(output: &mut Vec, value: &[u8]) -> Result<(), ProtocolError> { + if value.len() > MAX_PEER_FRAME_BYTES { + return Err(ProtocolError::new("peer frame exceeds limit")); + } + output.extend_from_slice(&(value.len() as u32).to_le_bytes()); + output.extend_from_slice(value); + Ok(()) +} + +pub fn encode_one_string(value: &str) -> Result, ProtocolError> { + let mut output = Vec::with_capacity(2 + value.len()); + push_string(&mut output, value)?; + Ok(output) +} + +pub fn encode_peer_frame(value: &[u8]) -> Result, ProtocolError> { + let mut output = Vec::with_capacity(4 + value.len()); + push_bytes(&mut output, value)?; + Ok(output) +} + +pub fn encode_inbound_request( + sender_instance_id: &str, + peer_frame: &[u8], +) -> Result, ProtocolError> { + if !valid_instance_id(sender_instance_id) { + return Err(ProtocolError::new("sender instance id is invalid")); + } + let mut output = Vec::with_capacity(2 + sender_instance_id.len() + 4 + peer_frame.len()); + push_string(&mut output, sender_instance_id)?; + push_bytes(&mut output, peer_frame)?; + Ok(output) +} + +pub fn encode_error(code: &str, message: &str) -> Result, ProtocolError> { + if code.is_empty() + || code.len() > 64 + || !code + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte == b'_') + { + return Err(ProtocolError::new("error code is invalid")); + } + if message.len() > 512 { + return Err(ProtocolError::new("error message exceeds limit")); + } + let mut output = Vec::with_capacity(4 + code.len() + message.len()); + push_string(&mut output, code)?; + push_string(&mut output, message)?; + Ok(output) +} + +pub fn valid_namespace_id(value: &str) -> bool { + value.len() == 12 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +pub fn valid_instance_id(value: &str) -> bool { + if value.len() != 36 { + return false; + } + value.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +pub fn valid_inbox_token(value: &str) -> bool { + value.len() == INBOX_TOKEN_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +pub fn valid_pipe_name_shape(value: &str, expected_namespace: Option<&str>) -> bool { + const PREFIX: &str = r"\\.\pipe\x-code-peer-v2-"; + let Some(suffix) = value.strip_prefix(PREFIX) else { + return false; + }; + let Some((namespace, random)) = suffix.split_once('-') else { + return false; + }; + valid_namespace_id(namespace) + && expected_namespace.is_none_or(|expected| expected == namespace) + && random.len() == 32 + && random + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(kind: u8, operation_id: u32, payload: &[u8]) -> Vec { + Frame { + kind, + operation_id, + payload: payload.to_vec(), + } + .encode() + .unwrap() + } + + #[test] + fn decoder_handles_every_chunk_boundary_and_merged_frames() { + let first = frame(START_SERVER, 7, b"alpha"); + let second = frame(SHUTDOWN, 9, &[]); + let joined = [first.as_slice(), second.as_slice()].concat(); + + for split in 0..=joined.len() { + let mut decoder = FrameDecoder::new(); + let mut decoded = decoder.push(&joined[..split]).unwrap(); + decoded.extend(decoder.push(&joined[split..]).unwrap()); + decoder.finish().unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].operation_id, 7); + assert_eq!(decoded[1].kind, SHUTDOWN); + } + } + + #[test] + fn decoder_rejects_truncation_unknown_flags_and_oversize_before_payload() { + let bytes = frame(SHUTDOWN, 1, &[]); + let mut truncated = FrameDecoder::new(); + truncated.push(&bytes[..HEADER_BYTES - 1]).unwrap(); + assert_eq!( + truncated.finish().unwrap_err().to_string(), + "truncated control frame" + ); + + let mut flags = bytes.clone(); + flags[6] = 1; + assert!(FrameDecoder::new().push(&flags).is_err()); + + let mut oversized = bytes; + oversized[12..16].copy_from_slice(&((MAX_CONTROL_PAYLOAD + 1) as u32).to_le_bytes()); + assert!(FrameDecoder::new().push(&oversized).is_err()); + } + + #[test] + fn payload_cursor_rejects_bad_utf8_truncation_suffix_and_peer_bounds() { + assert!(parse_secure_runtime(&[1, 0, 0xff]).is_err()); + assert!(parse_secure_runtime(&[4, 0, b'a']).is_err()); + assert!(parse_secure_runtime(&[1, 0, b'a', 0]).is_err()); + + let mut oversized = Vec::new(); + oversized.extend_from_slice(&((MAX_PEER_FRAME_BYTES + 1) as u32).to_le_bytes()); + assert!(parse_peer_frame_payload(&oversized).is_err()); + } + + #[test] + fn validators_lock_pipe_token_and_instance_shapes() { + let namespace = "012345abcdef"; + let random = "AbCdEfGhIjKlMnOpQrStUvWxYz012345"; + let name = format!(r"\\.\pipe\x-code-peer-v2-{namespace}-{random}"); + assert!(valid_pipe_name_shape(&name, Some(namespace))); + assert!(!valid_pipe_name_shape(&name, Some("fedcba543210"))); + assert!(!valid_pipe_name_shape( + r"\\.\pipe\x-code-peer-v2-012345abcdef-../../bad", + None + )); + assert!(valid_inbox_token(&"A".repeat(INBOX_TOKEN_BYTES))); + assert!(!valid_inbox_token(&"A".repeat(INBOX_TOKEN_BYTES - 1))); + assert!(valid_instance_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(!valid_instance_id("550e8400/e29b/41d4/a716/446655440000")); + } + + #[test] + fn control_payload_maximum_is_exact() { + assert_eq!(MAX_CONTROL_PAYLOAD, 139_264); + assert_eq!(MAX_PEER_FRAME_BYTES, 131_072); + let maximum = Frame { + kind: OPERATION_ERROR, + operation_id: 1, + payload: vec![0; MAX_CONTROL_PAYLOAD], + }; + assert_eq!( + maximum.encode().unwrap().len(), + HEADER_BYTES + MAX_CONTROL_PAYLOAD + ); + let too_large = Frame { + payload: vec![0; MAX_CONTROL_PAYLOAD + 1], + ..maximum + }; + assert!(too_large.encode().is_err()); + } +} diff --git a/packages/core/native/windows-peer-broker/src/runtime_acl.rs b/packages/core/native/windows-peer-broker/src/runtime_acl.rs new file mode 100644 index 0000000..94465a9 --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/runtime_acl.rs @@ -0,0 +1,280 @@ +use std::io; +use std::mem::zeroed; +use std::path::{Component, Path, Prefix}; +use std::ptr::{null, null_mut}; + +use sha2::{Digest, Sha256}; +use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError}; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, GetDriveTypeW, GetFileInformationByHandle, GetFinalPathNameByHandleW, + GetVolumeInformationW, GetVolumePathNameW, OPEN_EXISTING, READ_CONTROL, VOLUME_NAME_GUID, + WRITE_DAC, WRITE_OWNER, +}; + +use crate::security::{ + OwnedHandle, PrivateSecurityDescriptor, ProcessIdentity, audit_parent_handle, + verify_private_handle, wide, +}; + +const DRIVE_REMOTE: u32 = 4; +const DRIVE_UNKNOWN: u32 = 0; +const DRIVE_NO_ROOT_DIR: u32 = 1; +const FILE_PERSISTENT_ACLS: u32 = 0x0000_0008; +const MAX_WINDOWS_PATH_UNITS: usize = 32_767; + +pub fn secure_runtime(root: &str, identity: &ProcessIdentity) -> io::Result { + let root_path = validate_root_shape(root)?; + validate_local_acl_volume(root)?; + + let mut retained_handles = Vec::new(); + for (index, ancestor) in root_path + .ancestors() + .filter(|path| !path.as_os_str().is_empty()) + .enumerate() + { + let handle = open_directory(ancestor, false)?; + audit_parent_handle(handle.raw(), &identity.account_sid, index == 0)?; + retained_handles.push(handle); + } + + let descriptor = PrivateSecurityDescriptor::new(&identity.account_sid, true)?; + let runtime_path = root_path.join("runtime"); + let runtime = ensure_private_directory(&runtime_path, &descriptor, identity)?; + let peers_path = runtime_path.join("peers"); + let peers = ensure_private_directory(&peers_path, &descriptor, identity)?; + + verify_private_handle(runtime.raw(), &identity.account_sid)?; + verify_private_handle(peers.raw(), &identity.account_sid)?; + let canonical = final_guid_path(peers.raw())?; + drop(retained_handles); + Ok(namespace_id_from_utf16(&canonical)) +} + +fn validate_root_shape(root: &str) -> io::Result<&Path> { + if root.is_empty() + || root.encode_utf16().count() > MAX_WINDOWS_PATH_UNITS + || root.contains('\0') + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "runtime root length is invalid", + )); + } + let path = Path::new(root); + if !path.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "runtime root must be absolute", + )); + } + match path.components().next() { + Some(Component::Prefix(prefix)) + if matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_)) => {} + _ => { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "UNC and device runtime roots are unsupported", + )); + } + } + Ok(path) +} + +fn validate_local_acl_volume(root: &str) -> io::Result<()> { + let root_wide = wide(root)?; + let mut volume_path = vec![0u16; MAX_WINDOWS_PATH_UNITS + 1]; + if unsafe { + GetVolumePathNameW( + root_wide.as_ptr(), + volume_path.as_mut_ptr(), + volume_path.len() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let drive_type = unsafe { GetDriveTypeW(volume_path.as_ptr()) }; + if matches!(drive_type, DRIVE_UNKNOWN | DRIVE_NO_ROOT_DIR | DRIVE_REMOTE) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "runtime volume is not a supported local volume", + )); + } + let mut filesystem_flags = 0u32; + if unsafe { + GetVolumeInformationW( + volume_path.as_ptr(), + null_mut(), + 0, + null_mut(), + null_mut(), + &mut filesystem_flags, + null_mut(), + 0, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if filesystem_flags & FILE_PERSISTENT_ACLS == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "runtime volume does not support persistent ACLs", + )); + } + Ok(()) +} + +fn ensure_private_directory( + path: &Path, + descriptor: &PrivateSecurityDescriptor, + identity: &ProcessIdentity, +) -> io::Result { + let path_text = path.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "runtime path is not Unicode") + })?; + let path_wide = wide(path_text)?; + let attributes = descriptor.attributes(); + if unsafe { CreateDirectoryW(path_wide.as_ptr(), &attributes) } == 0 { + let error = unsafe { GetLastError() }; + if error != ERROR_ALREADY_EXISTS { + return Err(io::Error::from_raw_os_error(error as i32)); + } + } + let handle = open_directory_with_access( + path, + READ_CONTROL | WRITE_DAC | WRITE_OWNER | FILE_READ_ATTRIBUTES, + )?; + descriptor.apply_to_handle(handle.raw())?; + verify_private_handle(handle.raw(), &identity.account_sid)?; + Ok(handle) +} + +fn open_directory(path: &Path, writable_dacl: bool) -> io::Result { + let mut access = READ_CONTROL | FILE_READ_ATTRIBUTES; + if writable_dacl { + access |= WRITE_DAC; + } + open_directory_with_access(path, access) +} + +fn open_directory_with_access(path: &Path, access: u32) -> io::Result { + let path_text = path.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "runtime path is not Unicode") + })?; + let path_wide = wide(path_text)?; + let handle = OwnedHandle::new(unsafe { + CreateFileW( + path_wide.as_ptr(), + access, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + })?; + let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { zeroed() }; + if unsafe { GetFileInformationByHandle(handle.raw(), &mut information) } == 0 { + return Err(io::Error::last_os_error()); + } + if information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "runtime path is not a directory", + )); + } + if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "runtime path contains a reparse point", + )); + } + Ok(handle) +} + +fn final_guid_path(handle: windows_sys::Win32::Foundation::HANDLE) -> io::Result> { + let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_GUID; + let required = unsafe { GetFinalPathNameByHandleW(handle, null_mut(), 0, flags) } as usize; + if required == 0 || required > MAX_WINDOWS_PATH_UNITS { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "canonical runtime path length is invalid", + )); + } + let mut buffer = vec![0u16; required + 1]; + let written = unsafe { + GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags) + } as usize; + if written == 0 || written > required { + return Err(io::Error::last_os_error()); + } + buffer.truncate(written); + Ok(buffer) +} + +fn namespace_id_from_utf16(canonical_path: &[u16]) -> String { + let mut hasher = Sha256::new(); + for unit in canonical_path { + hasher.update(unit.to_le_bytes()); + } + let digest = hasher.finalize(); + let mut namespace = String::with_capacity(12); + for byte in &digest[..6] { + use std::fmt::Write as _; + write!(namespace, "{byte:02x}").expect("writing to String cannot fail"); + } + namespace +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespace_hash_uses_normalized_utf16le_bytes() { + let path: Vec = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\x\runtime\peers" + .encode_utf16() + .collect(); + assert_eq!(namespace_id_from_utf16(&path), "b7ba38534afc"); + assert_eq!(namespace_id_from_utf16(&path).len(), 12); + } + + #[test] + fn runtime_root_shape_rejects_relative_and_unc_paths() { + assert!(validate_root_shape(r"relative\x").is_err()); + assert!(validate_root_shape(r"\\server\share\x").is_err()); + assert!(validate_root_shape(r"C:\x-code").is_ok()); + } + + #[test] + fn secures_and_verifies_a_real_local_runtime_tree() { + let identity = crate::process_peer::current_process_identity().unwrap(); + let descriptor = PrivateSecurityDescriptor::new(&identity.account_sid, true).unwrap(); + let mut random = [0u8; 8]; + crate::security::random_bytes(&mut random).unwrap(); + let suffix = random + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let root = Path::new(&std::env::var("USERPROFILE").unwrap()) + .join(format!(".x-code-peer-native-test-{suffix}")); + let result = (|| { + let handle = ensure_private_directory(&root, &descriptor, &identity)?; + drop(handle); + let namespace = secure_runtime(root.to_str().unwrap(), &identity)?; + if namespace.len() != 12 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "namespace length mismatch", + )); + } + Ok(()) + })(); + let _ = std::fs::remove_dir_all(&root); + result.unwrap(); + } +} diff --git a/packages/core/native/windows-peer-broker/src/security.rs b/packages/core/native/windows-peer-broker/src/security.rs new file mode 100644 index 0000000..dd6dbad --- /dev/null +++ b/packages/core/native/windows-peer-broker/src/security.rs @@ -0,0 +1,711 @@ +use std::ffi::c_void; +use std::io; +use std::mem::{size_of, zeroed}; +use std::ptr::{null, null_mut}; +use std::sync::Arc; + +use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_ALL, HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Security::Cryptography::{ + BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACL, ACL_REVISION, ACL_SIZE_INFORMATION, AddAccessAllowedAceEx, CopySid, + CreateWellKnownSid, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, + GetKernelObjectSecurity, GetLengthSid, GetSecurityDescriptorControl, GetSecurityDescriptorDacl, + GetSecurityDescriptorOwner, GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, + INHERIT_ONLY_ACE, InitializeAcl, InitializeSecurityDescriptor, IsValidSid, OBJECT_INHERIT_ACE, + OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, SetKernelObjectSecurity, SetSecurityDescriptorControl, + SetSecurityDescriptorDacl, SetSecurityDescriptorOwner, TOKEN_MANDATORY_LABEL, TOKEN_USER, + TokenIntegrityLevel, TokenIsAppContainer, TokenUser, WELL_KNOWN_SID_TYPE, +}; +use windows_sys::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; + +const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; +const ACCESS_DENIED_ACE_TYPE: u8 = 1; +const CONTAINER_INHERIT_ACE: u32 = 0x02; +const SECURITY_DESCRIPTOR_REVISION: u32 = 1; + +#[derive(Debug)] +pub struct OwnedHandle(usize); + +impl OwnedHandle { + pub fn new(handle: HANDLE) -> io::Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle as usize)) + } + } + + pub fn raw(&self) -> HANDLE { + self.0 as HANDLE + } +} + +unsafe impl Send for OwnedHandle {} +unsafe impl Sync for OwnedHandle {} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { + CloseHandle(self.raw()); + } + } + } +} + +#[derive(Clone, Debug)] +pub struct Event(Arc); + +impl Event { + pub fn manual_reset() -> io::Result { + let handle = unsafe { CreateEventW(null(), 1, 0, null()) }; + Ok(Self(Arc::new(OwnedHandle::new(handle)?))) + } + + pub fn signal(&self) -> io::Result<()> { + if unsafe { SetEvent(self.raw()) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + pub fn is_signaled(&self) -> bool { + (unsafe { WaitForSingleObject(self.raw(), 0) }) == 0 + } + + pub fn raw(&self) -> HANDLE { + self.0.raw() + } +} + +#[derive(Clone, Debug)] +pub struct Sid { + storage: Arc>, + length: usize, +} + +impl Sid { + unsafe fn copy_from(raw: PSID) -> io::Result { + if raw.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "missing token SID", + )); + } + let length = unsafe { GetLengthSid(raw) } as usize; + if length == 0 || length > 68 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "token SID length is invalid", + )); + } + let mut storage = vec![0usize; length.div_ceil(size_of::())]; + if unsafe { CopySid(length as u32, storage.as_mut_ptr().cast(), raw) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Self { + storage: Arc::new(storage), + length, + }) + } + + pub fn raw(&self) -> PSID { + self.storage.as_ptr() as PSID + } + + pub fn length(&self) -> usize { + self.length + } + + pub fn equals_raw(&self, other: PSID) -> bool { + !other.is_null() && unsafe { EqualSid(self.raw(), other) } != 0 + } + + pub fn equals(&self, other: &Self) -> bool { + self.equals_raw(other.raw()) + } + + fn from_subauthorities( + identifier_authority: [u8; 6], + subauthorities: &[u32], + ) -> io::Result { + if subauthorities.len() > 15 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SID has too many subauthorities", + )); + } + let length = 8 + std::mem::size_of_val(subauthorities); + let mut storage = vec![0usize; length.div_ceil(size_of::())]; + let bytes = + unsafe { std::slice::from_raw_parts_mut(storage.as_mut_ptr().cast::(), length) }; + bytes[0] = 1; + bytes[1] = subauthorities.len() as u8; + bytes[2..8].copy_from_slice(&identifier_authority); + for (index, value) in subauthorities.iter().enumerate() { + let offset = 8 + index * size_of::(); + bytes[offset..offset + size_of::()].copy_from_slice(&value.to_le_bytes()); + } + if unsafe { IsValidSid(storage.as_ptr() as PSID) } == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "constructed SID is invalid", + )); + } + Ok(Self { + storage: Arc::new(storage), + length, + }) + } +} + +#[derive(Clone, Debug)] +pub struct ProcessIdentity { + pub account_sid: Sid, + pub integrity_rid: u32, + pub is_app_container: bool, +} + +pub fn token_identity(token: HANDLE) -> io::Result { + let user = query_token_information(token, TokenUser)?; + let token_user = unsafe { &*(user.as_ptr().cast::()) }; + let account_sid = unsafe { Sid::copy_from(token_user.User.Sid) }?; + + let integrity = query_token_information(token, TokenIntegrityLevel)?; + let label = unsafe { &*(integrity.as_ptr().cast::()) }; + let integrity_sid = label.Label.Sid; + if integrity_sid.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "missing integrity SID", + )); + } + let count = unsafe { *GetSidSubAuthorityCount(integrity_sid) } as u32; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "integrity SID is invalid", + )); + } + let integrity_rid = unsafe { *GetSidSubAuthority(integrity_sid, count - 1) }; + + let app_container = query_token_information(token, TokenIsAppContainer)?; + if app_container.len() * size_of::() < size_of::() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "app-container token field is truncated", + )); + } + let is_app_container = unsafe { app_container.as_ptr().cast::().read_unaligned() != 0 }; + + Ok(ProcessIdentity { + account_sid, + integrity_rid, + is_app_container, + }) +} + +pub fn identities_match(current: &ProcessIdentity, peer: &ProcessIdentity) -> bool { + !current.is_app_container + && !peer.is_app_container + && current.integrity_rid == peer.integrity_rid + && current.account_sid.equals(&peer.account_sid) +} + +fn query_token_information(token: HANDLE, class: i32) -> io::Result> { + let mut needed = 0u32; + unsafe { + GetTokenInformation(token, class, null_mut(), 0, &mut needed); + } + if needed == 0 || needed > 64 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "token field length is invalid", + )); + } + let mut buffer = vec![0usize; (needed as usize).div_ceil(size_of::())]; + if unsafe { + GetTokenInformation( + token, + class, + buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(buffer) +} + +pub fn random_bytes(bytes: &mut [u8]) -> io::Result<()> { + let length = u32::try_from(bytes.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "random request is too large"))?; + let status = unsafe { + BCryptGenRandom( + null_mut(), + bytes.as_mut_ptr(), + length, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if status < 0 { + Err(io::Error::other(format!( + "system random generator failed ({status:#x})" + ))) + } else { + Ok(()) + } +} + +pub fn constant_time_eq_43(left: &[u8], right: &[u8]) -> bool { + if left.len() != 43 || right.len() != 43 { + return false; + } + let mut difference = 0u8; + for index in 0..43 { + difference |= left[index] ^ right[index]; + } + difference == 0 +} + +pub struct PrivateSecurityDescriptor { + descriptor: Vec, + _acl: Vec, + _owner: Sid, +} + +impl PrivateSecurityDescriptor { + pub fn new(account_sid: &Sid, inheritable: bool) -> io::Result { + let ace_bytes = size_of::() - size_of::() + account_sid.length(); + let acl_bytes = size_of::() + ace_bytes; + let mut acl = vec![0usize; acl_bytes.div_ceil(size_of::())]; + let acl_pointer = acl.as_mut_ptr().cast::(); + if unsafe { InitializeAcl(acl_pointer, acl_bytes as u32, ACL_REVISION) } == 0 { + return Err(io::Error::last_os_error()); + } + let flags = if inheritable { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }; + if unsafe { + AddAccessAllowedAceEx( + acl_pointer, + ACL_REVISION, + flags, + GENERIC_ALL, + account_sid.raw(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let descriptor_bytes = 64usize; + let mut descriptor = vec![0usize; descriptor_bytes.div_ceil(size_of::())]; + let descriptor_pointer = descriptor.as_mut_ptr().cast::(); + if unsafe { InitializeSecurityDescriptor(descriptor_pointer, SECURITY_DESCRIPTOR_REVISION) } + == 0 + { + return Err(io::Error::last_os_error()); + } + if unsafe { SetSecurityDescriptorDacl(descriptor_pointer, 1, acl_pointer, 0) } == 0 { + return Err(io::Error::last_os_error()); + } + let owner = account_sid.clone(); + if unsafe { SetSecurityDescriptorOwner(descriptor_pointer, owner.raw(), 0) } == 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { + SetSecurityDescriptorControl(descriptor_pointer, SE_DACL_PROTECTED, SE_DACL_PROTECTED) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(Self { + descriptor, + _acl: acl, + _owner: owner, + }) + } + + pub fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: self.descriptor.as_ptr() as *mut c_void, + bInheritHandle: 0, + } + } + + pub fn apply_to_handle(&self, handle: HANDLE) -> io::Result<()> { + if unsafe { + SetKernelObjectSecurity( + handle, + DACL_SECURITY_INFORMATION + | OWNER_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + self.descriptor.as_ptr() as PSECURITY_DESCRIPTOR, + ) + } == 0 + { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } +} + +pub fn verify_private_handle(handle: HANDLE, account_sid: &Sid) -> io::Result<()> { + let descriptor = object_security_descriptor(handle)?; + let descriptor_pointer = descriptor.as_ptr() as PSECURITY_DESCRIPTOR; + + let mut control = 0u16; + let mut revision = 0u32; + if unsafe { GetSecurityDescriptorControl(descriptor_pointer, &mut control, &mut revision) } == 0 + { + return Err(io::Error::last_os_error()); + } + if control & SE_DACL_PROTECTED == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "directory DACL is inherited", + )); + } + + let mut owner = null_mut(); + let mut owner_defaulted = 0; + if unsafe { GetSecurityDescriptorOwner(descriptor_pointer, &mut owner, &mut owner_defaulted) } + == 0 + { + return Err(io::Error::last_os_error()); + } + if !account_sid.equals_raw(owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "directory owner is unsafe", + )); + } + + let mut present = 0; + let mut defaulted = 0; + let mut acl = null_mut(); + if unsafe { + GetSecurityDescriptorDacl(descriptor_pointer, &mut present, &mut acl, &mut defaulted) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if present == 0 || acl.is_null() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "directory DACL is missing", + )); + } + let information = acl_information(acl)?; + if information.AceCount != 1 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "directory DACL is not private", + )); + } + let DaclAce::Allow { mask, sid, .. } = parse_dacl_ace(acl, 0)? else { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "directory DACL allowlist is unsafe", + )); + }; + const FILE_ALL_ACCESS_MASK: u32 = 0x001f_01ff; + if (mask & GENERIC_ALL == 0 && mask & FILE_ALL_ACCESS_MASK != FILE_ALL_ACCESS_MASK) + || !account_sid.equals_raw(sid) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "directory DACL allowlist is unsafe", + )); + } + Ok(()) +} + +pub fn audit_parent_handle( + handle: HANDLE, + current_sid: &Sid, + protects_runtime_contents: bool, +) -> io::Result<()> { + let descriptor = object_security_descriptor(handle)?; + let descriptor_pointer = descriptor.as_ptr() as PSECURITY_DESCRIPTOR; + let mut owner = null_mut(); + let mut owner_defaulted = 0; + if unsafe { GetSecurityDescriptorOwner(descriptor_pointer, &mut owner, &mut owner_defaulted) } + == 0 + { + return Err(io::Error::last_os_error()); + } + + let system = well_known_sid(22)?; + let administrators = well_known_sid(26)?; + let creator_owner = well_known_sid(3)?; + let trusted_installer = trusted_installer_sid()?; + if owner.is_null() + || !(current_sid.equals_raw(owner) + || system.equals_raw(owner) + || administrators.equals_raw(owner) + || trusted_installer.equals_raw(owner)) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "parent directory owner is unsafe", + )); + } + let mut present = 0; + let mut defaulted = 0; + let mut acl = null_mut(); + if unsafe { + GetSecurityDescriptorDacl(descriptor_pointer, &mut present, &mut acl, &mut defaulted) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if present == 0 || acl.is_null() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "parent DACL is missing", + )); + } + let information = acl_information(acl)?; + const REPLACEMENT_RIGHTS: u32 = + 0x0000_0040 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000; + const CONTENT_WRITE_RIGHTS: u32 = 0x0000_0002 | 0x0000_0004 | 0x4000_0000; + let dangerous = REPLACEMENT_RIGHTS + | if protects_runtime_contents { + CONTENT_WRITE_RIGHTS + } else { + 0 + }; + for index in 0..information.AceCount { + let DaclAce::Allow { flags, mask, sid } = parse_dacl_ace(acl, index)? else { + continue; + }; + if flags & INHERIT_ONLY_ACE as u8 != 0 || mask & dangerous == 0 { + continue; + } + let allowed = current_sid.equals_raw(sid) + || system.equals_raw(sid) + || administrators.equals_raw(sid) + || (creator_owner.equals_raw(sid) && current_sid.equals_raw(owner)); + if !allowed { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "parent grants replacement rights to another principal", + )); + } + } + Ok(()) +} + +fn object_security_descriptor(handle: HANDLE) -> io::Result> { + let requested = DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION; + let mut needed = 0u32; + unsafe { + GetKernelObjectSecurity(handle, requested, null_mut(), 0, &mut needed); + } + if needed == 0 || needed > 64 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security descriptor length is invalid", + )); + } + let mut descriptor = vec![0usize; (needed as usize).div_ceil(size_of::())]; + if unsafe { + GetKernelObjectSecurity( + handle, + requested, + descriptor.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(descriptor) +} + +fn acl_information(acl: *mut ACL) -> io::Result { + let mut information: ACL_SIZE_INFORMATION = unsafe { zeroed() }; + if unsafe { + GetAclInformation( + acl, + (&mut information as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + 2, + ) + } == 0 + { + Err(io::Error::last_os_error()) + } else { + Ok(information) + } +} + +enum DaclAce { + Allow { flags: u8, mask: u32, sid: PSID }, + Deny, +} + +fn parse_dacl_ace(acl: *mut ACL, index: u32) -> io::Result { + let information = acl_information(acl)?; + let mut raw_ace = null_mut(); + if unsafe { GetAce(acl, index, &mut raw_ace) } == 0 || raw_ace.is_null() { + return Err(io::Error::last_os_error()); + } + let acl_start = acl as usize; + let acl_end = acl_start + .checked_add(information.AclBytesInUse as usize) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "ACL length overflow"))?; + let ace_start = raw_ace as usize; + if ace_start < acl_start || ace_start.checked_add(4).is_none_or(|end| end > acl_end) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ACE header is outside its ACL", + )); + } + let bytes = unsafe { std::slice::from_raw_parts(raw_ace.cast::(), acl_end - ace_start) }; + let kind = bytes[0]; + let flags = bytes[1]; + let ace_size = u16::from_le_bytes([bytes[2], bytes[3]]) as usize; + if ace_size < 4 || ace_size > bytes.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ACE size is invalid", + )); + } + if kind == ACCESS_DENIED_ACE_TYPE { + return Ok(DaclAce::Deny); + } + if kind != ACCESS_ALLOWED_ACE_TYPE { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unsupported ACE type in directory DACL", + )); + } + if ace_size < 16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allow ACE is truncated", + )); + } + let mask = u32::from_le_bytes(bytes[4..8].try_into().expect("checked allow ACE mask")); + let sid_bytes = &bytes[8..ace_size]; + let subauthority_count = sid_bytes[1] as usize; + let sid_length = + 8usize + .checked_add(subauthority_count.checked_mul(4).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "ACE SID length overflow") + })?) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "ACE SID length overflow"))?; + if sid_bytes[0] != 1 || sid_length > sid_bytes.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allow ACE SID is invalid", + )); + } + let sid = sid_bytes.as_ptr().cast_mut().cast(); + if unsafe { IsValidSid(sid) } == 0 || unsafe { GetLengthSid(sid) } as usize != sid_length { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allow ACE SID is invalid", + )); + } + Ok(DaclAce::Allow { flags, mask, sid }) +} + +fn trusted_installer_sid() -> io::Result { + Sid::from_subauthorities( + [0, 0, 0, 0, 0, 5], + &[ + 80, 956008885, 3418522649, 1831038044, 1853292631, 2271478464, + ], + ) +} + +fn well_known_sid(kind: WELL_KNOWN_SID_TYPE) -> io::Result { + let mut needed = 0u32; + unsafe { + CreateWellKnownSid(kind, null_mut(), null_mut(), &mut needed); + } + if needed == 0 || needed > 68 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "well-known SID length is invalid", + )); + } + let mut storage = vec![0usize; (needed as usize).div_ceil(size_of::())]; + if unsafe { CreateWellKnownSid(kind, null_mut(), storage.as_mut_ptr().cast(), &mut needed) } + == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(Sid { + storage: Arc::new(storage), + length: needed as usize, + }) +} + +pub fn wide(value: &str) -> io::Result> { + if value.encode_utf16().any(|unit| unit == 0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "string contains NUL", + )); + } + Ok(value.encode_utf16().chain(std::iter::once(0)).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_comparison_requires_exact_fixed_length_and_checks_all_bytes() { + let token = b"abcdefghijklmnopqrstuvwxyzABCDEFGH012345678"; + assert_eq!(token.len(), 43); + assert!(constant_time_eq_43(token, token)); + let mut first_changed = *token; + first_changed[0] ^= 1; + assert!(!constant_time_eq_43(token, &first_changed)); + let mut last_changed = *token; + last_changed[42] ^= 1; + assert!(!constant_time_eq_43(token, &last_changed)); + assert!(!constant_time_eq_43(token, &token[..42])); + } + + #[test] + fn random_generator_fills_requested_bytes() { + let mut bytes = [0u8; 32]; + random_bytes(&mut bytes).unwrap(); + assert_ne!(bytes, [0u8; 32]); + } + + #[test] + fn private_descriptor_pins_the_account_as_owner() { + let identity = crate::process_peer::current_process_identity().unwrap(); + let descriptor = PrivateSecurityDescriptor::new(&identity.account_sid, true).unwrap(); + let descriptor_pointer = descriptor.descriptor.as_ptr() as PSECURITY_DESCRIPTOR; + let mut owner = null_mut(); + let mut owner_defaulted = 0; + assert_ne!( + unsafe { + GetSecurityDescriptorOwner(descriptor_pointer, &mut owner, &mut owner_defaulted) + }, + 0 + ); + assert!(identity.account_sid.equals_raw(owner)); + assert_eq!(owner_defaulted, 0); + } +} diff --git a/packages/core/scripts/build-native.mjs b/packages/core/scripts/build-native.mjs index 17504e3..30fbd8c 100644 --- a/packages/core/scripts/build-native.mjs +++ b/packages/core/scripts/build-native.mjs @@ -1,10 +1,9 @@ import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { nativeSourceSha256 } from './native-artifacts.mjs' +import { WINDOWS_NATIVE_ARTIFACTS, updateWindowsNativeManifest } from './native-artifacts.mjs' const coreDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -19,35 +18,50 @@ function run(command, args) { }) } +function requestedArtifacts() { + const optionIndex = process.argv.indexOf('--artifact') + if (optionIndex < 0) return Object.keys(WINDOWS_NATIVE_ARTIFACTS) + const value = process.argv[optionIndex + 1] + if (value === 'all') return Object.keys(WINDOWS_NATIVE_ARTIFACTS) + if (!value || !WINDOWS_NATIVE_ARTIFACTS[value]) { + throw new Error(`Unknown Windows native artifact ${value ?? '(missing)'}`) + } + return [value] +} + +async function ensureCompleteDistNativeRoot(destinationRoot) { + const prebuiltRoot = path.join(coreDir, 'native', 'prebuilt', 'windows') + try { + await fs.access(path.join(destinationRoot, 'manifest.json')) + } catch { + await fs.mkdir(path.dirname(destinationRoot), { recursive: true }) + await fs.cp(prebuiltRoot, destinationRoot, { recursive: true }) + } +} + async function main() { if (process.platform !== 'win32') return if (process.arch !== 'x64' && process.arch !== 'arm64') { - throw new Error(`Windows shell supervisor does not support architecture ${process.arch}`) + throw new Error(`Windows native helpers do not support architecture ${process.arch}`) } - const nativeDir = path.join(coreDir, 'native', 'windows-job-supervisor') - await run('cargo', ['build', '--release', '--locked', '--manifest-path', path.join(nativeDir, 'Cargo.toml')]) - - const source = path.join(nativeDir, 'target', 'release', 'xc-shell-supervisor.exe') - const destinationDir = path.join(coreDir, 'dist', 'native', 'windows', process.arch) - const destination = path.join(destinationDir, 'xc-shell-supervisor.exe') - await fs.mkdir(destinationDir, { recursive: true }) - await fs.copyFile(source, destination) - const bytes = await fs.readFile(destination) - const sha256 = createHash('sha256').update(bytes).digest('hex') - - const manifestPath = path.join(coreDir, 'dist', 'native', 'windows', 'manifest.json') - let manifest = { protocolVersion: 2, artifacts: {} } - try { - manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) - } catch {} - manifest.protocolVersion = 2 - manifest.sourceSha256 = await nativeSourceSha256(coreDir) - manifest.artifacts[process.arch] = { - file: `${process.arch}/xc-shell-supervisor.exe`, - sha256, + const destinationRoot = path.join(coreDir, 'dist', 'native', 'windows') + await ensureCompleteDistNativeRoot(destinationRoot) + const artifactNames = requestedArtifacts() + for (const artifactName of artifactNames) { + const definition = WINDOWS_NATIVE_ARTIFACTS[artifactName] + const nativeDir = path.join(coreDir, 'native', definition.sourceDirectory) + await run('cargo', ['build', '--release', '--locked', '--manifest-path', path.join(nativeDir, 'Cargo.toml')]) + const source = path.join(nativeDir, 'target', 'release', definition.file) + const destinationDir = path.join(destinationRoot, process.arch) + await fs.mkdir(destinationDir, { recursive: true }) + await fs.copyFile(source, path.join(destinationDir, definition.file)) } - await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + await updateWindowsNativeManifest( + coreDir, + destinationRoot, + artifactNames.map((artifactName) => ({ arch: process.arch, artifactName })), + ) } await main() diff --git a/packages/core/scripts/copy-native.mjs b/packages/core/scripts/copy-native.mjs index 2584a35..1d1afff 100644 --- a/packages/core/scripts/copy-native.mjs +++ b/packages/core/scripts/copy-native.mjs @@ -21,8 +21,25 @@ try { // A missing, stale, or partial destination is replaced below. } +async function copyIfChanged(relativeFile) { + const sourcePath = path.join(sourceDir, relativeFile) + const destinationPath = path.join(destinationDir, relativeFile) + const sourceBytes = await fs.readFile(sourcePath) + const destinationBytes = await fs.readFile(destinationPath).catch(() => null) + if (destinationBytes?.equals(sourceBytes)) return + await fs.mkdir(path.dirname(destinationPath), { recursive: true }) + const temporaryPath = `${destinationPath}.${process.pid}.tmp` + await fs.writeFile(temporaryPath, sourceBytes) + await fs.rename(temporaryPath, destinationPath) +} + if (!destinationIsCurrent) { - await fs.rm(destinationDir, { recursive: true, force: true }) - await fs.mkdir(path.dirname(destinationDir), { recursive: true }) - await fs.cp(sourceDir, destinationDir, { recursive: true }) + await fs.mkdir(destinationDir, { recursive: true }) + for (const architecture of Object.values(sourceManifest.artifacts)) { + for (const artifact of Object.values(architecture)) await copyIfChanged(artifact.file) + } + const manifestPath = path.join(destinationDir, 'manifest.json') + const temporaryManifestPath = `${manifestPath}.${process.pid}.tmp` + await fs.writeFile(temporaryManifestPath, `${JSON.stringify(sourceManifest, null, 2)}\n`, 'utf8') + await fs.rename(temporaryManifestPath, manifestPath) } diff --git a/packages/core/scripts/native-artifacts.mjs b/packages/core/scripts/native-artifacts.mjs index 8fbbcc8..f6181c1 100644 --- a/packages/core/scripts/native-artifacts.mjs +++ b/packages/core/scripts/native-artifacts.mjs @@ -3,26 +3,40 @@ import fs from 'node:fs/promises' import path from 'node:path' export const WINDOWS_NATIVE_ARCHES = ['x64', 'arm64'] -export const WINDOWS_NATIVE_PROTOCOL_VERSION = 2 -export const WINDOWS_SUPERVISOR_FILE = 'xc-shell-supervisor.exe' +export const WINDOWS_NATIVE_MANIFEST_VERSION = 2 +export const WINDOWS_SUPERVISOR_PROTOCOL_VERSION = 2 +export const WINDOWS_PEER_BROKER_PROTOCOL_VERSION = 2 + +export const WINDOWS_NATIVE_ARTIFACTS = { + shellSupervisor: { + file: 'xc-shell-supervisor.exe', + protocolVersion: WINDOWS_SUPERVISOR_PROTOCOL_VERSION, + sourceDirectory: 'windows-job-supervisor', + }, + peerBroker: { + file: 'xc-peer-broker.exe', + protocolVersion: WINDOWS_PEER_BROKER_PROTOCOL_VERSION, + sourceDirectory: 'windows-peer-broker', + }, +} const WINDOWS_PE_MACHINES = { x64: 0x8664, arm64: 0xaa64, } -function verifyPeArchitecture(bytes, arch) { +export function verifyPeArchitecture(bytes, arch, artifactName = 'helper') { if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) { - throw new Error(`Windows prebuilt helper is not a PE executable for ${arch}`) + throw new Error(`Windows prebuilt ${artifactName} is not a PE executable for ${arch}`) } const peOffset = bytes.readUInt32LE(0x3c) if (peOffset + 6 > bytes.length || bytes.readUInt32LE(peOffset) !== 0x00004550) { - throw new Error(`Windows prebuilt helper has an invalid PE header for ${arch}`) + throw new Error(`Windows prebuilt ${artifactName} has an invalid PE header for ${arch}`) } const actualMachine = bytes.readUInt16LE(peOffset + 4) if (actualMachine !== WINDOWS_PE_MACHINES[arch]) { throw new Error( - `Windows prebuilt helper architecture mismatch for ${arch}: received PE machine 0x${actualMachine.toString(16)}`, + `Windows prebuilt ${artifactName} architecture mismatch for ${arch}: received PE machine 0x${actualMachine.toString(16)}`, ) } } @@ -39,8 +53,10 @@ async function sourceFiles(root, relative = '') { return files } -export async function nativeSourceSha256(coreDir) { - const sourceDir = path.join(coreDir, 'native', 'windows-job-supervisor') +export async function nativeSourceSha256(coreDir, artifactName = 'shellSupervisor') { + const definition = WINDOWS_NATIVE_ARTIFACTS[artifactName] + if (!definition) throw new Error(`Unknown Windows native artifact: ${artifactName}`) + const sourceDir = path.join(coreDir, 'native', definition.sourceDirectory) const relativeFiles = (await sourceFiles(sourceDir)) .filter((file) => file === 'Cargo.toml' || file === 'Cargo.lock' || file.endsWith('.rs')) .sort((left, right) => left.localeCompare(right, 'en')) @@ -55,54 +71,123 @@ export async function nativeSourceSha256(coreDir) { return hash.digest('hex') } -export async function writeWindowsNativeManifest(coreDir, windowsDir) { +async function artifactSourceHashes(coreDir) { + return Object.fromEntries( + await Promise.all( + Object.keys(WINDOWS_NATIVE_ARTIFACTS).map(async (artifactName) => [ + artifactName, + await nativeSourceSha256(coreDir, artifactName), + ]), + ), + ) +} + +function builtEntrySet(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error('Windows native manifest requires at least one explicitly built artifact') + } + const built = new Set() + for (const entry of entries) { + if (!WINDOWS_NATIVE_ARCHES.includes(entry?.arch) || !WINDOWS_NATIVE_ARTIFACTS[entry?.artifactName]) { + throw new Error(`Invalid Windows native build provenance: ${String(entry?.arch)}:${String(entry?.artifactName)}`) + } + built.add(`${entry.arch}:${entry.artifactName}`) + } + return built +} + +async function previousWindowsNativeManifest(windowsDir) { + try { + return JSON.parse(await fs.readFile(path.join(windowsDir, 'manifest.json'), 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +export async function updateWindowsNativeManifest(coreDir, windowsDir, builtEntries) { + const built = builtEntrySet(builtEntries) + const sourceHashes = await artifactSourceHashes(coreDir) + const previous = await previousWindowsNativeManifest(windowsDir) const artifacts = {} for (const arch of WINDOWS_NATIVE_ARCHES) { - const file = `${arch}/${WINDOWS_SUPERVISOR_FILE}` - const bytes = await fs.readFile(path.join(windowsDir, file)) - verifyPeArchitecture(bytes, arch) - artifacts[arch] = { - file, - sha256: createHash('sha256').update(bytes).digest('hex'), + artifacts[arch] = {} + for (const [artifactName, definition] of Object.entries(WINDOWS_NATIVE_ARTIFACTS)) { + const file = `${arch}/${definition.file}` + const bytes = await fs.readFile(path.join(windowsDir, file)) + verifyPeArchitecture(bytes, arch, artifactName) + const sha256 = createHash('sha256').update(bytes).digest('hex') + let sourceSha256 + if (built.has(`${arch}:${artifactName}`)) { + sourceSha256 = sourceHashes[artifactName] + } else { + const previousEntry = previous?.artifacts?.[arch]?.[artifactName] + if ( + previousEntry?.file !== file || + previousEntry.protocolVersion !== definition.protocolVersion || + previousEntry.sha256 !== sha256 || + !/^[a-f0-9]{64}$/.test(previousEntry.sourceSha256 ?? '') + ) { + throw new Error(`Cannot preserve provenance for unbuilt Windows ${arch} ${artifactName}`) + } + sourceSha256 = previousEntry.sourceSha256 + } + artifacts[arch][artifactName] = { + file, + protocolVersion: definition.protocolVersion, + sha256, + sourceSha256, + } } } const manifest = { - protocolVersion: WINDOWS_NATIVE_PROTOCOL_VERSION, - sourceSha256: await nativeSourceSha256(coreDir), + manifestVersion: WINDOWS_NATIVE_MANIFEST_VERSION, artifacts, } await fs.mkdir(windowsDir, { recursive: true }) - await fs.writeFile(path.join(windowsDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + const manifestPath = path.join(windowsDir, 'manifest.json') + const temporaryPath = `${manifestPath}.${process.pid}.tmp` + await fs.writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + await fs.rename(temporaryPath, manifestPath) return manifest } export async function verifyWindowsNativeArtifacts(coreDir, windowsDir) { const manifestPath = path.join(windowsDir, 'manifest.json') const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) - if (manifest.protocolVersion !== WINDOWS_NATIVE_PROTOCOL_VERSION) { + if (manifest.manifestVersion !== WINDOWS_NATIVE_MANIFEST_VERSION) { throw new Error( - `Windows native protocol mismatch: expected ${WINDOWS_NATIVE_PROTOCOL_VERSION}, received ${String(manifest.protocolVersion)}`, + `Windows native manifest mismatch: expected ${WINDOWS_NATIVE_MANIFEST_VERSION}, received ${String(manifest.manifestVersion)}`, ) } - const currentSourceHash = await nativeSourceSha256(coreDir) - if (manifest.sourceSha256 !== currentSourceHash) { - throw new Error('Windows prebuilt helper is stale; a maintainer must rebuild both architectures') - } + const sourceHashes = await artifactSourceHashes(coreDir) for (const arch of WINDOWS_NATIVE_ARCHES) { - const artifact = manifest.artifacts?.[arch] - const expectedFile = `${arch}/${WINDOWS_SUPERVISOR_FILE}` - if (artifact?.file !== expectedFile || !/^[a-f0-9]{64}$/.test(artifact.sha256 ?? '')) { - throw new Error(`Windows prebuilt manifest has an invalid ${arch} artifact`) - } - const filePath = path.resolve(windowsDir, artifact.file) - const relative = path.relative(windowsDir, filePath) - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error(`Windows prebuilt manifest ${arch} artifact escapes its directory`) + for (const [artifactName, definition] of Object.entries(WINDOWS_NATIVE_ARTIFACTS)) { + const artifact = manifest.artifacts?.[arch]?.[artifactName] + const expectedFile = `${arch}/${definition.file}` + if ( + artifact?.file !== expectedFile || + artifact.protocolVersion !== definition.protocolVersion || + !/^[a-f0-9]{64}$/.test(artifact.sha256 ?? '') || + !/^[a-f0-9]{64}$/.test(artifact.sourceSha256 ?? '') + ) { + throw new Error(`Windows prebuilt manifest has an invalid ${arch} ${artifactName} artifact`) + } + if (artifact.sourceSha256 !== sourceHashes[artifactName]) { + throw new Error(`Windows prebuilt ${artifactName} is stale; a maintainer must rebuild both architectures`) + } + const filePath = path.resolve(windowsDir, artifact.file) + const relative = path.relative(windowsDir, filePath) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Windows prebuilt manifest ${arch} ${artifactName} artifact escapes its directory`) + } + const bytes = await fs.readFile(filePath) + verifyPeArchitecture(bytes, arch, artifactName) + const actualHash = createHash('sha256').update(bytes).digest('hex') + if (actualHash !== artifact.sha256) { + throw new Error(`Windows prebuilt ${artifactName} hash mismatch for ${arch}`) + } } - const bytes = await fs.readFile(filePath) - verifyPeArchitecture(bytes, arch) - const actualHash = createHash('sha256').update(bytes).digest('hex') - if (actualHash !== artifact.sha256) throw new Error(`Windows prebuilt helper hash mismatch for ${arch}`) } return manifest } diff --git a/packages/core/scripts/write-native-manifest.mjs b/packages/core/scripts/write-native-manifest.mjs index b918e3b..d4392b2 100644 --- a/packages/core/scripts/write-native-manifest.mjs +++ b/packages/core/scripts/write-native-manifest.mjs @@ -1,7 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' -import { writeWindowsNativeManifest } from './native-artifacts.mjs' +import { WINDOWS_NATIVE_ARCHES, WINDOWS_NATIVE_ARTIFACTS, updateWindowsNativeManifest } from './native-artifacts.mjs' const coreDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const windowsDir = process.argv[2] @@ -12,4 +12,20 @@ if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('Native manifest target must stay inside the Core package') } -await writeWindowsNativeManifest(coreDir, windowsDir) +const buildEntries = [] +if (process.argv.includes('--all-current')) { + for (const arch of WINDOWS_NATIVE_ARCHES) { + for (const artifactName of Object.keys(WINDOWS_NATIVE_ARTIFACTS)) buildEntries.push({ arch, artifactName }) + } +} else { + for (let index = 3; index < process.argv.length; index++) { + if (process.argv[index] !== '--built' || !process.argv[index + 1]) { + throw new Error('Use --built : for each rebuilt binary, or --all-current after rebuilding all') + } + const [arch, artifactName, extra] = process.argv[++index].split(':') + if (extra !== undefined) throw new Error('Windows native build provenance must use :') + buildEntries.push({ arch, artifactName }) + } +} + +await updateWindowsNativeManifest(coreDir, windowsDir, buildEntries) diff --git a/packages/core/src/agent/pdf-ingest.ts b/packages/core/src/agent/pdf-ingest.ts index 8c46fec..4550336 100644 --- a/packages/core/src/agent/pdf-ingest.ts +++ b/packages/core/src/agent/pdf-ingest.ts @@ -1,4 +1,7 @@ +import { fork } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { Worker } from 'node:worker_threads' import { isModelAcceptedImageMime, normalizeImageMime } from '../providers/capabilities.js' @@ -23,6 +26,7 @@ export const PDF_ANALYSIS_PAGE_LIMIT = 200 export const PDF_MAX_DECLARED_PAGES = 2_000 const PDF_PROCESS_TIMEOUT_MS = 120_000 const PDF_WORKER_OPERATION_TIMEOUT_MS = 30_000 +const PDF_WORKER_SHUTDOWN_TIMEOUT_MS = 5_000 export type PdfMode = 'auto' | 'text-only' | 'visual' export type PdfPageKind = 'text' | 'visual' | 'both' @@ -111,26 +115,114 @@ function pdfWorkerUrl(): URL { return new URL('./pdf-render-worker.js', current) } +type PdfWorkerMessageListener = (response: PdfRenderResponse) => void +type PdfWorkerErrorListener = (error: Error) => void +type PdfWorkerExitListener = (code: number) => void + +class PdfWorkerEndpoint { + private readonly child?: ChildProcess + private readonly thread?: Worker + + constructor() { + if (process.platform === 'win32') { + // @napi-rs/canvas can fault the host when loaded in a Windows Worker thread; a process contains native faults. + this.child = fork(fileURLToPath(pdfWorkerUrl()), [], { + execArgv: ['--max-old-space-size=512'], + serialization: 'advanced', + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }) + return + } + this.thread = new Worker(pdfWorkerUrl(), { + execArgv: [], + resourceLimits: { maxOldGenerationSizeMb: 512, stackSizeMb: 8 }, + }) + } + + onMessage(listener: PdfWorkerMessageListener): void { + if (this.thread) this.thread.on('message', listener) + else this.child!.on('message', (message) => listener(message as PdfRenderResponse)) + } + + onError(listener: PdfWorkerErrorListener): void { + if (this.thread) this.thread.on('error', listener) + else this.child!.on('error', listener) + } + + onExit(listener: PdfWorkerExitListener): void { + if (this.thread) this.thread.on('exit', listener) + else this.child!.on('exit', (code) => listener(code ?? 1)) + } + + onceExit(listener: PdfWorkerExitListener): () => void { + if (this.thread) { + this.thread.once('exit', listener) + return () => this.thread!.off('exit', listener) + } + const childListener = (code: number | null): void => listener(code ?? 1) + this.child!.once('exit', childListener) + return () => this.child!.off('exit', childListener) + } + + postMessage(request: PdfRenderRequest, transfer: ArrayBuffer[]): void { + if (this.thread) { + this.thread.postMessage(request, transfer) + return + } + if (!this.child!.connected) throw new Error('PDF render process IPC channel is closed') + this.child!.send(request) + } + + async terminate(): Promise { + if (this.thread) { + await this.thread.terminate().catch(() => {}) + return + } + const child = this.child! + if (child.exitCode !== null || child.signalCode !== null) return + const exited = this.waitForChildExit(1_000) + child.kill() + if (await exited) return + const killed = this.waitForChildExit(1_000) + child.kill('SIGKILL') + await killed + } + + private waitForChildExit(timeoutMs: number): Promise { + const child = this.child! + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) + return new Promise((resolve) => { + const onExit = (): void => finish(true) + const finish = (exited: boolean): void => { + clearTimeout(timer) + child.off('exit', onExit) + resolve(exited) + } + const timer = setTimeout(() => finish(false), timeoutMs) + child.once('exit', onExit) + }) + } +} + class PdfWorkerClient { - private readonly worker: Worker + private readonly worker: PdfWorkerEndpoint private readonly pending = new Map() private readonly abortSignal?: AbortSignal private nextId = 1 + private closing = false private terminated = false + private terminationPromise?: Promise constructor(abortSignal?: AbortSignal) { this.abortSignal = abortSignal - this.worker = new Worker(pdfWorkerUrl(), { - execArgv: [], - resourceLimits: { maxOldGenerationSizeMb: 512, stackSizeMb: 8 }, - }) - this.worker.on('message', (response: PdfRenderResponse) => this.handleResponse(response)) - this.worker.on('error', (error) => { + this.worker = new PdfWorkerEndpoint() + this.worker.onMessage((response) => this.handleResponse(response)) + this.worker.onError((error) => { this.failAll(new PdfWorkerFailure(`PDF render worker failed: ${error.message}`)) void this.terminate() }) - this.worker.on('exit', (code) => { - const expected = this.terminated + this.worker.onExit((code) => { + const expected = (this.closing || this.terminated) && this.pending.size === 0 this.terminated = true if (!expected) this.failAll(new PdfWorkerFailure(`PDF render worker exited unexpectedly with code ${code}`)) }) @@ -161,17 +253,21 @@ class PdfWorkerClient { this.pending.clear() } - private request(request: PdfRenderRequest, transfer: ArrayBuffer[] = []): Promise { + private request( + request: PdfRenderRequest, + transfer: ArrayBuffer[] = [], + timeoutMs = PDF_WORKER_OPERATION_TIMEOUT_MS, + ): Promise { this.abortSignal?.throwIfAborted() if (this.terminated) return Promise.reject(new PdfWorkerFailure('PDF render worker is terminated')) return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(request.id) - const error = new PdfWorkerFailure(`PDF worker operation timed out after ${PDF_WORKER_OPERATION_TIMEOUT_MS} ms`) + const error = new PdfWorkerFailure(`PDF worker operation timed out after ${timeoutMs} ms`) reject(error) this.failAll(error) void this.terminate() - }, PDF_WORKER_OPERATION_TIMEOUT_MS) + }, timeoutMs) this.pending.set(request.id, { resolve, reject, timer }) try { this.worker.postMessage(request, transfer) @@ -209,20 +305,56 @@ class PdfWorkerClient { } async dispose(): Promise { - this.abortSignal?.removeEventListener('abort', this.handleAbort) - if (this.terminated) return try { - await this.request({ id: this.nextId++, type: 'destroy' }) - } catch { - // worker termination below is the final cleanup path + if (this.terminationPromise) { + await this.terminationPromise + return + } + if (this.terminated) return + try { + await this.request({ id: this.nextId++, type: 'destroy' }, [], PDF_WORKER_SHUTDOWN_TIMEOUT_MS) + } catch { + await this.terminate() + return + } + this.closing = true + // Immediate worker.terminate() can race @napi-rs/canvas finalizers and crash the host process on Windows. + if (await this.waitForExit(PDF_WORKER_SHUTDOWN_TIMEOUT_MS)) { + if (this.terminationPromise) await this.terminationPromise + return + } + await this.terminate() + } finally { + this.abortSignal?.removeEventListener('abort', this.handleAbort) } - await this.terminate() } - private async terminate(): Promise { - if (this.terminated) return + private waitForExit(timeoutMs: number): Promise { + if (this.terminated) return Promise.resolve(true) + return new Promise((resolve) => { + let settled = false + let removeExitListener = (): void => {} + const finish = (exited: boolean): void => { + if (settled) return + settled = true + clearTimeout(timer) + removeExitListener() + resolve(exited) + } + const onExit = (): void => finish(true) + const timer = setTimeout(() => finish(false), timeoutMs) + removeExitListener = this.worker.onceExit(onExit) + if (this.terminated) finish(true) + }) + } + + private terminate(): Promise { + if (this.terminationPromise) return this.terminationPromise + if (this.terminated) return Promise.resolve() + this.closing = true this.terminated = true - await this.worker.terminate().catch(() => {}) + this.terminationPromise = this.worker.terminate().catch(() => {}) + return this.terminationPromise } } diff --git a/packages/core/src/agent/pdf-render-worker.ts b/packages/core/src/agent/pdf-render-worker.ts index c9f85fe..ac3500b 100644 --- a/packages/core/src/agent/pdf-render-worker.ts +++ b/packages/core/src/agent/pdf-render-worker.ts @@ -4,8 +4,7 @@ import { parentPort } from 'node:worker_threads' import type { PdfRenderRequest, PdfRenderResponse } from './pdf-render-protocol.js' -if (!parentPort) throw new Error('PDF render worker requires a parent port') -const port = parentPort +if (!parentPort && !process.send) throw new Error('PDF render worker requires a parent channel') let parser: PDFParse | null = null let queue: Promise = Promise.resolve() @@ -68,13 +67,38 @@ async function handleRequest(request: PdfRenderRequest): Promise { +function send(response: PdfRenderResponse): Promise { + if (parentPort) { + if (response.ok && response.result.type === 'render') parentPort.postMessage(response, [response.result.data]) + else parentPort.postMessage(response) + return Promise.resolve(true) + } + if (!process.send || !process.connected) return Promise.resolve(false) + return new Promise((resolve) => { + try { + process.send!(response, (error) => resolve(!error)) + } catch { + resolve(false) + } + }) +} + +function closeChannel(): void { + if (parentPort) parentPort.close() + else if (process.connected) process.disconnect() +} + +function onRequest(request: PdfRenderRequest): void { queue = queue.then(async () => { const response = await handleRequest(request) - if (response.ok && response.result.type === 'render') { - port.postMessage(response, [response.result.data]) - } else { - port.postMessage(response) - } + const shouldClose = response.ok && response.result.type === 'destroy' + const sent = await send(response) + if (shouldClose || !sent) closeChannel() }) -}) +} + +if (parentPort) parentPort.on('message', onRequest) +else { + process.once('disconnect', () => process.exit(0)) + process.on('message', (request) => onRequest(request as PdfRenderRequest)) +} diff --git a/packages/core/src/native/windows-native-artifact.ts b/packages/core/src/native/windows-native-artifact.ts new file mode 100644 index 0000000..ab7fb03 --- /dev/null +++ b/packages/core/src/native/windows-native-artifact.ts @@ -0,0 +1,130 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' + +export const WINDOWS_NATIVE_MANIFEST_VERSION = 2 + +const WINDOWS_PE_MACHINES: Record<'x64' | 'arm64', number> = { x64: 0x8664, arm64: 0xaa64 } + +interface WindowsNativeManifestEntry { + file: string + protocolVersion: number + sha256: string + sourceSha256: string +} + +interface WindowsNativeManifest { + manifestVersion: number + artifacts: Record | undefined> +} + +export interface WindowsNativeArtifact { + executablePath: string + sha256: string + protocolVersion: number +} + +type WindowsNativeArtifactFailure = 'unsupported-arch' | 'missing' | 'protocol-mismatch' | 'integrity-mismatch' + +export interface WindowsNativeArtifactSpec { + artifactName: string + executableName: string + displayName: string + protocolVersion: number + createError?: (failure: WindowsNativeArtifactFailure, message: string, cause?: unknown) => Error +} + +function failure( + spec: WindowsNativeArtifactSpec, + kind: WindowsNativeArtifactFailure, + message: string, + cause?: unknown, +): never { + if (spec.createError) throw spec.createError(kind, message, cause) + throw new Error(message, cause === undefined ? undefined : { cause }) +} + +function verifyPeArchitecture(bytes: Buffer, arch: 'x64' | 'arm64', spec: WindowsNativeArtifactSpec): void { + if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) { + failure(spec, 'integrity-mismatch', `${spec.displayName} is not a PE executable for ${arch}`) + } + const peOffset = bytes.readUInt32LE(0x3c) + if ( + peOffset + 6 > bytes.length || + bytes.readUInt32LE(peOffset) !== 0x00004550 || + bytes.readUInt16LE(peOffset + 4) !== WINDOWS_PE_MACHINES[arch] + ) { + failure(spec, 'integrity-mismatch', `${spec.displayName} PE architecture mismatch for ${arch}`) + } +} + +export function resolveWindowsNativeRoot(modulePath: string): string { + let directory = path.dirname(path.resolve(modulePath)) + for (let depth = 0; depth < 8; depth++) { + const name = path.basename(directory) + if (name === 'dist') return path.join(directory, 'native', 'windows') + if (name === 'src') return path.join(path.dirname(directory), 'dist', 'native', 'windows') + const parent = path.dirname(directory) + if (parent === directory) break + directory = parent + } + throw new Error(`Windows native helper has an unsupported package layout: ${modulePath}`) +} + +export async function resolveWindowsNativeArtifact(options: { + arch?: NodeJS.Architecture + nativeRoot: string + spec: WindowsNativeArtifactSpec +}): Promise { + const arch = options.arch ?? process.arch + const { nativeRoot, spec } = options + if (arch !== 'x64' && arch !== 'arm64') { + failure(spec, 'unsupported-arch', `${spec.displayName} does not support architecture ${arch}`) + } + let manifest: WindowsNativeManifest + try { + manifest = JSON.parse(await fs.readFile(path.join(nativeRoot, 'manifest.json'), 'utf8')) as WindowsNativeManifest + } catch (error) { + failure(spec, 'missing', `${spec.displayName} is missing for ${arch}; reinstall x-code-cli`, error) + } + if (manifest.manifestVersion !== WINDOWS_NATIVE_MANIFEST_VERSION) { + failure( + spec, + 'protocol-mismatch', + `Windows native manifest mismatch: expected ${WINDOWS_NATIVE_MANIFEST_VERSION}, received ${manifest.manifestVersion}`, + ) + } + const artifact = manifest.artifacts?.[arch]?.[spec.artifactName] + if (!artifact) failure(spec, 'missing', `${spec.displayName} manifest has no ${arch} artifact`) + if (artifact.protocolVersion !== spec.protocolVersion) { + failure( + spec, + 'protocol-mismatch', + `${spec.displayName} protocol mismatch: expected ${spec.protocolVersion}, received ${artifact.protocolVersion}`, + ) + } + if (!/^[a-f0-9]{64}$/.test(artifact.sha256) || !/^[a-f0-9]{64}$/.test(artifact.sourceSha256)) { + failure(spec, 'integrity-mismatch', `${spec.displayName} manifest hash is invalid`) + } + const expectedFile = `${arch}/${spec.executableName}` + if (artifact.file !== expectedFile) { + failure(spec, 'integrity-mismatch', `${spec.displayName} manifest has an invalid ${arch} path`) + } + const executablePath = path.resolve(nativeRoot, artifact.file) + const relative = path.relative(nativeRoot, executablePath) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + failure(spec, 'integrity-mismatch', `${spec.displayName} path escapes its native directory`) + } + let bytes: Buffer + try { + bytes = await fs.readFile(executablePath) + } catch (error) { + failure(spec, 'missing', `${spec.displayName} is missing for ${arch}; reinstall x-code-cli`, error) + } + verifyPeArchitecture(bytes, arch, spec) + const actualHash = createHash('sha256').update(bytes).digest('hex') + if (actualHash !== artifact.sha256) { + failure(spec, 'integrity-mismatch', `${spec.displayName} hash mismatch for ${arch}`) + } + return { executablePath, sha256: actualHash, protocolVersion: artifact.protocolVersion } +} diff --git a/packages/core/src/peers/platform-transport.ts b/packages/core/src/peers/platform-transport.ts new file mode 100644 index 0000000..eed09bb --- /dev/null +++ b/packages/core/src/peers/platform-transport.ts @@ -0,0 +1,37 @@ +import type { PeerTransport } from './transport.js' +import { createUnixSocketTransport } from './unix-socket-transport.js' +import { createWindowsNamedPipeTransport } from './windows-named-pipe-transport.js' + +export interface PlatformPeerTransportOptions { + getRuntimePaths: () => { socketDir: string; namespaceId?: string } + platform?: NodeJS.Platform +} + +function createUnsupportedTransport(): PeerTransport { + const unsupported = (): never => { + throw Object.assign(new Error('Peer messaging is not supported on this platform.'), { + name: 'PEER_UNSUPPORTED_PLATFORM', + }) + } + return { + kind: 'unix', + validateAddress: () => false, + listen: async () => unsupported(), + request: async () => unsupported(), + } +} + +export function createPlatformPeerTransport(options: PlatformPeerTransportOptions): PeerTransport { + const platform = options.platform ?? process.platform + if (platform === 'darwin' || platform === 'linux') { + return createUnixSocketTransport({ + getSocketDir: () => options.getRuntimePaths().socketDir, + }) + } + if (platform === 'win32') { + return createWindowsNamedPipeTransport({ + getRuntimePaths: options.getRuntimePaths, + }) + } + return createUnsupportedTransport() +} diff --git a/packages/core/src/peers/registry.ts b/packages/core/src/peers/registry.ts index 27c3763..1e5936a 100644 --- a/packages/core/src/peers/registry.ts +++ b/packages/core/src/peers/registry.ts @@ -15,6 +15,11 @@ import { type PeerRegistrationV1, type RegistrationCandidate, } from './types.js' +import { + type WindowsPeerRuntimeSecurityProvider, + createWindowsPeerRuntimeSecurity, +} from './windows-peer-runtime-security.js' +import { isValidWindowsPeerPipeAddress } from './windows-pipe-address.js' function exactKeys(record: Record, allowed: readonly string[]): boolean { const allowedSet = new Set(allowed) @@ -25,7 +30,10 @@ function validIsoDate(value: unknown): value is string { return typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value)) } -function parseRegistration(value: unknown, socketDir: string): PeerRegistrationV1 | null { +export function parseRegistration( + value: unknown, + runtime: { socketDir: string; namespaceId?: string }, +): PeerRegistrationV1 | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null const source = value as Record if ( @@ -82,14 +90,18 @@ function parseRegistration(value: unknown, socketDir: string): PeerRegistrationV } if (!record.transport || typeof record.transport !== 'object' || Array.isArray(record.transport)) return null const transport = record.transport as Record - if ( - !exactKeys(transport, ['kind', 'address']) || - transport.kind !== 'unix' || - typeof transport.address !== 'string' || - !isSocketPathInNamespace(transport.address, socketDir) || - (path.basename(transport.address) !== `${record.instanceId.slice(0, 8)}.sock` && - !/^p-[A-Za-z0-9_-]{16}\.sock$/.test(path.basename(transport.address))) - ) { + if (!exactKeys(transport, ['kind', 'address']) || typeof transport.address !== 'string') return null + if (transport.kind === 'unix') { + if ( + !isSocketPathInNamespace(transport.address, runtime.socketDir) || + (path.basename(transport.address) !== `${record.instanceId.slice(0, 8)}.sock` && + !/^p-[A-Za-z0-9_-]{16}\.sock$/.test(path.basename(transport.address))) + ) { + return null + } + } else if (transport.kind === 'windows-pipe') { + if (!isValidWindowsPeerPipeAddress(transport.address, runtime.namespaceId)) return null + } else { return null } return structuredClone(record) as unknown as PeerRegistrationV1 @@ -108,7 +120,7 @@ async function validateOpenRegistration(handle: fs.FileHandle): Promise<{ size: async function readCandidateFile( registrationPath: string, expectedInstanceId: string, - socketDir: string, + runtime: { socketDir: string; namespaceId?: string }, ): Promise { let handle: fs.FileHandle | undefined try { @@ -118,7 +130,7 @@ async function readCandidateFile( if (!safe) return null const raw = await handle.readFile({ encoding: 'utf8' }) if (Buffer.byteLength(raw, 'utf8') > MAX_REGISTRATION_BYTES) return null - const registration = parseRegistration(JSON.parse(raw), socketDir) + const registration = parseRegistration(JSON.parse(raw), runtime) if (!registration || registration.instanceId !== expectedInstanceId) return null return { registration, registrationPath, mtimeMs: safe.mtimeMs } } catch { @@ -139,15 +151,8 @@ async function pidExists(pid: number): Promise { } } -function sameFileIdentity( - left: { dev: number | bigint; ino: number | bigint }, - right: { dev: number | bigint; ino: number | bigint }, -): boolean { - return left.dev === right.dev && left.ino === right.ino -} - export interface PeerRegistry { - initialize(): Promise + initialize(signal?: AbortSignal): Promise write(registration: PeerRegistrationV1): Promise read(instanceId: string): Promise listCandidates(): Promise @@ -159,18 +164,43 @@ export interface PeerRegistry { deadlineMs?: number }): Promise<{ peers: PublicPeer[]; registrations: RegistrationCandidate[]; partial: boolean }> removeOwn(instanceId: string): Promise - cleanupConfirmedDead(candidate: RegistrationCandidate, graceMs?: number): Promise - paths(): { registryDir: string; socketDir: string } + cleanupConfirmedDead(candidate: RegistrationCandidate, graceMs?: number, transport?: PeerTransport): Promise + paths(): { registryDir: string; socketDir: string; namespaceId?: string } +} + +export interface PeerRegistryOptions { + platform?: NodeJS.Platform + transportKind?: PeerTransport['kind'] + windowsRuntimeSecurity?: WindowsPeerRuntimeSecurityProvider } -export function createPeerRegistry(): PeerRegistry { +export function createPeerRegistry(options: PeerRegistryOptions = {}): PeerRegistry { + const platform = options.platform ?? process.platform + const windowsRuntimeSecurity = + options.windowsRuntimeSecurity ?? (platform === 'win32' ? createWindowsPeerRuntimeSecurity() : undefined) + const transportKind = options.transportKind ?? (platform === 'win32' ? 'windows-pipe' : 'unix') let registryDir = '' let socketDir = '' + let namespaceId: string | undefined + let initializePromise: Promise | undefined - const initialize = async (): Promise => { - const paths = await ensurePeerRuntimeDirectories() - registryDir = paths.registryDir - socketDir = paths.socketDir + const initialize = async (signal?: AbortSignal): Promise => { + if (registryDir && socketDir) return + if (initializePromise) return initializePromise + const operation = (async () => { + const paths = windowsRuntimeSecurity + ? await windowsRuntimeSecurity.initialize(signal) + : await ensurePeerRuntimeDirectories() + registryDir = paths.registryDir + socketDir = paths.socketDir + namespaceId = (paths as { namespaceId?: string }).namespaceId + })() + initializePromise = operation + try { + await operation + } finally { + if (initializePromise === operation) initializePromise = undefined + } } const ensureInitialized = async (): Promise => { @@ -180,7 +210,11 @@ export function createPeerRegistry(): PeerRegistry { const read = async (instanceId: string): Promise => { if (!isUuid(instanceId)) return null await ensureInitialized() - return readCandidateFile(path.join(registryDir, `${instanceId}.json`), instanceId, socketDir) + const candidate = await readCandidateFile(path.join(registryDir, `${instanceId}.json`), instanceId, { + socketDir, + namespaceId, + }) + return candidate?.registration.transport.kind === transportKind ? candidate : null } return { @@ -188,8 +222,8 @@ export function createPeerRegistry(): PeerRegistry { async write(registration) { await ensureInitialized() - const validated = parseRegistration(registration, socketDir) - if (!validated) throw new Error('Invalid peer registration') + const validated = parseRegistration(registration, { socketDir, namespaceId }) + if (!validated || validated.transport.kind !== transportKind) throw new Error('Invalid peer registration') const finalPath = path.join(registryDir, `${validated.instanceId}.json`) const tempPath = path.join(registryDir, `.${validated.instanceId}.${randomUUID()}.tmp`) const bytes = JSON.stringify(validated) + '\n' @@ -235,8 +269,8 @@ export function createPeerRegistry(): PeerRegistry { rejected++ continue } - const candidate = await readCandidateFile(path.join(registryDir, name), instanceId, socketDir) - if (candidate) candidates.push(candidate) + const candidate = await readCandidateFile(path.join(registryDir, name), instanceId, { socketDir, namespaceId }) + if (candidate?.registration.transport.kind === transportKind) candidates.push(candidate) else rejected++ } return { candidates, scanned: selected.length, rejected, truncated: names.length > selected.length } @@ -244,8 +278,12 @@ export function createPeerRegistry(): PeerRegistry { async listLive(options) { const scan = await this.listCandidates() + const activeTransportKind = options.transport.kind ?? transportKind const candidates = scan.candidates.filter( - (candidate) => candidate.registration.instanceId !== options.senderInstanceId, + (candidate) => + candidate.registration.instanceId !== options.senderInstanceId && + candidate.registration.transport.kind === activeTransportKind && + (options.transport.validateAddress?.(candidate.registration.transport.address) ?? true), ) const registrations: RegistrationCandidate[] = [] const concurrency = Math.min(16, Math.max(8, options.concurrency ?? 12)) @@ -271,7 +309,7 @@ export function createPeerRegistry(): PeerRegistry { if (!candidate) return const livePid = await pidExists(candidate.registration.pid) if (livePid === false) { - await this.cleanupConfirmedDead(candidate).catch(() => false) + await this.cleanupConfirmedDead(candidate, undefined, options.transport).catch(() => false) continue } const requestId = randomUUID() @@ -294,6 +332,7 @@ export function createPeerRegistry(): PeerRegistry { } catch { // A live PID may have a blocked event loop. Ping failure hides it // from this live view but never authorizes registration/socket deletion. + partial = true } } } @@ -326,7 +365,7 @@ export function createPeerRegistry(): PeerRegistry { return true }, - async cleanupConfirmedDead(candidate, graceMs = 30_000) { + async cleanupConfirmedDead(candidate, graceMs = 30_000, transport) { await ensureInitialized() const registration = candidate.registration if ((await pidExists(registration.pid)) !== false) return false @@ -341,29 +380,31 @@ export function createPeerRegistry(): PeerRegistry { ) { return false } - const socketPath = registration.transport.address - const socketBefore = isSocketPathInNamespace(socketPath, socketDir) - ? await fs.lstat(socketPath).catch(() => null) - : null + const address = registration.transport.address + const remainingBeforeRemoval = await this.listCandidates() + const shared = remainingBeforeRemoval.candidates.some( + (other) => + other.registration.instanceId !== registration.instanceId && other.registration.transport.address === address, + ) await fs.unlink(current.registrationPath).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error }) - if (socketBefore?.isSocket() && !socketBefore.isSymbolicLink()) { - const remaining = await this.listCandidates() - const shared = remaining.candidates.some((other) => other.registration.transport.address === socketPath) - if (!remaining.truncated && remaining.rejected === 0 && !shared) { - const socketAfter = await fs.lstat(socketPath).catch(() => null) - if (socketAfter?.isSocket() && !socketAfter.isSymbolicLink() && sameFileIdentity(socketBefore, socketAfter)) { - await fs.unlink(socketPath).catch(() => {}) - } - } + if ( + transport && + (transport.kind ?? transportKind) === registration.transport.kind && + transport.cleanupConfirmedDeadEndpoint && + !remainingBeforeRemoval.truncated && + remainingBeforeRemoval.rejected === 0 && + !shared + ) { + await transport.cleanupConfirmedDeadEndpoint(address) } return true }, paths() { if (!registryDir || !socketDir) throw new Error('Peer registry has not been initialized') - return { registryDir, socketDir } + return { registryDir, socketDir, ...(namespaceId ? { namespaceId } : {}) } }, } } diff --git a/packages/core/src/peers/service.ts b/packages/core/src/peers/service.ts index f95bfb6..f13fd17 100644 --- a/packages/core/src/peers/service.ts +++ b/packages/core/src/peers/service.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import fs from 'node:fs/promises' import { type PeerMessagingConfig, resolvePeerMessagingConfig } from '../config/index.js' import { sha256Text } from '../permissions/authority.js' @@ -20,6 +19,7 @@ import type { } from './inbox-types.js' import { createPeerInbox } from './inbox.js' import { peerSocketPath } from './paths.js' +import { createPlatformPeerTransport } from './platform-transport.js' import { MAX_MESSAGE_BYTES, encodePeerFrame } from './protocol.js' import type { PeerFrameV1 } from './protocol.js' import { type PeerRateLimiter, createPeerRateLimiter } from './rate-limit.js' @@ -27,7 +27,6 @@ import { type PeerRegistry, createPeerRegistry } from './registry.js' import { stripTerminalControls } from './terminal-sanitize.js' import type { PeerTransport, PeerTransportServer } from './transport.js' import type { PeerIdentity, PeerRegistrationV1, RegistrationCandidate } from './types.js' -import { createUnixSocketTransport } from './unix-socket-transport.js' export interface PreparedPeerSend { requestedTarget: string @@ -178,20 +177,24 @@ function sendPayloadHash(message: string, summary?: string): string { return sha256Text(JSON.stringify({ message, ...(summary ? { summary } : {}) })) } -async function lstatIfPresent(filePath: string): Promise> | null> { - try { - return await fs.lstat(filePath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null - throw error - } +function peerErrorCode(error: unknown, fallback = 'PEER_IO_ERROR'): string { + if (error instanceof Error && error.name.startsWith('PEER_')) return error.name + const message = errorMessage(error) + return message.startsWith('PEER_') && /^[A-Z0-9_]+$/.test(message) ? message : fallback } export function createPeerService(options: PeerServiceOptions = {}): PeerService { const config: PeerMessagingConfig = resolvePeerMessagingConfig(options.config) const enabled = options.enabled ?? false - const registry = options.registry ?? createPeerRegistry() - const transport = options.transport ?? createUnixSocketTransport() + const registry = + options.registry ?? createPeerRegistry(options.transport ? { transportKind: options.transport.kind ?? 'unix' } : {}) + const transport = + options.transport ?? + createPlatformPeerTransport({ + getRuntimePaths: () => registry.paths(), + }) + const transportKind = transport.kind ?? 'unix' + const isTransportAddressValid = (address: string): boolean => transport.validateAddress?.(address) ?? true const identity = enabled ? (options.identity ?? createPeerIdentity({ name: options.name, cwd: options.cwd, now: options.now })) : null @@ -236,7 +239,13 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService if (!identity || senderInstanceId === identity.instanceId) throw serviceError('PEER_SELF', 'Self-send is not allowed') const sender = await registry.read(senderInstanceId) - if (!sender) throw serviceError('PEER_AUTH_FAILED', 'Sender registration is missing or unsafe') + if ( + !sender || + sender.registration.transport.kind !== transportKind || + !isTransportAddressValid(sender.registration.transport.address) + ) { + throw serviceError('PEER_AUTH_FAILED', 'Sender registration is missing or unsafe') + } return sender } @@ -419,7 +428,13 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService try { const targetInstanceId = update.target.address.slice('peer:'.length) const candidate = await registry.read(targetInstanceId) - if (!candidate) return false + if ( + !candidate || + candidate.registration.transport.kind !== transportKind || + !isTransportAddressValid(candidate.registration.transport.address) + ) { + return false + } const requestId = randomUUID() const request = transport .request({ @@ -496,11 +511,6 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService await startPromise return } - if (process.platform === 'win32') { - unavailableReason = 'Peer messaging is not supported on Windows in this release.' - unavailableCode = 'PEER_UNSUPPORTED_PLATFORM' - return - } const generation = ++lifecycleGeneration const startIsCurrent = (): boolean => !shuttingDown && !signal?.aborted && lifecycleGeneration === generation const operation = (async (): Promise => { @@ -509,43 +519,19 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService if (signal?.aborted) throw signal.reason ?? serviceError('AbortError', 'Peer startup aborted') registrationWriteGeneration++ registrationWritesEnabled = true - await registry.initialize() - if (!startIsCurrent()) return - const socketPath = peerSocketPath(registry.paths().socketDir, identity.instanceId) - const stale = await lstatIfPresent(socketPath) + await registry.initialize(signal) if (!startIsCurrent()) return - if (stale) { - if (!stale.isSocket() || stale.isSymbolicLink()) { - throw serviceError('PEER_SOCKET_UNSAFE', 'Peer socket path is occupied by an unsafe file') - } - const scan = await registry.listCandidates() - if (!startIsCurrent()) return - if (scan.truncated) { - throw serviceError('PEER_SOCKET_IN_USE', 'Peer socket ownership scan was truncated') - } - const owners = scan.candidates.filter( - (candidate) => candidate.registration.transport.address === socketPath, - ) - if (owners.length === 0) { - throw serviceError('PEER_SOCKET_IN_USE', 'Peer socket owner cannot be proven dead') - } - for (const owner of owners) { - await registry.cleanupConfirmedDead(owner).catch(() => false) - if (!startIsCurrent()) return - } - if (await lstatIfPresent(socketPath)) { - throw serviceError('PEER_SOCKET_IN_USE', 'Peer socket is owned by another active or unverified session') - } - if (!startIsCurrent()) return - } localServer = await transport.listen({ - address: socketPath, + address: peerSocketPath(registry.paths().socketDir, identity.instanceId), instanceId: identity.instanceId, inboxToken: identity.inboxToken, onRequest, signal, }) if (!startIsCurrent()) return + if (!isTransportAddressValid(localServer.address)) { + throw serviceError('PEER_TRANSPORT_ADDRESS_INVALID', 'Peer transport returned an invalid address') + } server = localServer const timestamp = now().toISOString() registration = { @@ -555,7 +541,7 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService ...(options.sessionId ? { sessionId: options.sessionId } : {}), name: identity.name, cwd: options.cwd ?? process.cwd(), - transport: { kind: 'unix', address: localServer.address }, + transport: { kind: transportKind, address: localServer.address }, inboxToken: identity.inboxToken, permissionClass: options.getPermissionClass?.() ?? options.permissionClass ?? 'prompted', status: 'idle', @@ -573,6 +559,32 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService }, 15_000) heartbeat.unref() started = true + void localServer.closed?.then((result) => { + if (result.expected || shuttingDown || server !== localServer || !started) return + lifecycleGeneration++ + started = false + server = undefined + void localServer?.close({ deadlineMs: 250 }).catch(() => {}) + registrationWritesEnabled = false + registrationWriteGeneration++ + if (heartbeat) clearInterval(heartbeat) + heartbeat = undefined + unavailableCode = + transportKind === 'windows-pipe' + ? 'PEER_WINDOWS_BROKER_EXITED' + : peerErrorCode(result.reason, 'PEER_IO_ERROR') + unavailableReason = result.reason ?? 'Peer transport exited unexpectedly.' + void (async () => { + await registrationWriteTail.catch(() => {}) + if (identity) { + for (let attempt = 0; attempt < 4; attempt++) { + if (await registry.removeOwn(identity.instanceId).catch(() => false)) break + await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1))) + } + } + registration = undefined + })() + }) } catch (error) { if (startIsCurrent()) { registrationWritesEnabled = false @@ -580,7 +592,7 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService await registrationWriteTail.catch(() => {}) if (identity) await registry.removeOwn(identity.instanceId).catch(() => false) unavailableReason = errorMessage(error) - unavailableCode = 'PEER_IO_ERROR' + unavailableCode = peerErrorCode(error) debugLog('peer.start-failed', unavailableReason) } } finally { @@ -663,7 +675,12 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService throw serviceError(code, `Message ${messageId} is not eligible for retry`) } candidate = await registry.read(retry.record.receiverInstanceId) - if (!candidate || `peer:${candidate.registration.instanceId}` !== retry.record.receiverAddress) { + if ( + !candidate || + candidate.registration.transport.kind !== transportKind || + !isTransportAddressValid(candidate.registration.transport.address) || + `peer:${candidate.registration.instanceId}` !== retry.record.receiverAddress + ) { throw serviceError('PEER_STALE', 'The originally resolved receiver is no longer registered') } } else { @@ -720,6 +737,8 @@ export function createPeerService(options: PeerServiceOptions = {}): PeerService if ( !current || current.registration.instanceId !== prepared.candidate.registration.instanceId || + current.registration.transport.kind !== transportKind || + !isTransportAddressValid(current.registration.transport.address) || current.registration.transport.address !== prepared.candidate.registration.transport.address || current.registration.inboxToken !== prepared.candidate.registration.inboxToken ) { diff --git a/packages/core/src/peers/transport.ts b/packages/core/src/peers/transport.ts index feb4bd9..3224915 100644 --- a/packages/core/src/peers/transport.ts +++ b/packages/core/src/peers/transport.ts @@ -1,11 +1,15 @@ import type { PeerFrameV1 } from './protocol.js' +import type { PeerTransportDescriptor } from './types.js' export interface PeerTransportServer { address: string + closed?: Promise<{ expected: boolean; reason?: string }> close(options?: { deadlineMs?: number }): Promise } export interface PeerTransport { + readonly kind?: PeerTransportDescriptor['kind'] + validateAddress?(address: string): boolean listen(options: { address: string instanceId: string @@ -21,4 +25,5 @@ export interface PeerTransport { timeoutMs?: number signal?: AbortSignal }): Promise + cleanupConfirmedDeadEndpoint?(address: string): Promise } diff --git a/packages/core/src/peers/types.ts b/packages/core/src/peers/types.ts index dea8aab..93f1413 100644 --- a/packages/core/src/peers/types.ts +++ b/packages/core/src/peers/types.ts @@ -2,6 +2,8 @@ export const PEER_PROTOCOL_VERSION = 1 as const export const MAX_REGISTRATION_BYTES = 64 * 1024 export const MAX_REGISTRATION_CANDIDATES = 256 +export type PeerTransportDescriptor = { kind: 'unix'; address: string } | { kind: 'windows-pipe'; address: string } + export interface PeerRegistrationV1 { version: 1 instanceId: string @@ -9,7 +11,7 @@ export interface PeerRegistrationV1 { sessionId?: string name: string cwd: string - transport: { kind: 'unix'; address: string } + transport: PeerTransportDescriptor inboxToken: string permissionClass: 'prompted' | 'bypass' status: 'idle' | 'busy' | 'waiting' diff --git a/packages/core/src/peers/unix-socket-transport.ts b/packages/core/src/peers/unix-socket-transport.ts index f4ffb92..d32dd7a 100644 --- a/packages/core/src/peers/unix-socket-transport.ts +++ b/packages/core/src/peers/unix-socket-transport.ts @@ -39,6 +39,18 @@ interface UnixSocketFileSystem { unlink(filePath: string): Promise } +export interface UnixSocketTransportOptions { + fileSystem?: UnixSocketFileSystem + getSocketDir?: () => string +} + +function validUnixAddress(address: string, socketDir?: string): boolean { + if (!path.isAbsolute(address)) return false + if (socketDir && path.dirname(path.resolve(address)) !== path.resolve(socketDir)) return false + const basename = path.basename(address) + return /^p-[A-Za-z0-9_-]{16}\.sock$/.test(basename) || /^[0-9a-f]{8}\.sock$/i.test(basename) +} + async function lstatIfPresent( fileSystem: UnixSocketFileSystem, filePath: string, @@ -131,10 +143,13 @@ async function probeListener(options: { }) } -export function createUnixSocketTransport(dependencies: { fileSystem?: UnixSocketFileSystem } = {}): PeerTransport { +export function createUnixSocketTransport(dependencies: UnixSocketTransportOptions = {}): PeerTransport { const fileSystem = dependencies.fileSystem ?? fs + const validateAddress = (address: string): boolean => validUnixAddress(address, dependencies.getSocketDir?.()) if (process.platform === 'win32') { return { + kind: 'unix', + validateAddress, async listen() { throw new Error('PEER_UNSUPPORTED_PLATFORM') }, @@ -145,13 +160,28 @@ export function createUnixSocketTransport(dependencies: { fileSystem?: UnixSocke } return { + kind: 'unix', + validateAddress, + async listen(options): Promise { - const address = path.join(path.dirname(options.address), `p-${randomBytes(12).toString('base64url')}.sock`) + const socketDir = path.dirname(options.address) + const address = path.join(socketDir, `p-${randomBytes(12).toString('base64url')}.sock`) if (Buffer.byteLength(address, 'utf8') > 103) throw new Error('PEER_SOCKET_PATH_TOO_LONG') const ownershipProbeSenderId = randomUUID() const sockets = new Set() let ownedSocket: Awaited> | null = null let closePromise: Promise | null = null + let closeExpected = false + let closedSettled = false + let resolveClosed!: (result: { expected: boolean; reason?: string }) => void + const closed = new Promise<{ expected: boolean; reason?: string }>((resolve) => { + resolveClosed = resolve + }) + const settleClosed = (result: { expected: boolean; reason?: string }): void => { + if (closedSettled) return + closedSettled = true + resolveClosed(result) + } const server = net.createServer((socket) => { sockets.add(socket) const decoder = new NdjsonFrameDecoder() @@ -208,8 +238,12 @@ export function createUnixSocketTransport(dependencies: { fileSystem?: UnixSocke socket.once('error', () => {}) }) + server.on('error', (error) => settleClosed({ expected: closeExpected, reason: errorMessage(error) })) + server.once('close', () => settleClosed({ expected: closeExpected })) + const closeBoundServer = (deadlineMs = 500): Promise => { if (closePromise) return closePromise + closeExpected = true closePromise = (async () => { const expectedOwnedSocket = ownedSocket const displaced: Array<{ @@ -363,6 +397,7 @@ export function createUnixSocketTransport(dependencies: { fileSystem?: UnixSocke return { address, + closed, async close(closeOptions = {}) { await closeBoundServer(closeOptions.deadlineMs ?? 500) }, @@ -423,5 +458,15 @@ export function createUnixSocketTransport(dependencies: { fileSystem?: UnixSocke }) }) }, + + async cleanupConfirmedDeadEndpoint(address) { + if (!validateAddress(address)) return + const before = await lstatIfPresent(fileSystem, address) + if (!before?.isSocket() || before.isSymbolicLink()) return + const after = await lstatIfPresent(fileSystem, address) + if (after?.isSocket() && !after.isSymbolicLink() && sameFileIdentity(before, after)) { + await fileSystem.unlink(address).catch(() => {}) + } + }, } } diff --git a/packages/core/src/peers/windows-named-pipe-transport.ts b/packages/core/src/peers/windows-named-pipe-transport.ts new file mode 100644 index 0000000..0052d82 --- /dev/null +++ b/packages/core/src/peers/windows-named-pipe-transport.ts @@ -0,0 +1,589 @@ +import { spawn } from 'node:child_process' +import { setTimeout as delay } from 'node:timers/promises' + +import { errorMessage } from '../utils.js' +import { NdjsonFrameDecoder, encodePeerFrame } from './protocol.js' +import type { PeerFrameV1 } from './protocol.js' +import { stripTerminalControls } from './terminal-sanitize.js' +import type { PeerTransport, PeerTransportServer } from './transport.js' +import { type WindowsPeerBrokerArtifact, resolveWindowsPeerBrokerArtifact } from './windows-peer-broker-artifact.js' +import { type WindowsPeerBrokerProcess, spawnWindowsPeerBrokerProcess } from './windows-peer-broker-process.js' +import { + WINDOWS_PEER_BROKER_MAX_OPERATIONS, + type WindowsPeerBrokerFrame, + WindowsPeerBrokerFrameKind, + decodeInboundRequestPayload, + decodeOneStringPayload, + decodeOperationErrorPayload, + decodePeerFramePayload, + encodeOutboundRequestPayload, + encodePeerFramePayload, + encodeStartServerPayload, +} from './windows-peer-broker-protocol.js' +import { isValidWindowsPeerPipeAddress } from './windows-pipe-address.js' + +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000 +const STARTUP_TIMEOUT_MS = 5_000 +const INBOUND_CALLBACK_TIMEOUT_MS = 29_000 +const CAPACITY_RETRY_DELAY_MS = 10 +const CANCELED_OPERATION_TTL_MS = 125_000 +const MAX_CANCELED_OPERATIONS = WINDOWS_PEER_BROKER_MAX_OPERATIONS * 4 +const SHUTDOWN_OPERATION_ID = 0xffff_ffff + +interface RuntimePaths { + namespaceId?: string +} + +export interface WindowsNamedPipeTransportOptions { + getRuntimePaths: () => RuntimePaths + artifact?: WindowsPeerBrokerArtifact | Promise + spawnBroker?: typeof spawn + requestTimeoutMs?: number +} + +interface PendingOperation { + resolve: (frame: PeerFrameV1) => void + reject: (error: unknown) => void + timer: NodeJS.Timeout + signal?: AbortSignal + onAbort?: () => void +} + +interface StartupOperation { + operationId: number + resolve: (address: string) => void + reject: (error: unknown) => void +} + +function transportError(code: string, message: string, cause?: unknown): Error { + const error = new Error(stripTerminalControls(message), cause === undefined ? undefined : { cause }) + error.name = code + return error +} + +function abortError(): Error { + return Object.assign(new Error('Peer request was interrupted'), { name: 'AbortError' }) +} + +function parseBusinessFrame(bytes: Buffer): PeerFrameV1 { + const decoder = new NdjsonFrameDecoder() + const frames = decoder.push(bytes) + decoder.finish() + if (frames.length !== 1) { + throw transportError('PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', 'Windows peer broker returned multiple peer frames') + } + return frames[0]! +} + +class BrokerClient { + readonly closed: Promise<{ expected: boolean; reason?: string }> + private readonly brokerProcess: WindowsPeerBrokerProcess + private readonly pending = new Map() + private readonly canceled = new Map() + private readonly inbound = new Set() + private readonly onRequest: (frame: PeerFrameV1, senderInstanceId: string) => Promise + private readonly requestTimeoutMs: number + private readonly resolveClosed: (result: { expected: boolean; reason?: string }) => void + private nextOperationId = 1 + private startup?: StartupOperation + private expectedClose = false + private exited = false + private fatalReason?: string + private resolveShutdown?: () => void + private closePromise?: Promise + + constructor( + artifact: WindowsPeerBrokerArtifact, + options: { + spawnBroker: typeof spawn + onRequest: (frame: PeerFrameV1, senderInstanceId: string) => Promise + requestTimeoutMs: number + }, + ) { + this.onRequest = options.onRequest + this.requestTimeoutMs = options.requestTimeoutMs + let resolveClosed!: (result: { expected: boolean; reason?: string }) => void + this.closed = new Promise((resolve) => { + resolveClosed = resolve + }) + this.resolveClosed = resolveClosed + this.brokerProcess = spawnWindowsPeerBrokerProcess({ + artifact, + mode: 'broker', + spawnBroker: options.spawnBroker, + debugKey: 'peer.windows.broker-stderr', + onFrame: (frame) => this.handleFrame(frame), + onError: (error) => this.fail(error), + onClose: () => this.onExit(), + }) + } + + async startServer(input: { namespaceId: string; instanceId: string; inboxToken: string }): Promise { + if (this.startup) { + throw transportError('PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', 'Windows peer broker server is already starting') + } + const operationId = this.allocateOperationId() + return new Promise((resolve, reject) => { + this.startup = { operationId, resolve, reject } + void this.send({ + kind: WindowsPeerBrokerFrameKind.StartServer, + operationId, + payload: encodeStartServerPayload(input), + }).catch((error) => { + if (this.startup?.operationId === operationId) this.startup = undefined + reject(error) + }) + }) + } + + request(options: { + address: string + targetToken: string + senderInstanceId: string + frame: PeerFrameV1 + timeoutMs?: number + signal?: AbortSignal + }): Promise { + if (options.signal?.aborted) return Promise.reject(abortError()) + const timeoutMs = Math.min(120_000, Math.max(1, options.timeoutMs ?? this.requestTimeoutMs)) + return this.requestWithCapacityRetry(options, timeoutMs) + } + + private async requestWithCapacityRetry( + options: { + address: string + targetToken: string + senderInstanceId: string + frame: PeerFrameV1 + signal?: AbortSignal + }, + timeoutMs: number, + ): Promise { + const deadline = Date.now() + timeoutMs + while (true) { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + throw transportError('PEER_WINDOWS_REQUEST_TIMEOUT', 'Windows peer request timed out') + } + try { + return await this.requestOnce(options, remainingMs) + } catch (error) { + if (!(error instanceof Error) || error.name !== 'PEER_WINDOWS_OPERATION_CAPACITY') throw error + if (options.signal?.aborted) throw abortError() + const retryDelayMs = Math.min(CAPACITY_RETRY_DELAY_MS, deadline - Date.now()) + if (retryDelayMs <= 0) throw error + try { + await delay(retryDelayMs, undefined, { signal: options.signal, ref: false }) + } catch (delayError) { + if (options.signal?.aborted) throw abortError() + throw delayError + } + } + } + } + + private requestOnce( + options: { + address: string + targetToken: string + senderInstanceId: string + frame: PeerFrameV1 + signal?: AbortSignal + }, + timeoutMs: number, + ): Promise { + let payload: Buffer + try { + payload = encodeOutboundRequestPayload({ + address: options.address, + targetToken: options.targetToken, + senderInstanceId: options.senderInstanceId, + timeoutMs, + peerFrame: encodePeerFrame(options.frame), + }) + } catch (error) { + return Promise.reject(error) + } + if (this.pending.size >= WINDOWS_PEER_BROKER_MAX_OPERATIONS) { + return Promise.reject( + transportError('PEER_WINDOWS_OPERATION_CAPACITY', 'Windows peer broker operation capacity is exhausted'), + ) + } + const operationId = this.allocateOperationId() + return new Promise((resolve, reject) => { + const onAbort = () => this.cancelOperation(operationId, abortError()) + const timer = setTimeout( + () => + this.cancelOperation( + operationId, + transportError('PEER_WINDOWS_REQUEST_TIMEOUT', 'Windows peer request timed out'), + ), + timeoutMs, + ) + timer.unref() + this.pending.set(operationId, { + resolve, + reject, + timer, + signal: options.signal, + onAbort, + }) + options.signal?.addEventListener('abort', onAbort, { once: true }) + void this.send({ + kind: WindowsPeerBrokerFrameKind.OutboundRequest, + operationId, + payload, + }).catch((error) => this.finishPending(operationId, error)) + }) + } + + close(deadlineMs: number): Promise { + this.closePromise ??= this.closeOnce(deadlineMs) + return this.closePromise + } + + private async closeOnce(deadlineMs: number): Promise { + this.expectedClose = true + if (this.exited) return + const shutdown = new Promise((resolve) => { + this.resolveShutdown = resolve + }) + await this.send({ + kind: WindowsPeerBrokerFrameKind.Shutdown, + operationId: SHUTDOWN_OPERATION_ID, + payload: Buffer.alloc(0), + }).catch(() => {}) + let timer: NodeJS.Timeout | undefined + const acknowledged = await Promise.race([ + shutdown.then(() => true), + this.closed.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(0, deadlineMs)) + timer.unref() + }), + ]) + if (timer) clearTimeout(timer) + if (acknowledged) await this.waitForExit(250) + if (!this.exited) this.brokerProcess.kill() + await this.waitForExit(250) + } + + private async waitForExit(deadlineMs: number): Promise { + if (this.exited) return true + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + this.closed.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(0, deadlineMs)) + timer.unref() + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } + + private allocateOperationId(): number { + if (this.nextOperationId >= SHUTDOWN_OPERATION_ID) { + const failure = transportError( + 'PEER_WINDOWS_OPERATION_CAPACITY', + 'Windows peer broker operation IDs are exhausted', + ) + this.fail(failure) + throw failure + } + return this.nextOperationId++ + } + + private handleFrame(frame: WindowsPeerBrokerFrame): void { + switch (frame.kind) { + case WindowsPeerBrokerFrameKind.ServerReady: + this.handleServerReady(frame) + return + case WindowsPeerBrokerFrameKind.InboundRequest: + this.handleInbound(frame) + return + case WindowsPeerBrokerFrameKind.OutboundResponse: + this.handleTerminal(frame, undefined) + return + case WindowsPeerBrokerFrameKind.OperationError: + this.handleOperationError(frame) + return + case WindowsPeerBrokerFrameKind.ServerFatal: { + if (frame.operationId !== 0) throw this.protocolViolation('SERVER_FATAL operation ID must be zero') + const failure = decodeOperationErrorPayload(frame.payload) + this.fatalReason = failure.message + this.fail(transportError(failure.code, failure.message)) + return + } + case WindowsPeerBrokerFrameKind.ShutdownComplete: + if (frame.operationId !== 0 || frame.payload.length !== 0) { + throw this.protocolViolation('SHUTDOWN_COMPLETE frame is invalid') + } + if (!this.resolveShutdown) throw this.protocolViolation('Unexpected SHUTDOWN_COMPLETE frame') + this.resolveShutdown() + this.resolveShutdown = undefined + return + default: + throw this.protocolViolation('Unexpected broker-to-Node frame kind') + } + } + + private handleServerReady(frame: WindowsPeerBrokerFrame): void { + if (frame.operationId !== 0 || !this.startup) throw this.protocolViolation('Unexpected SERVER_READY frame') + const address = decodeOneStringPayload(frame.payload) + const startup = this.startup + this.startup = undefined + startup.resolve(address) + } + + private handleInbound(frame: WindowsPeerBrokerFrame): void { + if (frame.operationId === 0 || this.inbound.has(frame.operationId) || this.inbound.size >= 256) { + throw this.protocolViolation('Invalid inbound operation ID') + } + const request = decodeInboundRequestPayload(frame.payload) + this.inbound.add(frame.operationId) + void (async () => { + let response: PeerFrameV1 + let callbackTimer: NodeJS.Timeout | undefined + try { + response = await Promise.race([ + this.onRequest(parseBusinessFrame(request.peerFrame), request.senderInstanceId), + new Promise((resolve) => { + callbackTimer = setTimeout( + () => + resolve({ + v: 1, + type: 'error', + code: 'PEER_REQUEST_TIMEOUT', + message: 'Peer request processing timed out', + }), + INBOUND_CALLBACK_TIMEOUT_MS, + ) + callbackTimer.unref() + }), + ]) + } catch (error) { + response = { + v: 1, + type: 'error', + code: 'PEER_PROTOCOL_ERROR', + message: stripTerminalControls(errorMessage(error)), + } + } finally { + if (callbackTimer) clearTimeout(callbackTimer) + } + try { + await this.send({ + kind: WindowsPeerBrokerFrameKind.InboundResponse, + operationId: frame.operationId, + payload: encodePeerFramePayload(encodePeerFrame(response)), + }) + } catch (error) { + this.fail(error) + } finally { + this.inbound.delete(frame.operationId) + } + })() + } + + private handleOperationError(frame: WindowsPeerBrokerFrame): void { + if (frame.operationId === 0) throw this.protocolViolation('OPERATION_ERROR operation ID must be nonzero') + const failure = decodeOperationErrorPayload(frame.payload) + if (this.startup?.operationId === frame.operationId) { + const startup = this.startup + this.startup = undefined + startup.reject(transportError(failure.code, failure.message)) + return + } + this.handleTerminal(frame, transportError(failure.code, failure.message)) + } + + private handleTerminal(frame: WindowsPeerBrokerFrame, failure: Error | undefined): void { + const pending = this.pending.get(frame.operationId) + if (!pending) { + if (this.consumeCanceled(frame.operationId)) return + throw this.protocolViolation('Unexpected terminal operation ID') + } + let response: PeerFrameV1 | undefined + if (!failure && frame.kind === WindowsPeerBrokerFrameKind.OutboundResponse) { + response = parseBusinessFrame(decodePeerFramePayload(frame.payload)) + } + this.pending.delete(frame.operationId) + this.disposePending(pending) + if (failure) pending.reject(failure) + else pending.resolve(response!) + } + + private cancelOperation(operationId: number, reason: Error): void { + const pending = this.pending.get(operationId) + if (!pending) return + this.pending.delete(operationId) + this.rememberCanceled(operationId) + this.disposePending(pending) + pending.reject(reason) + void this.send({ + kind: WindowsPeerBrokerFrameKind.CancelOperation, + operationId, + payload: Buffer.alloc(0), + }).catch((error) => this.fail(error)) + } + + private finishPending(operationId: number, error: unknown): void { + const pending = this.pending.get(operationId) + if (!pending) return + this.pending.delete(operationId) + this.disposePending(pending) + pending.reject(error) + } + + private disposePending(pending: PendingOperation): void { + clearTimeout(pending.timer) + if (pending.signal && pending.onAbort) pending.signal.removeEventListener('abort', pending.onAbort) + } + + private rememberCanceled(operationId: number): void { + const now = Date.now() + for (const [candidate, expiresAt] of this.canceled) { + if (expiresAt > now) continue + this.canceled.delete(candidate) + } + if (this.canceled.size >= MAX_CANCELED_OPERATIONS) { + this.fail( + transportError('PEER_WINDOWS_OPERATION_CAPACITY', 'Windows peer broker cancellation tracking is exhausted'), + ) + return + } + this.canceled.set(operationId, now + CANCELED_OPERATION_TTL_MS) + } + + private consumeCanceled(operationId: number): boolean { + const expiresAt = this.canceled.get(operationId) + if (expiresAt === undefined) return false + this.canceled.delete(operationId) + return expiresAt > Date.now() + } + + private send(frame: WindowsPeerBrokerFrame): Promise { + if (this.exited) { + return Promise.reject( + transportError('PEER_WINDOWS_BROKER_EXITED', this.fatalReason ?? 'Windows peer broker exited unexpectedly'), + ) + } + return this.brokerProcess.send(frame) + } + + private fail(error: unknown): void { + if (this.exited) return + this.fatalReason = stripTerminalControls(errorMessage(error)) + this.brokerProcess.kill() + } + + private onExit(): void { + if (this.exited) return + this.exited = true + const failure = transportError( + 'PEER_WINDOWS_BROKER_EXITED', + this.fatalReason ?? 'Windows peer broker exited unexpectedly', + ) + this.startup?.reject(failure) + this.startup = undefined + for (const pending of this.pending.values()) { + this.disposePending(pending) + pending.reject(failure) + } + this.pending.clear() + this.canceled.clear() + this.inbound.clear() + this.resolveShutdown?.() + this.resolveClosed({ + expected: this.expectedClose, + ...(this.expectedClose ? {} : { reason: failure.message }), + }) + } + + private protocolViolation(message: string): Error { + return transportError('PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', message) + } +} + +export function createWindowsNamedPipeTransport(options: WindowsNamedPipeTransportOptions): PeerTransport { + let client: BrokerClient | undefined + let server: PeerTransportServer | undefined + const namespaceId = (): string => { + const value = options.getRuntimePaths().namespaceId + if (!value || !/^[a-f0-9]{12}$/.test(value)) + throw transportError('PEER_RUNTIME_NOT_INITIALIZED', 'Peer runtime is not initialized') + return value + } + return { + kind: 'windows-pipe', + validateAddress: (address) => isValidWindowsPeerPipeAddress(address, options.getRuntimePaths().namespaceId), + + async listen(listenOptions) { + if (server) throw transportError('PEER_WINDOWS_PIPE_CREATE_FAILED', 'Windows peer broker is already listening') + const expectedNamespace = namespaceId() + if (listenOptions.signal?.aborted) throw abortError() + const artifact = await (options.artifact ?? resolveWindowsPeerBrokerArtifact()) + if (listenOptions.signal?.aborted) throw abortError() + const broker = new BrokerClient(artifact, { + spawnBroker: options.spawnBroker ?? spawn, + onRequest: listenOptions.onRequest, + requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + }) + client = broker + const onAbort = () => void broker.close(0) + listenOptions.signal?.addEventListener('abort', onAbort, { once: true }) + try { + let startupTimer: NodeJS.Timeout | undefined + const address = await Promise.race([ + broker.startServer({ + namespaceId: expectedNamespace, + instanceId: listenOptions.instanceId, + inboxToken: listenOptions.inboxToken, + }), + new Promise((_, reject) => { + startupTimer = setTimeout( + () => reject(transportError('PEER_WINDOWS_PIPE_CREATE_FAILED', 'Windows peer broker startup timed out')), + STARTUP_TIMEOUT_MS, + ) + startupTimer.unref() + }), + ]).finally(() => { + if (startupTimer) clearTimeout(startupTimer) + }) + if (!isValidWindowsPeerPipeAddress(address, expectedNamespace)) { + throw transportError( + 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + 'Windows peer broker returned an invalid pipe address', + ) + } + const created: PeerTransportServer = { + address, + closed: broker.closed, + async close(closeOptions = {}) { + await broker.close(closeOptions.deadlineMs ?? 500) + }, + } + server = created + return created + } catch (error) { + await broker.close(0).catch(() => {}) + throw error + } finally { + listenOptions.signal?.removeEventListener('abort', onAbort) + } + }, + + async request(requestOptions) { + if (!client || !server) { + throw transportError('PEER_WINDOWS_BROKER_EXITED', 'Windows peer broker is not running') + } + if (!isValidWindowsPeerPipeAddress(requestOptions.address, namespaceId())) { + throw transportError('PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', 'Windows peer pipe address is invalid') + } + return client.request(requestOptions) + }, + } +} diff --git a/packages/core/src/peers/windows-peer-broker-artifact.ts b/packages/core/src/peers/windows-peer-broker-artifact.ts new file mode 100644 index 0000000..a6ff8ff --- /dev/null +++ b/packages/core/src/peers/windows-peer-broker-artifact.ts @@ -0,0 +1,48 @@ +import { fileURLToPath } from 'node:url' + +import { + type WindowsNativeArtifact, + type WindowsNativeArtifactSpec, + resolveWindowsNativeArtifact, + resolveWindowsNativeRoot, +} from '../native/windows-native-artifact.js' +import { WINDOWS_PEER_BROKER_PROTOCOL_VERSION } from './windows-peer-broker-protocol.js' + +const SPEC: WindowsNativeArtifactSpec = { + artifactName: 'peerBroker', + executableName: 'xc-peer-broker.exe', + displayName: 'Windows peer broker', + protocolVersion: WINDOWS_PEER_BROKER_PROTOCOL_VERSION, + createError(failure, message, cause) { + const names = { + 'unsupported-arch': 'PEER_WINDOWS_UNSUPPORTED_ARCH', + missing: 'PEER_WINDOWS_HELPER_MISSING', + 'protocol-mismatch': 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + 'integrity-mismatch': 'PEER_WINDOWS_HELPER_HASH_MISMATCH', + } as const + const error = new Error(message, cause === undefined ? undefined : { cause }) + error.name = names[failure] + return error + }, +} + +export type WindowsPeerBrokerArtifact = WindowsNativeArtifact + +export function resolveWindowsPeerBrokerNativeRoot(modulePath: string): string { + try { + return resolveWindowsNativeRoot(modulePath) + } catch (error) { + throw SPEC.createError!( + 'missing', + `Windows peer broker bundle has an unsupported package layout: ${modulePath}`, + error, + ) + } +} + +export function resolveWindowsPeerBrokerArtifact( + arch: NodeJS.Architecture = process.arch, + nativeRoot = resolveWindowsPeerBrokerNativeRoot(fileURLToPath(import.meta.url)), +): Promise { + return resolveWindowsNativeArtifact({ arch, nativeRoot, spec: SPEC }) +} diff --git a/packages/core/src/peers/windows-peer-broker-process.ts b/packages/core/src/peers/windows-peer-broker-process.ts new file mode 100644 index 0000000..dd743fb --- /dev/null +++ b/packages/core/src/peers/windows-peer-broker-process.ts @@ -0,0 +1,100 @@ +import { type ChildProcessWithoutNullStreams, type spawn } from 'node:child_process' + +import { debugLog } from '../utils.js' +import { stripTerminalControls } from './terminal-sanitize.js' +import type { WindowsPeerBrokerArtifact } from './windows-peer-broker-artifact.js' +import { + WINDOWS_PEER_BROKER_PROTOCOL_VERSION, + type WindowsPeerBrokerFrame, + WindowsPeerBrokerFrameDecoder, + encodeWindowsPeerBrokerFrame, +} from './windows-peer-broker-protocol.js' + +const MAX_STDERR_BYTES = 4_096 + +export interface WindowsPeerBrokerProcess { + closed: Promise<{ code: number | null; signal: NodeJS.Signals | null }> + send(frame: WindowsPeerBrokerFrame): Promise + endInput(): void + kill(): void +} + +export function spawnWindowsPeerBrokerProcess(options: { + artifact: WindowsPeerBrokerArtifact + mode: 'broker' | 'secure-runtime' + spawnBroker: typeof spawn + debugKey: string + onFrame: (frame: WindowsPeerBrokerFrame) => void + onError: (error: unknown) => void + onClose?: (status: { code: number | null; signal: NodeJS.Signals | null }) => void +}): WindowsPeerBrokerProcess { + const child: ChildProcessWithoutNullStreams = options.spawnBroker( + options.artifact.executablePath, + [options.mode, '--protocol', String(WINDOWS_PEER_BROKER_PROTOCOL_VERSION)], + { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }, + ) + const decoder = new WindowsPeerBrokerFrameDecoder() + let exited = false + let stderrBytes = 0 + let writeTail = Promise.resolve() + let resolveClosed!: (status: { code: number | null; signal: NodeJS.Signals | null }) => void + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + resolveClosed = resolve + }) + + const fail = (error: unknown): void => options.onError(error) + child.stdin.on('error', fail) + child.stdout.on('error', fail) + child.stderr.on('error', fail) + child.stdout.on('data', (chunk: Buffer) => { + if (exited) return + try { + for (const frame of decoder.push(chunk)) options.onFrame(frame) + } catch (error) { + child.kill() + fail(error) + } + }) + child.stdout.once('end', () => { + try { + decoder.finish() + } catch (error) { + fail(error) + } + }) + child.stderr.on('data', (chunk: Buffer) => { + if (stderrBytes >= MAX_STDERR_BYTES) return + const bytes = chunk.subarray(0, MAX_STDERR_BYTES - stderrBytes) + stderrBytes += bytes.length + const text = stripTerminalControls(bytes.toString('utf8')) + if (text) debugLog(options.debugKey, text) + }) + child.once('error', fail) + child.once('close', (code, signal) => { + exited = true + const status = { code, signal } + resolveClosed(status) + options.onClose?.(status) + }) + + return { + closed, + send(frame) { + if (exited) return Promise.reject(new Error('Windows peer broker exited unexpectedly')) + const bytes = encodeWindowsPeerBrokerFrame(frame) + const operation = writeTail.then( + () => + new Promise((resolve, reject) => { + child.stdin.write(bytes, (error) => { + if (error) reject(error) + else resolve() + }) + }), + ) + writeTail = operation.catch(() => {}) + return operation + }, + endInput: () => child.stdin.end(), + kill: () => child.kill(), + } +} diff --git a/packages/core/src/peers/windows-peer-broker-protocol.ts b/packages/core/src/peers/windows-peer-broker-protocol.ts new file mode 100644 index 0000000..3a0cedd --- /dev/null +++ b/packages/core/src/peers/windows-peer-broker-protocol.ts @@ -0,0 +1,245 @@ +export const WINDOWS_PEER_BROKER_PROTOCOL_VERSION = 2 +export const WINDOWS_PEER_BROKER_HEADER_BYTES = 16 +export const WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES = 139_264 +export const WINDOWS_PEER_BROKER_MAX_OPERATIONS = 256 + +const MAGIC = Buffer.from('XCPB') +const MAX_PEER_FRAME_BYTES = 131_072 + +export const WindowsPeerBrokerFrameKind = { + SecureRuntime: 0x01, + StartServer: 0x02, + OutboundRequest: 0x03, + InboundResponse: 0x04, + CancelOperation: 0x05, + Shutdown: 0x06, + SecureRuntimeResult: 0x81, + ServerReady: 0x82, + InboundRequest: 0x83, + OutboundResponse: 0x84, + OperationError: 0x86, + ServerFatal: 0x87, + ShutdownComplete: 0x88, +} as const + +export type WindowsPeerBrokerFrameKind = (typeof WindowsPeerBrokerFrameKind)[keyof typeof WindowsPeerBrokerFrameKind] + +const KNOWN_KINDS = new Set(Object.values(WindowsPeerBrokerFrameKind)) + +export interface WindowsPeerBrokerFrame { + kind: WindowsPeerBrokerFrameKind + operationId: number + payload: Buffer +} + +function protocolError(message: string): Error { + return Object.assign(new Error(message), { name: 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH' }) +} + +function assertOperationId(operationId: number): void { + if (!Number.isSafeInteger(operationId) || operationId < 0 || operationId > 0xffff_ffff) { + throw protocolError('Windows peer broker operation ID is invalid') + } +} + +export function encodeWindowsPeerBrokerFrame(frame: WindowsPeerBrokerFrame): Buffer { + assertOperationId(frame.operationId) + if (!KNOWN_KINDS.has(frame.kind)) throw protocolError('Windows peer broker frame kind is invalid') + if (frame.payload.length > WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES) { + throw protocolError('Windows peer broker frame exceeds the payload limit') + } + const bytes = Buffer.allocUnsafe(WINDOWS_PEER_BROKER_HEADER_BYTES + frame.payload.length) + MAGIC.copy(bytes, 0) + bytes[4] = WINDOWS_PEER_BROKER_PROTOCOL_VERSION + bytes[5] = frame.kind + bytes.writeUInt16LE(0, 6) + bytes.writeUInt32LE(frame.operationId, 8) + bytes.writeUInt32LE(frame.payload.length, 12) + frame.payload.copy(bytes, WINDOWS_PEER_BROKER_HEADER_BYTES) + return bytes +} + +export class WindowsPeerBrokerFrameDecoder { + private buffer = Buffer.alloc(0) + + push(chunk: Uint8Array): WindowsPeerBrokerFrame[] { + if (chunk.length === 0) return [] + this.buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]) + const frames: WindowsPeerBrokerFrame[] = [] + while (this.buffer.length >= WINDOWS_PEER_BROKER_HEADER_BYTES) { + if (!this.buffer.subarray(0, 4).equals(MAGIC)) throw protocolError('Invalid Windows peer broker frame magic') + if (this.buffer[4] !== WINDOWS_PEER_BROKER_PROTOCOL_VERSION) { + throw protocolError('Unsupported Windows peer broker protocol version') + } + const kind = this.buffer[5]! + if (!KNOWN_KINDS.has(kind)) throw protocolError('Unknown Windows peer broker frame kind') + if (this.buffer.readUInt16LE(6) !== 0) throw protocolError('Unsupported Windows peer broker frame flags') + const payloadLength = this.buffer.readUInt32LE(12) + if (payloadLength > WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES) { + throw protocolError('Windows peer broker frame exceeds the payload limit') + } + const frameLength = WINDOWS_PEER_BROKER_HEADER_BYTES + payloadLength + if (this.buffer.length < frameLength) break + frames.push({ + kind: kind as WindowsPeerBrokerFrameKind, + operationId: this.buffer.readUInt32LE(8), + payload: Buffer.from(this.buffer.subarray(WINDOWS_PEER_BROKER_HEADER_BYTES, frameLength)), + }) + this.buffer = this.buffer.subarray(frameLength) + } + return frames + } + + finish(): void { + if (this.buffer.length !== 0) throw protocolError('Truncated Windows peer broker frame') + } +} + +class PayloadWriter { + private readonly chunks: Buffer[] = [] + private length = 0 + + string(value: string): this { + const bytes = Buffer.from(value, 'utf8') + if (bytes.length > 0xffff) throw protocolError('Windows peer broker string exceeds the limit') + const prefix = Buffer.allocUnsafe(2) + prefix.writeUInt16LE(bytes.length) + this.push(prefix) + this.push(bytes) + return this + } + + bytes(value: Uint8Array): this { + if (value.length > MAX_PEER_FRAME_BYTES) throw protocolError('Peer frame exceeds the broker limit') + const prefix = Buffer.allocUnsafe(4) + prefix.writeUInt32LE(value.length) + this.push(prefix) + this.push(Buffer.from(value)) + return this + } + + u32(value: number): this { + assertOperationId(value) + const bytes = Buffer.allocUnsafe(4) + bytes.writeUInt32LE(value) + this.push(bytes) + return this + } + + build(): Buffer { + if (this.length > WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES) { + throw protocolError('Windows peer broker control payload exceeds the limit') + } + return Buffer.concat(this.chunks, this.length) + } + + private push(bytes: Buffer): void { + this.length += bytes.length + this.chunks.push(bytes) + } +} + +class PayloadReader { + private offset = 0 + + constructor(private readonly bytes: Buffer) {} + + string(): string { + const length = this.take(2).readUInt16LE(0) + const bytes = this.take(length) + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + throw protocolError('Windows peer broker string is not valid UTF-8') + } + } + + byteArray(): Buffer { + const length = this.take(4).readUInt32LE(0) + if (length > MAX_PEER_FRAME_BYTES) throw protocolError('Peer frame exceeds the broker limit') + return Buffer.from(this.take(length)) + } + + u32(): number { + return this.take(4).readUInt32LE(0) + } + + finish(): void { + if (this.offset !== this.bytes.length) throw protocolError('Unexpected Windows peer broker payload suffix') + } + + private take(length: number): Buffer { + const end = this.offset + length + if (!Number.isSafeInteger(end) || end > this.bytes.length) { + throw protocolError('Truncated Windows peer broker payload') + } + const value = this.bytes.subarray(this.offset, end) + this.offset = end + return value + } +} + +export function encodeSecureRuntimePayload(root: string): Buffer { + return new PayloadWriter().string(root).build() +} + +export function encodeStartServerPayload(input: { + namespaceId: string + instanceId: string + inboxToken: string +}): Buffer { + return new PayloadWriter().string(input.namespaceId).string(input.instanceId).string(input.inboxToken).build() +} + +export function encodeOutboundRequestPayload(input: { + address: string + targetToken: string + senderInstanceId: string + timeoutMs: number + peerFrame: Uint8Array +}): Buffer { + return new PayloadWriter() + .string(input.address) + .string(input.targetToken) + .string(input.senderInstanceId) + .u32(input.timeoutMs) + .bytes(input.peerFrame) + .build() +} + +export function encodePeerFramePayload(peerFrame: Uint8Array): Buffer { + return new PayloadWriter().bytes(peerFrame).build() +} + +export function decodeOneStringPayload(payload: Buffer): string { + const reader = new PayloadReader(payload) + const value = reader.string() + reader.finish() + return value +} + +export function decodePeerFramePayload(payload: Buffer): Buffer { + const reader = new PayloadReader(payload) + const peerFrame = reader.byteArray() + if (peerFrame.length === 0) throw protocolError('Windows peer broker returned an empty peer frame') + reader.finish() + return peerFrame +} + +export function decodeInboundRequestPayload(payload: Buffer): { senderInstanceId: string; peerFrame: Buffer } { + const reader = new PayloadReader(payload) + const senderInstanceId = reader.string() + const peerFrame = reader.byteArray() + if (peerFrame.length === 0) throw protocolError('Windows peer broker returned an empty inbound frame') + reader.finish() + return { senderInstanceId, peerFrame } +} + +export function decodeOperationErrorPayload(payload: Buffer): { code: string; message: string } { + const reader = new PayloadReader(payload) + const code = reader.string() + const message = reader.string() + reader.finish() + if (!/^PEER_[A-Z0-9_]+$/.test(code)) throw protocolError('Windows peer broker returned an invalid error code') + return { code, message } +} diff --git a/packages/core/src/peers/windows-peer-runtime-security.ts b/packages/core/src/peers/windows-peer-runtime-security.ts new file mode 100644 index 0000000..5fd49db --- /dev/null +++ b/packages/core/src/peers/windows-peer-runtime-security.ts @@ -0,0 +1,179 @@ +import { spawn } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { errorMessage, userXcodeDir } from '../utils.js' +import { stripTerminalControls } from './terminal-sanitize.js' +import { type WindowsPeerBrokerArtifact, resolveWindowsPeerBrokerArtifact } from './windows-peer-broker-artifact.js' +import { spawnWindowsPeerBrokerProcess } from './windows-peer-broker-process.js' +import { + WindowsPeerBrokerFrameKind, + decodeOneStringPayload, + decodeOperationErrorPayload, + encodeSecureRuntimePayload, +} from './windows-peer-broker-protocol.js' + +const SECURE_RUNTIME_OPERATION_ID = 1 +const SECURE_RUNTIME_TIMEOUT_MS = 5_000 + +export interface WindowsPeerRuntimePaths { + registryDir: string + socketDir: string + namespaceId: string +} + +export interface WindowsPeerRuntimeSecurityProvider { + initialize(signal?: AbortSignal): Promise +} + +export interface WindowsPeerRuntimeSecurityOptions { + root?: string + artifact?: WindowsPeerBrokerArtifact | Promise + spawnBroker?: typeof spawn + timeoutMs?: number +} + +function runtimeError(code: string, message: string, cause?: unknown): Error { + const error = new Error(stripTerminalControls(message), cause === undefined ? undefined : { cause }) + error.name = code + return error +} + +function abortError(): Error { + return Object.assign(new Error('Windows peer runtime initialization was interrupted'), { name: 'AbortError' }) +} + +export function createWindowsPeerRuntimeSecurity( + options: WindowsPeerRuntimeSecurityOptions = {}, +): WindowsPeerRuntimeSecurityProvider { + let initialized: Promise | undefined + return { + initialize(signal) { + if (initialized) return initialized + const operation = secureWindowsPeerRuntime(options, signal).catch((error) => { + initialized = undefined + throw error + }) + initialized = operation + return operation + }, + } +} + +async function secureWindowsPeerRuntime( + options: WindowsPeerRuntimeSecurityOptions, + signal?: AbortSignal, +): Promise { + if (process.platform !== 'win32') { + throw runtimeError('PEER_UNSUPPORTED_PLATFORM', 'Windows peer runtime security is only available on Windows') + } + if (signal?.aborted) throw abortError() + const root = path.resolve(options.root ?? userXcodeDir()) + try { + await fs.mkdir(root, { recursive: true }) + } catch (error) { + throw runtimeError( + 'PEER_WINDOWS_RUNTIME_UNSAFE', + `Windows peer runtime root creation failed: ${errorMessage(error)}`, + error, + ) + } + const artifact = await (options.artifact ?? resolveWindowsPeerBrokerArtifact()) + if (signal?.aborted) throw abortError() + let settled = false + let resolveResult!: (namespaceId: string) => void + let rejectResult!: (error: unknown) => void + const result = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }) + const settleError = (error: unknown): void => { + if (settled) return + settled = true + rejectResult(error) + } + const brokerProcess = spawnWindowsPeerBrokerProcess({ + artifact, + mode: 'secure-runtime', + spawnBroker: options.spawnBroker ?? spawn, + debugKey: 'peer.windows.runtime-helper', + onFrame(frame) { + if (settled) return + if (frame.operationId !== SECURE_RUNTIME_OPERATION_ID) { + throw runtimeError( + 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + 'Windows peer runtime helper returned an unknown operation ID', + ) + } + if (frame.kind === WindowsPeerBrokerFrameKind.SecureRuntimeResult) { + const namespaceId = decodeOneStringPayload(frame.payload) + if (!/^[a-f0-9]{12}$/.test(namespaceId)) { + throw runtimeError( + 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + 'Windows peer runtime helper returned an invalid namespace', + ) + } + settled = true + resolveResult(namespaceId) + } else if (frame.kind === WindowsPeerBrokerFrameKind.OperationError) { + const failure = decodeOperationErrorPayload(frame.payload) + settleError(runtimeError(failure.code, failure.message)) + } else { + throw runtimeError( + 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + 'Windows peer runtime helper returned an unexpected frame', + ) + } + }, + onError: settleError, + onClose() { + settleError( + runtimeError( + 'PEER_WINDOWS_RUNTIME_UNSAFE', + 'Windows peer runtime helper exited before completing the security check', + ), + ) + }, + }) + const onAbort = (): void => { + brokerProcess.kill() + settleError(abortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + const timer = setTimeout(() => { + brokerProcess.kill() + settleError(runtimeError('PEER_WINDOWS_RUNTIME_UNSAFE', 'Windows peer runtime security check timed out')) + }, options.timeoutMs ?? SECURE_RUNTIME_TIMEOUT_MS) + timer.unref() + + try { + await brokerProcess.send({ + kind: WindowsPeerBrokerFrameKind.SecureRuntime, + operationId: SECURE_RUNTIME_OPERATION_ID, + payload: encodeSecureRuntimePayload(root), + }) + brokerProcess.endInput() + const namespaceId = await result + const status = await brokerProcess.closed + if (status.code !== 0) { + throw runtimeError( + 'PEER_WINDOWS_RUNTIME_UNSAFE', + 'Windows peer runtime helper exited before completing the security check', + ) + } + const registryDir = path.join(root, 'runtime', 'peers') + return { registryDir, socketDir: registryDir, namespaceId } + } catch (error) { + brokerProcess.kill() + await brokerProcess.closed + if (error instanceof Error && (error.name === 'AbortError' || error.name.startsWith('PEER_'))) throw error + throw runtimeError( + 'PEER_WINDOWS_RUNTIME_UNSAFE', + `Windows peer runtime security check failed: ${errorMessage(error)}`, + error, + ) + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } +} diff --git a/packages/core/src/peers/windows-pipe-address.ts b/packages/core/src/peers/windows-pipe-address.ts new file mode 100644 index 0000000..6d85483 --- /dev/null +++ b/packages/core/src/peers/windows-pipe-address.ts @@ -0,0 +1,6 @@ +const WINDOWS_PEER_PIPE_PATTERN = /^\\\\\.\\pipe\\x-code-peer-v2-([a-f0-9]{12})-([A-Za-z0-9_-]{32})$/ + +export function isValidWindowsPeerPipeAddress(address: string, namespaceId?: string): boolean { + const match = WINDOWS_PEER_PIPE_PATTERN.exec(address) + return Boolean(match && namespaceId && match[1] === namespaceId) +} diff --git a/packages/core/src/permissions/authority.ts b/packages/core/src/permissions/authority.ts index 8c58689..6a4defa 100644 --- a/packages/core/src/permissions/authority.ts +++ b/packages/core/src/permissions/authority.ts @@ -418,7 +418,7 @@ export function verifyAuthorityApproval( preview: AuthorityApprovalPreview, currentAuthority: ExecutionAuthority, ): boolean { - if (approval.decision !== 'allow-once' || !approval.viewedComplete || !preview.complete || !preview.approvable) { + if (approval.decision !== 'allow-once' || !preview.complete || !preview.approvable) { return false } if (!equalHex(approval.authorityHash, authoritySnapshotHash(currentAuthority))) return false diff --git a/packages/core/src/tools/shell-session/providers/windows-job.ts b/packages/core/src/tools/shell-session/providers/windows-job.ts index 26f8558..5d03f89 100644 --- a/packages/core/src/tools/shell-session/providers/windows-job.ts +++ b/packages/core/src/tools/shell-session/providers/windows-job.ts @@ -1,9 +1,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' import { fileURLToPath } from 'node:url' +import { resolveWindowsNativeArtifact, resolveWindowsNativeRoot } from '../../../native/windows-native-artifact.js' import { debugLog, errorMessage } from '../../../utils.js' import type { ManagedExitStatus, @@ -31,11 +29,6 @@ export interface WindowsSupervisorArtifact { sha256: string } -interface WindowsSupervisorManifest { - protocolVersion: number - artifacts: Record -} - export interface WindowsJobObjectProviderOptions { artifact?: Promise | WindowsSupervisorArtifact executable?: string @@ -54,56 +47,23 @@ function timeoutWake(ms: number): { promise: Promise; dispose(): void } { } export function resolveWindowsSupervisorNativeRoot(modulePath: string): string { - const moduleDir = path.dirname(modulePath) - if (!/^windows-job\.(?:[cm]?js|ts)$/.test(path.basename(modulePath))) { - if (path.basename(moduleDir) === 'dist') return path.join(moduleDir, 'native', 'windows') - if (path.basename(moduleDir) === 'chunks' && path.basename(path.dirname(moduleDir)) === 'dist') { - return path.join(path.dirname(moduleDir), 'native', 'windows') - } - throw new Error(`Windows shell supervisor bundle has an unsupported package layout: ${modulePath}`) - } - - const sourceRoot = path.dirname(path.dirname(path.dirname(moduleDir))) - if (path.basename(sourceRoot) === 'dist') return path.join(sourceRoot, 'native', 'windows') - if (path.basename(sourceRoot) === 'src') { - return path.join(path.dirname(sourceRoot), 'dist', 'native', 'windows') - } - throw new Error(`Windows shell supervisor module has an unsupported package layout: ${modulePath}`) + return resolveWindowsNativeRoot(modulePath) } export async function resolveWindowsSupervisorArtifact( arch: NodeJS.Architecture = process.arch, ): Promise { - if (arch !== 'x64' && arch !== 'arm64') { - throw new Error(`Windows unified shell does not support architecture ${arch}`) - } - const root = resolveWindowsSupervisorNativeRoot(fileURLToPath(import.meta.url)) - const manifestPath = path.join(root, 'manifest.json') - let manifest: WindowsSupervisorManifest - try { - manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as WindowsSupervisorManifest - } catch (error) { - throw new Error(`Windows shell supervisor artifact is missing for ${arch}; reinstall x-code-cli`, { cause: error }) - } - if (manifest.protocolVersion !== WINDOWS_SUPERVISOR_PROTOCOL_VERSION) { - throw new Error( - `Windows shell supervisor manifest protocol mismatch: expected ${WINDOWS_SUPERVISOR_PROTOCOL_VERSION}, received ${manifest.protocolVersion}`, - ) - } - const artifact = manifest.artifacts[arch] - if (!artifact) throw new Error(`Windows shell supervisor manifest has no ${arch} artifact`) - if (!/^[a-f0-9]{64}$/i.test(artifact.sha256)) throw new Error('Windows shell supervisor manifest hash is invalid') - const executablePath = path.resolve(root, artifact.file) - const relative = path.relative(root, executablePath) - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error('Windows shell supervisor manifest points outside its native directory') - } - const bytes = await fs.readFile(executablePath) - const actualHash = createHash('sha256').update(bytes).digest('hex') - if (actualHash !== artifact.sha256.toLowerCase()) { - throw new Error(`Windows shell supervisor hash mismatch for ${arch}`) - } - return { executablePath, sha256: actualHash } + const artifact = await resolveWindowsNativeArtifact({ + arch, + nativeRoot: resolveWindowsSupervisorNativeRoot(fileURLToPath(import.meta.url)), + spec: { + artifactName: 'shellSupervisor', + executableName: 'xc-shell-supervisor.exe', + displayName: 'Windows shell supervisor', + protocolVersion: WINDOWS_SUPERVISOR_PROTOCOL_VERSION, + }, + }) + return { executablePath: artifact.executablePath, sha256: artifact.sha256 } } class WindowsManagedProcess implements ManagedProcess { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 4a8614f..dd88fe7 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -133,7 +133,8 @@ export type AuthorityDecision = export interface AuthorityApproval { decision: 'allow-once' | 'deny' - viewedComplete: boolean + /** @deprecated Payload pagination no longer gates a local approval. */ + viewedComplete?: boolean canonicalPayloadSha256?: string canonicalCallSha256: string authorityHash: string @@ -286,8 +287,8 @@ export interface AgentCallbacks { input: Record }) => Promise /** Peer-influenced calls use a separate allow-once-only surface. The - * callback must prove that the complete canonical payload was rendered; - * absence of this callback is a fail-closed denial. */ + * callback binds its local decision to the complete canonical preview and + * hashes; absence of this callback is a fail-closed denial. */ onAskAuthority?: (request: { toolCallId: string toolName: string diff --git a/packages/core/src/utils/image-compress.ts b/packages/core/src/utils/image-compress.ts index d3b0620..838b930 100644 --- a/packages/core/src/utils/image-compress.ts +++ b/packages/core/src/utils/image-compress.ts @@ -502,15 +502,17 @@ async function runCompressionWorker( return new Promise((resolve, reject) => { let settled = false + let outputError: Error | undefined + let outputResult: CompressResult | undefined const cleanup = (): void => { clearTimeout(timer) abortSignal?.removeEventListener('abort', onAbort) } - const fail = (error: Error): void => { + const fail = (error: Error, terminate = true): void => { if (settled) return settled = true cleanup() - void worker.terminate() + if (terminate) void worker.terminate() reject(error) } const onAbort = (): void => fail(abortError(abortSignal)) @@ -523,17 +525,29 @@ async function runCompressionWorker( worker.once('message', (output: ImageCompressWorkerOutput) => { if (settled) return if (!output.ok) { - fail(new Error(output.error)) + outputError = new Error(output.error) return } - settled = true - cleanup() - void worker.terminate() - resolve({ ...output.result, data: Buffer.from(output.result.data) }) + outputResult = { ...output.result, data: Buffer.from(output.result.data) } }) worker.once('error', fail) worker.once('exit', (code) => { - if (!settled) fail(new Error(`Image compression worker exited unexpectedly with code ${code}`)) + if (settled) return + if (code !== 0) { + fail(new Error(`Image compression worker exited unexpectedly with code ${code}`), false) + return + } + if (outputError) { + fail(outputError, false) + return + } + if (!outputResult) { + fail(new Error('Image compression worker exited without a result'), false) + return + } + settled = true + cleanup() + resolve(outputResult) }) }) } diff --git a/packages/core/tests/audio-decode.test.ts b/packages/core/tests/audio-decode.test.ts index c26400c..552f275 100644 --- a/packages/core/tests/audio-decode.test.ts +++ b/packages/core/tests/audio-decode.test.ts @@ -330,5 +330,5 @@ describe('bounded audio decoder', () => { `Audio exceeds the ${MAX_AUDIO_DURATION_SECONDS}s local decode limit`, ) expect((await fs.stat(output)).size).toBeLessThanOrEqual(MAX_AUDIO_PCM_INPUT_BYTES) - }) + }, 15_000) }) diff --git a/packages/core/tests/file-ingest.test.ts b/packages/core/tests/file-ingest.test.ts index d1d5a5f..89db111 100644 --- a/packages/core/tests/file-ingest.test.ts +++ b/packages/core/tests/file-ingest.test.ts @@ -709,7 +709,7 @@ describe('ingestFile', () => { expect(JSON.stringify(parts)).not.toContain('%PDF-1.4') expect(parts.every((part) => part.type === 'text')).toBe(true) - }) + }, 15_000) // Regression: a multi-MB @path attachment used to be inlined verbatim, // pushing the user message past the model's context window before the @@ -906,7 +906,7 @@ describe('buildUserContent', () => { if (!Array.isArray(result)) return expect(result.filter((part) => part.type === 'file')).toHaveLength(10) expect(JSON.stringify(result)).toContain('10-media-part limit') - }) + }, 15_000) it('counts Base64 expansion in the cumulative serialized attachment budget', async () => { const padded = addPngAncillaryChunk(await fs.readFile(imageFile), 3.5 * 1024 * 1024) diff --git a/packages/core/tests/pdf-ingest.test.ts b/packages/core/tests/pdf-ingest.test.ts index eaa23e3..e3c87fa 100644 --- a/packages/core/tests/pdf-ingest.test.ts +++ b/packages/core/tests/pdf-ingest.test.ts @@ -80,7 +80,7 @@ describe('processPdf', () => { if (result.type !== 'content') return expect(result.parts.every((part) => part.type === 'text')).toBe(true) expect(result.parts.map((part) => (part.type === 'text' ? part.text : '')).join('\n')).toContain('mock local OCR') - }) + }, 15_000) it('does not pass parent-only Node flags to the PDF worker', async () => { process.execArgv.push('--input-type=module') @@ -91,7 +91,7 @@ describe('processPdf', () => { } finally { process.execArgv.splice(process.execArgv.lastIndexOf('--input-type=module'), 1) } - }) + }, 15_000) it('returns a reference before rendering too many visual pages', async () => { const largeScan = path.join(tempDir, 'eleven-pages.pdf') @@ -122,7 +122,7 @@ describe('processPdf', () => { pageRange: { first: 2, last: 4 }, }) expect(invalid).toMatchObject({ type: 'error', code: 'invalid-range' }) - }) + }, 15_000) it('forces page rendering in visual mode and returns an atomic continuation at the byte budget', async () => { const result = await processPdf(textPdf, { @@ -185,6 +185,30 @@ describe('processPdf', () => { name: 'AbortError', }) }) + + it.runIf(process.platform === 'win32')( + 'waits for an in-flight PDF child process to exit after abort before accepting another request', + async () => { + const controller = new AbortController() + let abortedAfterInit = false + + await expect( + processPdf(textPdf, { + vision: true, + abortSignal: controller.signal, + onNotice: (message) => { + if (abortedAfterInit || !message.startsWith('Extracting PDF text')) return + abortedAfterInit = true + controller.abort() + }, + }), + ).rejects.toMatchObject({ name: 'AbortError' }) + + expect(abortedAfterInit).toBe(true) + await expect(processPdf(textPdf, { vision: true })).resolves.toMatchObject({ type: 'content' }) + }, + 20_000, + ) }) describe('extractPdfTextWithFallback', () => { diff --git a/packages/core/tests/peer-authority.test.ts b/packages/core/tests/peer-authority.test.ts index 2853464..e4a1b33 100644 --- a/packages/core/tests/peer-authority.test.ts +++ b/packages/core/tests/peer-authority.test.ts @@ -36,7 +36,6 @@ function classify( function approvalFor(preview: AuthorityApprovalPreview): AuthorityApproval { return { decision: 'allow-once', - viewedComplete: true, authorityHash: preview.authorityHash, canonicalCallSha256: preview.canonicalCallSha256, ...(preview.outboundPayload ? { canonicalPayloadSha256: preview.outboundPayload.sha256 } : {}), @@ -367,7 +366,7 @@ describe('canonical outbound approval payloads', () => { }) describe('allow-once approval binding', () => { - it('accepts only a complete, explicitly viewed matching approval', () => { + it('accepts only a complete matching approval without a pagination gate', () => { const preview = classify('webFetch', { url: 'https://example.test/data', prompt: 'extract one field', @@ -375,7 +374,7 @@ describe('allow-once approval binding', () => { const approval = approvalFor(preview) expect(verifyAuthorityApproval(approval, preview, PEER_AUTHORITY)).toBe(true) - expect(verifyAuthorityApproval({ ...approval, viewedComplete: false }, preview, PEER_AUTHORITY)).toBe(false) + expect(verifyAuthorityApproval({ ...approval, viewedComplete: false }, preview, PEER_AUTHORITY)).toBe(true) expect(verifyAuthorityApproval({ ...approval, decision: 'deny' }, preview, PEER_AUTHORITY)).toBe(false) expect( verifyAuthorityApproval({ ...approval, canonicalPayloadSha256: '0'.repeat(64) }, preview, PEER_AUTHORITY), diff --git a/packages/core/tests/peer-registry.test.ts b/packages/core/tests/peer-registry.test.ts index e9ebfa2..bd78f1e 100644 --- a/packages/core/tests/peer-registry.test.ts +++ b/packages/core/tests/peer-registry.test.ts @@ -9,7 +9,7 @@ import path from 'node:path' import { createPeerIdentity } from '../src/peers/identity.js' import { peerSocketPath } from '../src/peers/paths.js' -import { createPeerRegistry } from '../src/peers/registry.js' +import { createPeerRegistry, parseRegistration } from '../src/peers/registry.js' import type { PeerTransport } from '../src/peers/transport.js' import type { PeerRegistrationV1 } from '../src/peers/types.js' @@ -26,6 +26,21 @@ afterEach(async () => { await rm(testDir, { recursive: true, force: true }) }) +function createTestRegistry() { + if (process.platform !== 'win32') return createPeerRegistry({}) + return createPeerRegistry({ + transportKind: 'unix', + windowsRuntimeSecurity: { + async initialize() { + const registryDir = path.join(process.env.X_CODE_HOME!, 'runtime', 'peers') + const socketDir = path.join(process.env.X_CODE_HOME!, 'runtime', 'peer-sockets') + await Promise.all([mkdir(registryDir, { recursive: true }), mkdir(socketDir, { recursive: true })]) + return { registryDir, socketDir, namespaceId: '0123456789ab' } + }, + }, + }) +} + function registration(socketDir: string, overrides: Partial = {}): PeerRegistrationV1 { const identity = createPeerIdentity({ name: 'backend' }) const now = '2026-08-13T00:00:00.000Z' @@ -68,12 +83,12 @@ describe('owner-only peer registry', () => { const linked = path.join(testDir, 'linked-home') await symlink(target, linked) process.env.X_CODE_HOME = linked - await expect(createPeerRegistry().initialize()).rejects.toThrow('Unsafe peer runtime directory') + await expect(createTestRegistry().initialize()).rejects.toThrow('Unsafe peer runtime directory') if (process.platform !== 'win32') expect((await lstat(target)).mode & 0o777).toBe(0o755) }) it('routes through X_CODE_HOME and atomically writes owner-only registrations', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const paths = registry.paths() expect(paths.registryDir).toBe(path.join(process.env.X_CODE_HOME!, 'runtime', 'peers')) @@ -91,7 +106,7 @@ describe('owner-only peer registry', () => { }) it('enumerates duplicate names and short-display collisions with full UUID identities', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const socketDir = registry.paths().socketDir const oneInstanceId = '12345678-1111-4111-8111-111111111111' @@ -118,7 +133,7 @@ describe('owner-only peer registry', () => { }) it('sanitizes untrusted registry name and cwd fields before exposing candidates', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const value = registration(registry.paths().socketDir, { name: 'back\x1b]52;c;Y2xpcGJvYXJk\x07end\u202e', @@ -136,7 +151,7 @@ describe('owner-only peer registry', () => { }) it('rejects symlinks, broad modes, oversized files, bad schema, and socket namespace escape', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const { registryDir, socketDir } = registry.paths() @@ -170,7 +185,7 @@ describe('owner-only peer registry', () => { }) it('does not remove a registration for a live pid during residual cleanup', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const value = registration(registry.paths().socketDir, { updatedAt: new Date(Date.now() - 60_000).toISOString(), @@ -182,7 +197,7 @@ describe('owner-only peer registry', () => { }) it('removes only a twice-confirmed dead registration after the grace period', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const value = registration(registry.paths().socketDir, { pid: 2_147_483_647, @@ -195,7 +210,7 @@ describe('owner-only peer registry', () => { }) itPosix('removes a dead colliding registration without unlinking a live registration shared socket', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const deadId = 'bbbbbbbb-1111-4111-8111-111111111111' const liveId = 'bbbbbbbb-2222-4222-8222-222222222222' @@ -237,10 +252,18 @@ describe('owner-only peer registry', () => { const identityModule = path.join(process.cwd(), 'packages/core/dist/peers/identity.js') const pathsModule = path.join(process.cwd(), 'packages/core/dist/peers/paths.js') const childScript = ` + import { mkdir } from 'node:fs/promises' + import path from 'node:path' import { createPeerRegistry } from ${JSON.stringify(`file://${registryModule}`)} import { createPeerIdentity } from ${JSON.stringify(`file://${identityModule}`)} import { peerSocketPath } from ${JSON.stringify(`file://${pathsModule}`)} - const registry = createPeerRegistry() + const windowsRuntimeSecurity = { initialize: async () => { + const registryDir = path.join(process.env.X_CODE_HOME, 'runtime', 'peers') + const socketDir = path.join(process.env.X_CODE_HOME, 'runtime', 'peer-sockets') + await Promise.all([mkdir(registryDir, { recursive: true }), mkdir(socketDir, { recursive: true })]) + return { registryDir, socketDir, namespaceId: '0123456789ab' } + } } + const registry = createPeerRegistry(process.platform === 'win32' ? { transportKind: 'unix', windowsRuntimeSecurity } : {}) await registry.initialize() const identity = createPeerIdentity({ name: 'child' }) const now = new Date().toISOString() @@ -251,22 +274,39 @@ describe('owner-only peer registry', () => { ` await Promise.all([runChild(childScript), runChild(childScript)]) - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const scan = await registry.listCandidates() expect(scan.candidates).toHaveLength(2) expect(scan.candidates.every((candidate) => candidate.registration.name === 'child')).toBe(true) }) + it('strictly validates Windows pipe descriptors and namespace isolation', () => { + const value = registration('C:\\runtime', { + transport: { + kind: 'windows-pipe', + address: '\\\\.\\pipe\\x-code-peer-v2-0123456789ab-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + }) + expect(parseRegistration(value, { socketDir: 'C:\\runtime', namespaceId: '0123456789ab' })).not.toBeNull() + expect(parseRegistration(value, { socketDir: 'C:\\runtime', namespaceId: 'ffffffffffff' })).toBeNull() + expect( + parseRegistration( + { ...value, transport: { kind: 'windows-pipe', address: '\\\\.\\pipe\\x-code-peer-v2-0123456789ab-..' } }, + { socketDir: 'C:\\runtime', namespaceId: '0123456789ab' }, + ), + ).toBeNull() + }) + it('rejects registration tokens that are not full random 32-byte base64url values', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const value = registration(registry.paths().socketDir, { inboxToken: randomBytes(8).toString('base64url') }) await expect(registry.write(value)).rejects.toThrow('Invalid peer registration') }) it('bounds listLive ping concurrency and returns only authenticated pong identities', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const values = Array.from({ length: 20 }, () => registration(registry.paths().socketDir)) for (const value of values) await registry.write(value) @@ -292,13 +332,15 @@ describe('owner-only peer registry', () => { }) it('never deletes a live-pid registration merely because ping times out', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() const value = registration(registry.paths().socketDir, { updatedAt: new Date(Date.now() - 60_000).toISOString(), }) await registry.write(value) const transport: PeerTransport = { + kind: 'unix', + validateAddress: () => true, listen: vi.fn() as never, request: vi.fn(async () => { throw new Error('PEER_TIMEOUT') @@ -306,14 +348,17 @@ describe('owner-only peer registry', () => { } const live = await registry.listLive({ transport, senderInstanceId: randomUUID() }) expect(live.peers).toEqual([]) + expect(live.partial).toBe(true) expect(await registry.read(value.instanceId)).not.toBeNull() }) it('honors the overall listLive deadline and caller abort', async () => { - const registry = createPeerRegistry() + const registry = createTestRegistry() await registry.initialize() for (let index = 0; index < 20; index++) await registry.write(registration(registry.paths().socketDir)) const transport: PeerTransport = { + kind: 'unix', + validateAddress: () => true, listen: vi.fn() as never, request: ({ signal }) => new Promise((_, reject) => diff --git a/packages/core/tests/peer-service-legacy-transport.test.ts b/packages/core/tests/peer-service-legacy-transport.test.ts new file mode 100644 index 0000000..b27bb9d --- /dev/null +++ b/packages/core/tests/peer-service-legacy-transport.test.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { createPeerService } from '../src/peers/service.js' +import type { PeerTransport } from '../src/peers/transport.js' + +describe('PeerService transport compatibility', () => { + it('uses the Unix registry kind for an injected legacy transport without metadata', async () => { + const previousHome = process.env.X_CODE_HOME + const testHome = + process.platform === 'win32' + ? path.join(os.homedir(), `.x-code-peer-legacy-${randomUUID()}`) + : await fs.mkdtemp(path.join(os.tmpdir(), 'x-code-peer-legacy-')) + process.env.X_CODE_HOME = testHome + const transport: PeerTransport = { + async listen(options) { + return { + address: options.address, + async close() {}, + } + }, + async request() { + throw new Error('not used') + }, + } + const service = createPeerService({ enabled: true, name: 'legacy-transport', transport }) + + try { + await service.start() + expect(service.isAvailable(), service.getUnavailableReason()).toBe(true) + } finally { + await service.shutdown() + if (previousHome === undefined) delete process.env.X_CODE_HOME + else process.env.X_CODE_HOME = previousHome + await fs.rm(testHome, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/tests/peer-service-sanitize.test.ts b/packages/core/tests/peer-service-sanitize.test.ts index d2307b6..97973a7 100644 --- a/packages/core/tests/peer-service-sanitize.test.ts +++ b/packages/core/tests/peer-service-sanitize.test.ts @@ -57,9 +57,15 @@ describe('peer service terminal sanitization', () => { paths: () => ({ registryDir: '/runtime/peers', socketDir: '/runtime/sockets' }), } const transport: PeerTransport = { + kind: 'unix', + validateAddress: () => true, listen: vi.fn(async (options) => { onRequest = options.onRequest - return { address: options.address, close: vi.fn(async () => {}) } + return { + address: options.address, + closed: new Promise<{ expected: boolean }>(() => {}), + close: vi.fn(async () => {}), + } }), request: vi.fn(async () => { throw new Error('not used') diff --git a/packages/core/tests/peer-service.test.ts b/packages/core/tests/peer-service.test.ts index fcb465b..5bcd52d 100644 --- a/packages/core/tests/peer-service.test.ts +++ b/packages/core/tests/peer-service.test.ts @@ -273,7 +273,7 @@ describeUnix('PeerService over real Unix domain sockets', () => { const realTransport = createUnixSocketTransport() let stalledAttempts = 0 const receiverTransport: PeerTransport = { - listen: (options) => realTransport.listen(options), + ...realTransport, request: async (options) => { if ( options.frame.type !== 'delivery-update' || @@ -282,7 +282,7 @@ describeUnix('PeerService over real Unix domain sockets', () => { return realTransport.request(options) } stalledAttempts++ - return new Promise(() => {}) + return new Promise(() => {}) }, } const receiver = await startPeer('receiver', 'hold', { transport: receiverTransport }) @@ -363,7 +363,13 @@ describeUnix('PeerService over real Unix domain sockets', () => { } const close = vi.fn(async () => {}) const transport: PeerTransport = { - listen: vi.fn(async (options) => ({ address: options.address, close })), + kind: 'unix', + validateAddress: () => true, + listen: vi.fn(async (options) => ({ + address: options.address, + closed: new Promise<{ expected: boolean }>(() => {}), + close, + })), request: vi.fn(), } const service = createPeerService({ enabled: true, name: 'late-start', registry, transport }) @@ -382,6 +388,39 @@ describeUnix('PeerService over real Unix domain sockets', () => { expect(close).not.toHaveBeenCalled() }) + it('fails closed and removes registration after an unexpected transport exit', async () => { + const removeOwn = vi.fn(async () => true) + const registry: PeerRegistry = { + initialize: vi.fn(async () => {}), + write: vi.fn(), + read: vi.fn(async () => null), + listCandidates: vi.fn(async () => ({ candidates: [], scanned: 0, rejected: 0, truncated: false })), + listLive: vi.fn(async () => ({ peers: [], registrations: [], partial: false })), + removeOwn, + cleanupConfirmedDead: vi.fn(async () => false), + paths: () => ({ registryDir: testHome, socketDir: testHome }), + } + let closeUnexpectedly!: (result: { expected: boolean; reason?: string }) => void + const closed = new Promise<{ expected: boolean; reason?: string }>((resolve) => { + closeUnexpectedly = resolve + }) + const transport: PeerTransport = { + kind: 'unix', + validateAddress: () => true, + listen: vi.fn(async (options) => ({ address: options.address, closed, close: vi.fn(async () => {}) })), + request: vi.fn(), + } + const service = createPeerService({ enabled: true, name: 'crashed-transport', registry, transport }) + services.push(service) + await service.start() + expect(service.isAvailable()).toBe(true) + + closeUnexpectedly({ expected: false, reason: 'listener crashed' }) + await vi.waitFor(() => expect(service.isAvailable()).toBe(false)) + await vi.waitFor(() => expect(removeOwn).toHaveBeenCalledWith(service.identity!.instanceId)) + expect(service.getUnavailableReason()).toBe('listener crashed') + }) + it('closes a listener that resolves after shutdown invalidates startup', async () => { const registry: PeerRegistry = { initialize: vi.fn(async () => {}), @@ -399,10 +438,12 @@ describeUnix('PeerService over real Unix domain sockets', () => { const listenGate = new Promise((resolve) => (releaseListen = resolve)) const close = vi.fn(async () => {}) const transport: PeerTransport = { + kind: 'unix', + validateAddress: () => true, listen: vi.fn(async (options) => { signalListen() await listenGate - return { address: options.address, close } + return { address: options.address, closed: new Promise<{ expected: boolean }>(() => {}), close } }), request: vi.fn(), } @@ -438,11 +479,13 @@ describeUnix('PeerService over real Unix domain sockets', () => { const close = vi.fn(async () => {}) let receivedSignal: AbortSignal | undefined const transport: PeerTransport = { + kind: 'unix', + validateAddress: () => true, listen: vi.fn(async (options) => { receivedSignal = options.signal signalListen() await listenGate - return { address: options.address, close } + return { address: options.address, closed: new Promise<{ expected: boolean }>(() => {}), close } }), request: vi.fn(), } @@ -488,7 +531,13 @@ describeUnix('PeerService over real Unix domain sockets', () => { } const close = vi.fn(async () => {}) const transport: PeerTransport = { - listen: vi.fn(async (options) => ({ address: options.address, close })), + kind: 'unix', + validateAddress: () => true, + listen: vi.fn(async (options) => ({ + address: options.address, + closed: new Promise<{ expected: boolean }>(() => {}), + close, + })), request: vi.fn(), } const service = createPeerService({ enabled: true, name: 'aborted-registration', registry, transport }) @@ -524,7 +573,7 @@ describeUnix('PeerService over real Unix domain sockets', () => { const realTransport = createUnixSocketTransport() let dropFirstMessageAck = true const ackDroppingTransport: PeerTransport = { - listen: (options) => realTransport.listen(options), + ...realTransport, request: async (options) => { const response = await realTransport.request(options) if (dropFirstMessageAck && options.frame.type === 'message') { diff --git a/packages/core/tests/peer-tools.test.ts b/packages/core/tests/peer-tools.test.ts index 3e9854d..05c2f99 100644 --- a/packages/core/tests/peer-tools.test.ts +++ b/packages/core/tests/peer-tools.test.ts @@ -133,7 +133,6 @@ describe('peer model tools', () => { ) const onAskAuthority = vi.fn(async ({ preview }: Parameters>[0]) => ({ decision: 'allow-once' as const, - viewedComplete: true, canonicalPayloadSha256: preview.outboundPayload?.sha256, canonicalCallSha256: preview.canonicalCallSha256, authorityHash: preview.authorityHash, @@ -331,7 +330,6 @@ describe('peer model tools', () => { ) const onAskAuthority = vi.fn(async ({ preview }: Parameters>[0]) => ({ decision: 'deny' as const, - viewedComplete: true, canonicalPayloadSha256: preview.outboundPayload?.sha256, canonicalCallSha256: preview.canonicalCallSha256, authorityHash: preview.authorityHash, diff --git a/packages/core/tests/peer-transport.test.ts b/packages/core/tests/peer-transport.test.ts index c064fe7..c53b26e 100644 --- a/packages/core/tests/peer-transport.test.ts +++ b/packages/core/tests/peer-transport.test.ts @@ -23,6 +23,10 @@ function token(): string { return randomBytes(32).toString('base64url') } +function addressHint(): string { + return path.join(directory, 'peer.sock') +} + async function listenReplacement(address: string, sockets: Set): Promise { const server = net.createServer((socket) => { sockets.add(socket) @@ -55,17 +59,16 @@ async function closeReplacement(server: net.Server, sockets: Set): P describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { it('closes the bound listener and removes its owned socket when post-bind initialization fails', async () => { - const address = path.join(directory, 'post-bind-failure.sock') let boundAddress: string | undefined const chmod = vi.fn(async (filePath: string) => { boundAddress = filePath throw new Error('simulated post-bind chmod failure') }) - const transport = createUnixSocketTransport({ fileSystem: { ...fs, chmod } }) + const transport = createUnixSocketTransport({ fileSystem: { ...fs, chmod }, getSocketDir: () => directory }) await expect( transport.listen({ - address, + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => ({ v: 1, type: 'error', code: 'unused', message: 'unused' }), @@ -87,13 +90,13 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('aborts a listener while post-bind initialization is in flight without leaking the socket', async () => { - const address = path.join(directory, 'post-bind-abort.sock') let signalChmod!: () => void const chmodStarted = new Promise((resolve) => (signalChmod = resolve)) let releaseChmod!: () => void const chmodGate = new Promise((resolve) => (releaseChmod = resolve)) let boundAddress: string | undefined const transport = createUnixSocketTransport({ + getSocketDir: () => directory, fileSystem: { ...fs, chmod: vi.fn(async (filePath, mode) => { @@ -106,7 +109,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) const controller = new AbortController() const listening = transport.listen({ - address, + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => ({ v: 1, type: 'error', code: 'unused', message: 'unused' }), @@ -128,6 +131,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { const replacementSockets = new Set() let replaced = false const transport = createUnixSocketTransport({ + getSocketDir: () => directory, fileSystem: { ...fs, lstat: vi.fn(async (filePath: string) => { @@ -145,7 +149,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { await expect( transport.listen({ - address: path.join(directory, 'race.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => ({ v: 1, type: 'error', code: 'unused', message: 'unused' }), @@ -165,6 +169,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { let replacementIdentity: Awaited> | undefined const replacementSockets = new Set() const transport = createUnixSocketTransport({ + getSocketDir: () => directory, fileSystem: { ...fs, rename: vi.fn(async (oldPath: string, newPath: string) => { @@ -179,7 +184,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }, }) const server = await transport.listen({ - address: path.join(directory, 'close-lstat-rename.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => ({ v: 1, type: 'error', code: 'unused', message: 'unused' }), @@ -206,6 +211,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { let replacementIdentity: Awaited> | undefined const replacementSockets = new Set() const transport = createUnixSocketTransport({ + getSocketDir: () => directory, fileSystem: { ...fs, mkdir: vi.fn(async (filePath: string, options: { mode: number }) => { @@ -219,7 +225,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }, }) const server = await transport.listen({ - address: path.join(directory, 'close-rename-sentinel.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => ({ v: 1, type: 'error', code: 'unused', message: 'unused' }), @@ -240,11 +246,11 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('authenticates and completes a ping/pong round trip', async () => { - const transport = createUnixSocketTransport() + const transport = createUnixSocketTransport({ getSocketDir: () => directory }) const instanceId = randomUUID() const inboxToken = token() const server = await transport.listen({ - address: path.join(directory, 'peer.sock'), + address: addressHint(), instanceId, inboxToken, onRequest: async (frame) => { @@ -265,10 +271,10 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('rejects an incorrect token before dispatch', async () => { - const transport = createUnixSocketTransport() + const transport = createUnixSocketTransport({ getSocketDir: () => directory }) const onRequest = vi.fn() const server = await transport.listen({ - address: path.join(directory, 'auth.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest, @@ -286,9 +292,9 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('times out and honors AbortSignal while waiting for a reply', async () => { - const transport = createUnixSocketTransport() + const transport = createUnixSocketTransport({ getSocketDir: () => directory }) const server = await transport.listen({ - address: path.join(directory, 'timeout.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => new Promise(() => {}), @@ -303,7 +309,7 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { const actualToken = token() await server.close() const active = await transport.listen({ - address: path.join(directory, 'active.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken: actualToken, onRequest: async () => new Promise(() => {}), @@ -324,12 +330,12 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('drops malformed and oversized raw client frames without dispatching', async () => { - const transport = createUnixSocketTransport() + const transport = createUnixSocketTransport({ getSocketDir: () => directory }) const instanceId = randomUUID() const inboxToken = token() const onRequest = vi.fn() const server = await transport.listen({ - address: path.join(directory, 'raw.sock'), + address: addressHint(), instanceId, inboxToken, onRequest, @@ -349,10 +355,10 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('drains active connections on shutdown within the configured deadline', async () => { - const transport = createUnixSocketTransport() + const transport = createUnixSocketTransport({ getSocketDir: () => directory }) const inboxToken = token() const server = await transport.listen({ - address: path.join(directory, 'shutdown.sock'), + address: addressHint(), instanceId: randomUUID(), inboxToken, onRequest: async () => new Promise(() => {}), @@ -374,10 +380,9 @@ describe.runIf(process.platform !== 'win32')('Unix peer transport', () => { }) it('does not unlink a replacement socket when its owned path changes identity before close', async () => { - const address = path.join(directory, 'replaced.sock') - const transport = createUnixSocketTransport() + const transport = createUnixSocketTransport({ getSocketDir: () => directory }) const server = await transport.listen({ - address, + address: addressHint(), instanceId: randomUUID(), inboxToken: token(), onRequest: async () => ({ v: 1, type: 'error', code: 'unused', message: 'unused' }), diff --git a/packages/core/tests/shell-tool-execution.test.ts b/packages/core/tests/shell-tool-execution.test.ts index 797f955..6c0e5f7 100644 --- a/packages/core/tests/shell-tool-execution.test.ts +++ b/packages/core/tests/shell-tool-execution.test.ts @@ -229,7 +229,6 @@ describe('PTY shell tool transport', () => { const options: AgentOptions = { modelId: 'test-model', trustMode: true, printMode: false } const onAskAuthority = vi.fn(async (request: Parameters>[0]) => ({ decision: request.toolCallId === 'call-peer-write' ? ('deny' as const) : ('allow-once' as const), - viewedComplete: true, canonicalPayloadSha256: request.preview.outboundPayload?.sha256, canonicalCallSha256: request.preview.canonicalCallSha256, authorityHash: request.preview.authorityHash, diff --git a/packages/core/tests/windows-native-artifacts.test.ts b/packages/core/tests/windows-native-artifacts.test.ts new file mode 100644 index 0000000..080c94f --- /dev/null +++ b/packages/core/tests/windows-native-artifacts.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +import { resolveWindowsPeerBrokerArtifact } from '../src/peers/windows-peer-broker-artifact.js' + +interface ManifestEntry { + file: string + protocolVersion: number + sha256: string + sourceSha256: string +} + +interface NativeManifest { + manifestVersion: number + artifacts: Record<'x64' | 'arm64', Record<'shellSupervisor' | 'peerBroker', ManifestEntry>> +} + +const nativeRoot = path.resolve('packages/core/dist/native/windows') +const coreDir = path.resolve('packages/core') +const writeManifestScript = path.join(coreDir, 'scripts', 'write-native-manifest.mjs') +const execFileAsync = promisify(execFile) +let testRoot = '' + +beforeEach(async () => { + testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'x-code-native-artifacts-')) + await fs.cp(nativeRoot, testRoot, { recursive: true }) +}) + +afterEach(async () => { + await fs.rm(testRoot, { recursive: true, force: true }) +}) + +async function readManifest(root = testRoot): Promise { + return JSON.parse(await fs.readFile(path.join(root, 'manifest.json'), 'utf8')) as NativeManifest +} + +async function writeManifest(manifest: NativeManifest): Promise { + await fs.writeFile(path.join(testRoot, 'manifest.json'), JSON.stringify(manifest), 'utf8') +} + +describe('Windows native artifacts', () => { + it('packages both independently traceable helpers for x64 and arm64', async () => { + const manifest = await readManifest(nativeRoot) + expect(manifest.manifestVersion).toBe(2) + for (const arch of ['x64', 'arm64'] as const) { + for (const artifactName of ['shellSupervisor', 'peerBroker'] as const) { + const artifact = manifest.artifacts[arch][artifactName] + const bytes = await fs.readFile(path.join(nativeRoot, artifact.file)) + expect(createHash('sha256').update(bytes).digest('hex')).toBe(artifact.sha256) + expect(artifact.sourceSha256).toMatch(/^[a-f0-9]{64}$/) + } + expect(manifest.artifacts[arch].shellSupervisor.protocolVersion).toBe(2) + expect(manifest.artifacts[arch].peerBroker.protocolVersion).toBe(2) + } + expect(manifest.artifacts.x64.shellSupervisor.sourceSha256).not.toBe(manifest.artifacts.x64.peerBroker.sourceSha256) + }) + + it('validates protocol, path, hash, and PE architecture before launching a broker', async () => { + await expect(resolveWindowsPeerBrokerArtifact('x64', testRoot)).resolves.toMatchObject({ protocolVersion: 2 }) + await expect(resolveWindowsPeerBrokerArtifact('arm64', testRoot)).resolves.toMatchObject({ protocolVersion: 2 }) + + const protocol = await readManifest() + protocol.artifacts.x64.peerBroker.protocolVersion = 1 + await writeManifest(protocol) + await expect(resolveWindowsPeerBrokerArtifact('x64', testRoot)).rejects.toMatchObject({ + name: 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + }) + + const escaped = await readManifest(nativeRoot) + escaped.artifacts.x64.peerBroker.file = '../outside.exe' + await writeManifest(escaped) + await expect(resolveWindowsPeerBrokerArtifact('x64', testRoot)).rejects.toMatchObject({ + name: 'PEER_WINDOWS_HELPER_HASH_MISMATCH', + }) + + const wrongArchitecture = await readManifest(nativeRoot) + const armBytes = await fs.readFile(path.join(nativeRoot, wrongArchitecture.artifacts.arm64.peerBroker.file)) + const x64Path = path.join(testRoot, wrongArchitecture.artifacts.x64.peerBroker.file) + await fs.writeFile(x64Path, armBytes) + wrongArchitecture.artifacts.x64.peerBroker.sha256 = createHash('sha256').update(armBytes).digest('hex') + await writeManifest(wrongArchitecture) + await expect(resolveWindowsPeerBrokerArtifact('x64', testRoot)).rejects.toMatchObject({ + name: 'PEER_WINDOWS_HELPER_HASH_MISMATCH', + }) + }) + + it('fails closed for unsupported Windows architectures', async () => { + await expect(resolveWindowsPeerBrokerArtifact('ia32', testRoot)).rejects.toMatchObject({ + name: 'PEER_WINDOWS_UNSUPPORTED_ARCH', + }) + }) + + it('preserves source provenance for binaries that were not rebuilt', async () => { + const scriptRoot = await fs.mkdtemp(path.join(coreDir, '.native-manifest-test-')) + try { + await fs.cp(nativeRoot, scriptRoot, { recursive: true }) + const manifest = await readManifest(scriptRoot) + const currentX64Source = manifest.artifacts.x64.peerBroker.sourceSha256 + manifest.artifacts.arm64.peerBroker.sourceSha256 = '1'.repeat(64) + await fs.writeFile(path.join(scriptRoot, 'manifest.json'), JSON.stringify(manifest), 'utf8') + + await execFileAsync(process.execPath, [writeManifestScript, scriptRoot, '--built', 'x64:peerBroker']) + const updated = await readManifest(scriptRoot) + expect(updated.artifacts.x64.peerBroker.sourceSha256).toBe(currentX64Source) + expect(updated.artifacts.arm64.peerBroker.sourceSha256).toBe('1'.repeat(64)) + + await expect(execFileAsync(process.execPath, [writeManifestScript, scriptRoot])).rejects.toThrow( + /explicitly built artifact/i, + ) + + await fs.appendFile(path.join(scriptRoot, updated.artifacts.arm64.peerBroker.file), Buffer.from([0])) + await expect( + execFileAsync(process.execPath, [writeManifestScript, scriptRoot, '--built', 'x64:peerBroker']), + ).rejects.toThrow(/cannot preserve provenance/i) + } finally { + await fs.rm(scriptRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/tests/windows-peer-broker-process.test.ts b/packages/core/tests/windows-peer-broker-process.test.ts new file mode 100644 index 0000000..78fea99 --- /dev/null +++ b/packages/core/tests/windows-peer-broker-process.test.ts @@ -0,0 +1,49 @@ +import { execFile } from 'node:child_process' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +describe('Windows peer broker process wrapper', () => { + it('survives a broker exit while writes are queued', async () => { + const moduleUrl = pathToFileURL(path.resolve('packages/core/dist/peers/windows-peer-broker-process.js')).href + const script = ` + import { spawn } from 'node:child_process' + import { spawnWindowsPeerBrokerProcess } from ${JSON.stringify(moduleUrl)} + + let streamErrors = 0 + const broker = spawnWindowsPeerBrokerProcess({ + artifact: { executablePath: process.execPath }, + mode: 'broker', + spawnBroker(_file, _args, spawnOptions) { + return spawn( + process.execPath, + ['--input-type=module', '--eval', 'process.stdin.destroy(); setTimeout(() => process.exit(0), 20)'], + spawnOptions, + ) + }, + debugKey: 'test.windows-peer-broker-process', + onFrame() {}, + onError() { + streamErrors++ + }, + }) + const payload = Buffer.alloc(131_072) + const writes = Array.from({ length: 16 }, (_, index) => + broker.send({ kind: 1, operationId: index + 1, payload }), + ) + await Promise.allSettled(writes) + await broker.closed + await new Promise((resolve) => setTimeout(resolve, 50)) + process.stdout.write('survived:' + streamErrors) + ` + + const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '--eval', script], { + timeout: 10_000, + windowsHide: true, + }) + + expect(stdout).toMatch(/^survived:[1-9]\d*$/) + }) +}) diff --git a/packages/core/tests/windows-peer-broker-protocol.test.ts b/packages/core/tests/windows-peer-broker-protocol.test.ts new file mode 100644 index 0000000..8a6a05c --- /dev/null +++ b/packages/core/tests/windows-peer-broker-protocol.test.ts @@ -0,0 +1,113 @@ +import { + WINDOWS_PEER_BROKER_HEADER_BYTES, + WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES, + WindowsPeerBrokerFrameDecoder, + WindowsPeerBrokerFrameKind, + decodeInboundRequestPayload, + decodeOneStringPayload, + decodeOperationErrorPayload, + decodePeerFramePayload, + encodeOutboundRequestPayload, + encodePeerFramePayload, + encodeStartServerPayload, + encodeWindowsPeerBrokerFrame, +} from '../src/peers/windows-peer-broker-protocol.js' + +function frame(kind = WindowsPeerBrokerFrameKind.OutboundResponse, payload = Buffer.from('payload')): Buffer { + return encodeWindowsPeerBrokerFrame({ kind, operationId: 7, payload }) +} + +describe('Windows peer broker protocol', () => { + it('decodes every fragmentation boundary and merged frames', () => { + const encoded = frame() + for (let split = 1; split < encoded.length; split++) { + const decoder = new WindowsPeerBrokerFrameDecoder() + expect(decoder.push(encoded.subarray(0, split))).toEqual([]) + expect(decoder.push(encoded.subarray(split))).toEqual([ + { kind: WindowsPeerBrokerFrameKind.OutboundResponse, operationId: 7, payload: Buffer.from('payload') }, + ]) + expect(() => decoder.finish()).not.toThrow() + } + + const decoder = new WindowsPeerBrokerFrameDecoder() + expect(decoder.push(Buffer.concat([encoded, encoded]))).toHaveLength(2) + }) + + it('rejects malformed headers, flags, versions, lengths, and truncated EOF', () => { + const valid = frame() + for (const mutate of [ + (bytes: Buffer) => (bytes[0] = 0), + (bytes: Buffer) => (bytes[4] = 1), + (bytes: Buffer) => (bytes[5] = 0x40), + (bytes: Buffer) => (bytes[6] = 1), + (bytes: Buffer) => bytes.writeUInt32LE(WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES + 1, 12), + ]) { + const bytes = Buffer.from(valid) + mutate(bytes) + expect(() => new WindowsPeerBrokerFrameDecoder().push(bytes)).toThrow() + } + + const decoder = new WindowsPeerBrokerFrameDecoder() + decoder.push(valid.subarray(0, WINDOWS_PEER_BROKER_HEADER_BYTES - 1)) + expect(() => decoder.finish()).toThrow('Truncated') + }) + + it('locks the exact control payload boundary', () => { + expect(() => + encodeWindowsPeerBrokerFrame({ + kind: WindowsPeerBrokerFrameKind.OutboundResponse, + operationId: 1, + payload: Buffer.alloc(WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES), + }), + ).not.toThrow() + expect(() => + encodeWindowsPeerBrokerFrame({ + kind: WindowsPeerBrokerFrameKind.OutboundResponse, + operationId: 1, + payload: Buffer.alloc(WINDOWS_PEER_BROKER_MAX_PAYLOAD_BYTES + 1), + }), + ).toThrow('payload limit') + }) + + it('encodes fixed payload layouts and rejects suffixes and invalid UTF-8', () => { + const start = encodeStartServerPayload({ + namespaceId: '0123456789ab', + instanceId: '12345678-1234-4234-8234-123456789abc', + inboxToken: 'a'.repeat(43), + }) + expect(start.readUInt16LE(0)).toBe(12) + expect(start.subarray(2, 14).toString()).toBe('0123456789ab') + + const outbound = encodeOutboundRequestPayload({ + address: '\\\\.\\pipe\\x-code-peer-v2-0123456789ab-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + targetToken: 'b'.repeat(43), + senderInstanceId: '12345678-1234-4234-8234-123456789abc', + timeoutMs: 3_000, + peerFrame: Buffer.from('{"v":1}\n'), + }) + expect(outbound.length).toBeGreaterThan(100) + + const inbound = Buffer.concat([ + Buffer.from([36, 0]), + Buffer.from('12345678-1234-4234-8234-123456789abc'), + encodePeerFramePayload(Buffer.from('frame')), + ]) + expect(decodeInboundRequestPayload(inbound)).toEqual({ + senderInstanceId: '12345678-1234-4234-8234-123456789abc', + peerFrame: Buffer.from('frame'), + }) + expect(decodePeerFramePayload(encodePeerFramePayload(Buffer.from('frame')))).toEqual(Buffer.from('frame')) + expect(() => decodeOneStringPayload(Buffer.from([2, 0, 0xc3, 0x28]))).toThrow('UTF-8') + expect(() => decodeOneStringPayload(Buffer.from([1, 0, 0x61, 0x00]))).toThrow('suffix') + }) + + it('decodes stable operation errors', () => { + const errorPayload = Buffer.concat([ + Buffer.from([15, 0]), + Buffer.from('PEER_TEST_ERROR'), + Buffer.from([7, 0]), + Buffer.from('failure'), + ]) + expect(decodeOperationErrorPayload(errorPayload)).toEqual({ code: 'PEER_TEST_ERROR', message: 'failure' }) + }) +}) diff --git a/packages/core/tests/windows-peer-transport.test.ts b/packages/core/tests/windows-peer-transport.test.ts new file mode 100644 index 0000000..8983918 --- /dev/null +++ b/packages/core/tests/windows-peer-transport.test.ts @@ -0,0 +1,548 @@ +import { type ChildProcessWithoutNullStreams, execFile, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { promisify } from 'node:util' + +import { createPeerIdentity } from '../src/peers/identity.js' +import { createPeerRegistry } from '../src/peers/registry.js' +import { createPeerService } from '../src/peers/service.js' +import { createWindowsNamedPipeTransport } from '../src/peers/windows-named-pipe-transport.js' +import { resolveWindowsPeerBrokerArtifact } from '../src/peers/windows-peer-broker-artifact.js' +import { + WindowsPeerBrokerFrameDecoder, + WindowsPeerBrokerFrameKind, + decodeOneStringPayload, + encodeStartServerPayload, + encodeWindowsPeerBrokerFrame, +} from '../src/peers/windows-peer-broker-protocol.js' +import { createWindowsPeerRuntimeSecurity } from '../src/peers/windows-peer-runtime-security.js' + +const describeWindows = process.platform === 'win32' ? describe : describe.skip +const addressHint = path.join(os.tmpdir(), 'x-code-windows-peer.sock') +const execFileAsync = promisify(execFile) + +describeWindows('Windows named pipe peer transport', () => { + it('creates and secures an absent user runtime root', async () => { + const root = path.join(os.homedir(), `.x-code-peer-clean-root-${randomUUID()}`) + const registry = createPeerRegistry({ + windowsRuntimeSecurity: createWindowsPeerRuntimeSecurity({ root }), + }) + try { + await registry.initialize() + expect((await fs.stat(registry.paths().registryDir)).isDirectory()).toBe(true) + expect(registry.paths().namespaceId).toMatch(/^[a-f0-9]{12}$/) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it('rejects a runtime root reached through a junction', async () => { + const base = path.join(os.homedir(), `.x-code-peer-junction-${randomUUID()}`) + const target = path.join(base, 'target') + const root = path.join(base, 'root') + await fs.mkdir(target, { recursive: true }) + await fs.symlink(target, root, 'junction') + try { + await expect(createWindowsPeerRuntimeSecurity({ root }).initialize()).rejects.toMatchObject({ + name: 'PEER_WINDOWS_RUNTIME_UNSAFE', + }) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it('rejects a runtime root replaceable by another ordinary principal', async () => { + const root = path.join(os.homedir(), `.x-code-peer-insecure-acl-${randomUUID()}`) + await fs.mkdir(root, { recursive: true }) + await execFileAsync('icacls.exe', [root, '/grant', '*S-1-1-0:(OI)(CI)M']) + try { + await expect(createWindowsPeerRuntimeSecurity({ root }).initialize()).rejects.toMatchObject({ + name: 'PEER_WINDOWS_RUNTIME_UNSAFE', + }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it('discovers and sends between two real broker-backed services', async () => { + const suffix = Date.now().toString(36) + const sender = createPeerService({ enabled: true, name: `windows-sender-${suffix}` }) + const receiver = createPeerService({ enabled: true, name: `windows-receiver-${suffix}` }) + try { + await sender.start() + await receiver.start() + expect(sender.isAvailable()).toBe(true) + expect(receiver.isAvailable()).toBe(true) + const peers = await sender.listAgents() + expect(peers.some((peer) => peer.address === receiver.identity!.address)).toBe(true) + await expect( + sender.sendMessage(receiver.identity!.address, 'Windows transport integration'), + ).resolves.toMatchObject({ + success: true, + status: 'delivered', + }) + } finally { + await Promise.all([sender.shutdown(), receiver.shutdown()]) + } + }) + + it('rejects a wrong token before dispatching to Node', async () => { + const senderRegistry = createPeerRegistry() + const receiverRegistry = createPeerRegistry() + await Promise.all([senderRegistry.initialize(), receiverRegistry.initialize()]) + const sender = createPeerIdentity({ name: 'transport-sender' }) + const receiver = createPeerIdentity({ name: 'transport-receiver' }) + const senderTransport = createWindowsNamedPipeTransport({ getRuntimePaths: () => senderRegistry.paths() }) + const receiverTransport = createWindowsNamedPipeTransport({ getRuntimePaths: () => receiverRegistry.paths() }) + let dispatches = 0 + const senderServer = await senderTransport.listen({ + address: addressHint, + instanceId: sender.instanceId, + inboxToken: sender.inboxToken, + onRequest: async () => ({ v: 1, type: 'error', code: 'PEER_TEST', message: 'unexpected' }), + }) + const receiverServer = await receiverTransport.listen({ + address: addressHint, + instanceId: receiver.instanceId, + inboxToken: receiver.inboxToken, + onRequest: async (frame) => { + dispatches++ + return frame + }, + }) + try { + await expect( + senderTransport.request({ + address: receiverServer.address, + targetToken: 'x'.repeat(43), + senderInstanceId: sender.instanceId, + frame: { v: 1, type: 'ping', requestId: randomUUID() }, + timeoutMs: 1_000, + }), + ).rejects.toMatchObject({ name: 'PEER_WINDOWS_PIPE_IO_FAILED' }) + expect(dispatches).toBe(0) + } finally { + await Promise.all([senderServer.close(), receiverServer.close()]) + } + }) + + it('propagates abort and bounds shutdown while an inbound callback remains active', async () => { + const senderRegistry = createPeerRegistry() + const receiverRegistry = createPeerRegistry() + await Promise.all([senderRegistry.initialize(), receiverRegistry.initialize()]) + const sender = createPeerIdentity({ name: 'abort-sender' }) + const receiver = createPeerIdentity({ name: 'abort-receiver' }) + const senderTransport = createWindowsNamedPipeTransport({ getRuntimePaths: () => senderRegistry.paths() }) + const receiverTransport = createWindowsNamedPipeTransport({ getRuntimePaths: () => receiverRegistry.paths() }) + let dispatches = 0 + const senderServer = await senderTransport.listen({ + address: addressHint, + instanceId: sender.instanceId, + inboxToken: sender.inboxToken, + onRequest: async () => ({ v: 1, type: 'error', code: 'PEER_TEST', message: 'unexpected' }), + }) + const receiverServer = await receiverTransport.listen({ + address: addressHint, + instanceId: receiver.instanceId, + inboxToken: receiver.inboxToken, + onRequest: async (frame) => { + dispatches++ + if (dispatches === 1) return new Promise(() => {}) + return frame + }, + }) + const controller = new AbortController() + const request = senderTransport.request({ + address: receiverServer.address, + targetToken: receiver.inboxToken, + senderInstanceId: sender.instanceId, + frame: { v: 1, type: 'ping', requestId: randomUUID() }, + timeoutMs: 5_000, + signal: controller.signal, + }) + while (dispatches === 0) await delay(5) + controller.abort() + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + const followUpRequestId = randomUUID() + await expect( + senderTransport.request({ + address: receiverServer.address, + targetToken: receiver.inboxToken, + senderInstanceId: sender.instanceId, + frame: { v: 1, type: 'ping', requestId: followUpRequestId }, + timeoutMs: 2_000, + }), + ).resolves.toMatchObject({ type: 'ping', requestId: followUpRequestId }) + const startedAt = Date.now() + await Promise.all([senderServer.close({ deadlineMs: 300 }), receiverServer.close({ deadlineMs: 300 })]) + expect(Date.now() - startedAt).toBeLessThan(1_500) + }) + + it('exits and releases its pipe when the parent stdin channel reaches EOF', async () => { + const registry = createPeerRegistry() + await registry.initialize() + const identity = createPeerIdentity({ name: 'parent-eof' }) + const artifact = await resolveWindowsPeerBrokerArtifact() + const child = spawn(artifact.executablePath, ['broker', '--protocol', '2'], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + const decoder = new WindowsPeerBrokerFrameDecoder() + const address = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', () => reject(new Error('broker exited before SERVER_READY'))) + child.stdout.on('data', (chunk: Buffer) => { + try { + for (const frame of decoder.push(chunk)) { + if (frame.kind === WindowsPeerBrokerFrameKind.ServerReady) resolve(decodeOneStringPayload(frame.payload)) + } + } catch (error) { + reject(error) + } + }) + child.stdin.write( + encodeWindowsPeerBrokerFrame({ + kind: WindowsPeerBrokerFrameKind.StartServer, + operationId: 1, + payload: encodeStartServerPayload({ + namespaceId: registry.paths().namespaceId!, + instanceId: identity.instanceId, + inboxToken: identity.inboxToken, + }), + }), + ) + }) + const exited = new Promise((resolve) => child.once('exit', () => resolve())) + child.stdin.end() + await Promise.race([ + exited, + delay(2_000).then(() => { + child.kill() + throw new Error('broker did not exit after parent EOF') + }), + ]) + await expect( + new Promise((resolve, reject) => { + const socket = net.connect(address) + socket.once('connect', () => { + socket.destroy() + reject(new Error('released peer pipe remained connectable')) + }) + socket.once('error', () => resolve()) + }), + ).resolves.toBeUndefined() + }) + + it('receives the graceful shutdown acknowledgement from a real broker', async () => { + const registry = createPeerRegistry() + await registry.initialize() + const identity = createPeerIdentity({ name: 'graceful-shutdown' }) + let acknowledged = false + let resolveAcknowledgement!: () => void + let rejectAcknowledgement!: (error: unknown) => void + const acknowledgement = new Promise((resolve, reject) => { + resolveAcknowledgement = resolve + rejectAcknowledgement = reject + }) + let resolveExit!: (status: { code: number | null; signal: NodeJS.Signals | null }) => void + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + resolveExit = resolve + }) + const spawnBroker: typeof spawn = ((command, args, options) => { + const child = spawn(command, args ?? [], options as never) as ChildProcessWithoutNullStreams + const decoder = new WindowsPeerBrokerFrameDecoder() + child.stdout.on('data', (chunk: Buffer) => { + try { + for (const frame of decoder.push(chunk)) { + if (frame.kind !== WindowsPeerBrokerFrameKind.ShutdownComplete) continue + acknowledged = true + resolveAcknowledgement() + } + } catch (error) { + rejectAcknowledgement(error) + } + }) + child.once('close', (code, signal) => { + if (!acknowledged) rejectAcknowledgement(new Error('broker exited before SHUTDOWN_COMPLETE')) + resolveExit({ code, signal }) + }) + return child + }) as typeof spawn + const transport = createWindowsNamedPipeTransport({ getRuntimePaths: () => registry.paths(), spawnBroker }) + const server = await transport.listen({ + address: addressHint, + instanceId: identity.instanceId, + inboxToken: identity.inboxToken, + onRequest: async (frame) => frame, + }) + const gracefulExit = expect(Promise.all([acknowledgement, exited])).resolves.toEqual([ + undefined, + { code: 0, signal: null }, + ]) + + await server.close({ deadlineMs: 500 }) + await gracefulExit + }) + + it('reports an unexpected broker exit through server.closed', async () => { + const registry = createPeerRegistry() + await registry.initialize() + const children: ChildProcessWithoutNullStreams[] = [] + const spawnBroker: typeof spawn = ((command, args, options) => { + const child = spawn(command, args ?? [], options as never) as ChildProcessWithoutNullStreams + children.push(child) + return child + }) as typeof spawn + const identity = createPeerIdentity({ name: 'crash-receiver' }) + const transport = createWindowsNamedPipeTransport({ getRuntimePaths: () => registry.paths(), spawnBroker }) + const server = await transport.listen({ + address: addressHint, + instanceId: identity.instanceId, + inboxToken: identity.inboxToken, + onRequest: async (frame) => frame, + }) + expect(children).toHaveLength(1) + children[0]!.kill() + await expect(server.closed!).resolves.toMatchObject({ expected: false }) + }) + + it('survives clients that disconnect before authentication', async () => { + const registry = createPeerRegistry() + await registry.initialize() + const identity = createPeerIdentity({ name: 'disconnect-stress' }) + const transport = createWindowsNamedPipeTransport({ getRuntimePaths: () => registry.paths() }) + const server = await transport.listen({ + address: addressHint, + instanceId: identity.instanceId, + inboxToken: identity.inboxToken, + onRequest: async (frame) => frame, + }) + try { + for (let index = 0; index < 4_000; index++) { + await new Promise((resolve) => { + const socket = net.connect(server.address) + socket.once('connect', () => { + socket.destroy() + resolve() + }) + socket.once('error', () => resolve()) + }) + } + + const requestId = randomUUID() + await expect( + transport.request({ + address: server.address, + targetToken: identity.inboxToken, + senderInstanceId: identity.instanceId, + frame: { v: 1, type: 'ping', requestId }, + timeoutMs: 2_000, + }), + ).resolves.toMatchObject({ type: 'ping', requestId }) + await expect(Promise.race([server.closed!.then(() => true), delay(50).then(() => false)])).resolves.toBe(false) + } finally { + await server.close() + } + }, 30_000) + + it('releases capacity after 256 concurrent unreachable operations', async () => { + const registry = createPeerRegistry() + await registry.initialize() + const identity = createPeerIdentity({ name: 'success-capacity' }) + const transport = createWindowsNamedPipeTransport({ getRuntimePaths: () => registry.paths() }) + const server = await transport.listen({ + address: addressHint, + instanceId: identity.instanceId, + inboxToken: identity.inboxToken, + onRequest: async (frame) => frame, + }) + try { + for (let cycle = 0; cycle < 3; cycle++) { + const unreachableAddress = `\\\\.\\pipe\\x-code-peer-v2-${registry.paths().namespaceId}-${'B'.repeat(32)}` + const attempts = await Promise.allSettled( + Array.from({ length: 256 }, () => + transport.request({ + address: unreachableAddress, + targetToken: identity.inboxToken, + senderInstanceId: identity.instanceId, + frame: { v: 1, type: 'ping', requestId: randomUUID() }, + timeoutMs: 500, + }), + ), + ) + expect(attempts.every((attempt) => attempt.status === 'rejected')).toBe(true) + + const requestId = randomUUID() + await expect( + transport.request({ + address: server.address, + targetToken: identity.inboxToken, + senderInstanceId: identity.instanceId, + frame: { v: 1, type: 'ping', requestId }, + timeoutMs: 2_000, + }), + ).resolves.toMatchObject({ type: 'ping', requestId }) + } + } finally { + await server.close() + } + }, 15_000) + + it('fails the service closed and removes its registration after a broker crash', async () => { + const registry = createPeerRegistry() + const children: ChildProcessWithoutNullStreams[] = [] + const spawnBroker: typeof spawn = ((command, args, options) => { + const child = spawn(command, args ?? [], options as never) as ChildProcessWithoutNullStreams + children.push(child) + return child + }) as typeof spawn + const transport = createWindowsNamedPipeTransport({ getRuntimePaths: () => registry.paths(), spawnBroker }) + const service = createPeerService({ + enabled: true, + name: `crash-cleanup-${Date.now().toString(36)}`, + registry, + transport, + }) + try { + await service.start() + expect(service.isAvailable()).toBe(true) + await expect(registry.read(service.identity!.instanceId)).resolves.not.toBeNull() + expect(children).toHaveLength(1) + + children[0]!.kill() + await vi.waitFor(() => expect(service.isAvailable()).toBe(false)) + await vi.waitFor(async () => expect(await registry.read(service.identity!.instanceId)).toBeNull()) + expect(service.getUnavailableReason()).toBeTruthy() + } finally { + await service.shutdown() + } + }) + + it('accepts late canceled terminals but rejects unsolicited duplicate terminals', async () => { + const namespaceId = '0123456789ab' + const fakeBroker = String.raw` + let buffered = Buffer.alloc(0) + const pending = new Map() + let cancellations = 0 + const send = (kind, operationId, payload = Buffer.alloc(0)) => { + const response = Buffer.allocUnsafe(16 + payload.length) + response.write('XCPB') + response[4] = 2 + response[5] = kind + response.writeUInt16LE(0, 6) + response.writeUInt32LE(operationId, 8) + response.writeUInt32LE(payload.length, 12) + payload.copy(response, 16) + process.stdout.write(response) + } + const encodeBytes = (bytes) => { + const payload = Buffer.allocUnsafe(4 + bytes.length) + payload.writeUInt32LE(bytes.length, 0) + bytes.copy(payload, 4) + return payload + } + const readPeerFrame = (payload) => { + let offset = 0 + for (let index = 0; index < 3; index++) { + const length = payload.readUInt16LE(offset) + offset += 2 + length + } + offset += 4 + const length = payload.readUInt32LE(offset) + return Buffer.from(payload.subarray(offset + 4, offset + 4 + length)) + } + process.stdin.on('data', (chunk) => { + buffered = Buffer.concat([buffered, chunk]) + while (buffered.length >= 16) { + const payloadLength = buffered.readUInt32LE(12) + const frameLength = 16 + payloadLength + if (buffered.length < frameLength) return + const kind = buffered[5] + const operationId = buffered.readUInt32LE(8) + const payload = buffered.subarray(16, frameLength) + buffered = buffered.subarray(frameLength) + if (kind === 2) { + const namespaceLength = payload.readUInt16LE(0) + const namespace = payload.subarray(2, 2 + namespaceLength).toString('utf8') + const address = '\\\\.\\pipe\\x-code-peer-v2-' + namespace + '-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + const addressBytes = Buffer.from(address) + const responsePayload = Buffer.allocUnsafe(2 + addressBytes.length) + responsePayload.writeUInt16LE(addressBytes.length) + addressBytes.copy(responsePayload, 2) + send(0x82, 0, responsePayload) + } else if (kind === 3) { + pending.set(operationId, readPeerFrame(payload)) + setTimeout(() => { + const peerFrame = pending.get(operationId) + if (!peerFrame) return + pending.delete(operationId) + send(0x84, operationId, encodeBytes(peerFrame)) + if (cancellations === 300) send(0x84, operationId, encodeBytes(peerFrame)) + }, 10) + } else if (kind === 5) { + cancellations++ + const peerFrame = pending.get(operationId) + if (!peerFrame) continue + pending.delete(operationId) + send(0x84, operationId, encodeBytes(peerFrame)) + } else if (kind === 6) { + send(0x88, 0) + setTimeout(() => process.exit(0), 10) + } + } + }) + ` + const spawnBroker = (() => + spawn(process.execPath, ['-e', fakeBroker], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) as ChildProcessWithoutNullStreams) as unknown as typeof spawn + const transport = createWindowsNamedPipeTransport({ + getRuntimePaths: () => ({ namespaceId }), + artifact: { executablePath: process.execPath, protocolVersion: 2, sha256: '0'.repeat(64) }, + spawnBroker, + }) + const identity = createPeerIdentity({ name: 'bounded-operation-client' }) + const server = await transport.listen({ + address: addressHint, + instanceId: identity.instanceId, + inboxToken: identity.inboxToken, + onRequest: async (frame) => frame, + }) + const request = (signal?: AbortSignal, targetToken = identity.inboxToken) => + transport.request({ + address: server.address, + targetToken, + senderInstanceId: identity.instanceId, + frame: { v: 1, type: 'ping', requestId: randomUUID() }, + timeoutMs: 60_000, + signal, + }) + + try { + for (let index = 0; index < 300; index++) { + await expect(request(undefined, 'x'.repeat(65_536))).rejects.toMatchObject({ + name: 'PEER_WINDOWS_HELPER_PROTOCOL_MISMATCH', + }) + } + + for (let index = 0; index < 300; index++) { + const controller = new AbortController() + const pending = request(controller.signal) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + } + await expect(request()).resolves.toMatchObject({ type: 'ping' }) + await expect(server.closed).resolves.toEqual({ + expected: false, + reason: 'Unexpected terminal operation ID', + }) + } finally { + await server.close({ deadlineMs: 0 }) + } + }) +}) diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index f628571..cf11388 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -5,11 +5,18 @@ import { resolve } from 'node:path' const MIB = 1024 * 1024 const limits = { packed: 3.5 * MIB, - unpacked: 12 * MIB, - files: 40, + unpacked: 12.75 * MIB, + files: 42, } const cliPackage = JSON.parse(readFileSync(resolve('packages/cli/package.json'), 'utf8')) const requiredRuntimeDependencies = ['@vscode/ripgrep', 'fs-ext-extra-prebuilt', 'undici'] +const requiredNativeArtifacts = [ + 'dist/native/windows/x64/xc-shell-supervisor.exe', + 'dist/native/windows/x64/xc-peer-broker.exe', + 'dist/native/windows/arm64/xc-shell-supervisor.exe', + 'dist/native/windows/arm64/xc-peer-broker.exe', +] +const nativeArtifactLimit = 0.4 * MIB const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' const result = spawnSync(npm, ['pack', './packages/cli', '--dry-run', '--ignore-scripts', '--json'], { @@ -48,6 +55,15 @@ for (const dependency of requiredRuntimeDependencies) { violations.push(`required runtime dependency is not declared: ${dependency}`) } } +for (const artifactPath of requiredNativeArtifacts) { + const artifact = files.find((file) => file.path === artifactPath) + if (!artifact) violations.push(`required native artifact is missing: ${artifactPath}`) + else if (Number(artifact.size) > nativeArtifactLimit) { + violations.push( + `native artifact ${artifactPath} size ${formatBytes(Number(artifact.size))} exceeds ${formatBytes(nativeArtifactLimit)}`, + ) + } +} if (!Number.isFinite(packedSize) || packedSize > limits.packed) { violations.push(`packed size ${formatBytes(packedSize)} exceeds ${formatBytes(limits.packed)}`)