diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a330ff866..c8b7c850a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,6 +252,26 @@ jobs: echo "NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT=$NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT" } >> "$GITHUB_ENV" + - name: Require signed Grok compatibility registry + shell: bash + env: + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL: ${{ secrets.GROK_SCHEMA_REGISTRY_URL }} + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY: ${{ secrets.GROK_SCHEMA_REGISTRY_PUBLIC_KEY }} + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS: ${{ secrets.GROK_SCHEMA_REGISTRY_PUBLIC_KEYS }} + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT: ${{ secrets.GROK_SCHEMA_REGISTRY_CHECKPOINT }} + run: | + node scripts/check-grok-registry-release.mjs + { + echo "NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL=$NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL" + echo "NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY<> "$GITHUB_ENV" + # Decode the App Store Connect API key onto the runner for the custom # macOS release script. The path is exported into the env for later # steps. Runs only on the macOS leg. diff --git a/apps/ios/CovenCave/CovenCave/State/AppModel.swift b/apps/ios/CovenCave/CovenCave/State/AppModel.swift index 4fb230c7f..641e0098f 100644 --- a/apps/ios/CovenCave/CovenCave/State/AppModel.swift +++ b/apps/ios/CovenCave/CovenCave/State/AppModel.swift @@ -73,6 +73,13 @@ final class AppModel { var familiarOrder: [String] = [] var threads: [ChatThread] = [] + /// Default Chats destination: the newest active conversation. Pinning only + /// affects list order and never makes an older thread the launch default. + var mostRecentThread: ChatThread? { + threads + .filter { !$0.archived } + .max { $0.updatedAt < $1.updatedAt } + } /// Process-lifetime launch intent. It survives destination remounts until a /// matching hydrated thread can be opened, then is consumed exactly once. var launchThreadId: String? diff --git a/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift b/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift index 0b1b4d3f7..af4b8daab 100644 --- a/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift +++ b/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift @@ -101,9 +101,11 @@ struct ChatsHomeView: View { .onAppear { consumeLaunchThreadIntent() consumeGlobalRequests() + selectMostRecentThreadIfNeeded() } .onChange(of: app.threads.map(\.id)) { _, _ in consumeLaunchThreadIntent() + selectMostRecentThreadIfNeeded() } // A slash command (`/new`, `/familiar `) or a task link asked to // open a specific thread — surface it in the detail column. @@ -390,6 +392,19 @@ struct ChatsHomeView: View { open(.thread(thread)) } + /// Open Chats at the latest active conversation without stealing focus from + /// a deep link, cross-view handoff, New Chat, or an existing selection. + private func selectMostRecentThreadIfNeeded() { + guard selection == nil, + !showNewChat, + app.threadToOpen == nil, + app.launchThreadId == nil, + !app.newChatRequested, + let thread = app.mostRecentThread + else { return } + open(.thread(thread)) + } + /// Consume a cross-destination thread handoff on first appearance and on /// later updates. Clearing the one-shot intent prevents re-appearance from /// reopening the same conversation. diff --git a/apps/ios/CovenCave/CovenCave/Views/RootView.swift b/apps/ios/CovenCave/CovenCave/Views/RootView.swift index dfca35b41..92d3aa33c 100644 --- a/apps/ios/CovenCave/CovenCave/Views/RootView.swift +++ b/apps/ios/CovenCave/CovenCave/Views/RootView.swift @@ -280,7 +280,7 @@ struct ConnectingView: View { .accessibilityHidden(true) .padding(.bottom, 34) - Text("Opening the Cave") + Text("Entering the Cave") .font(.title.weight(.medium)) .fontDesign(.serif) .italic() diff --git a/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift b/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift index b9592776f..d91944362 100644 --- a/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift +++ b/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift @@ -34,4 +34,31 @@ final class LaunchThreadIntentTests: XCTestCase { app.threads = [expected] XCTAssertTrue(app.consumeLaunchThreadIntent() === expected) } + + func testMostRecentThreadUsesUpdateTimeAndSkipsArchivedThreads() { + let app = AppModel() + let olderPinned = ChatThread(id: "older-pinned", title: "Older pinned", familiarIds: []) + olderPinned.updatedAt = Date(timeIntervalSince1970: 100) + olderPinned.pinned = true + + let newest = ChatThread(id: "newest", title: "Newest", familiarIds: []) + newest.updatedAt = Date(timeIntervalSince1970: 200) + + let archived = ChatThread(id: "archived", title: "Archived", familiarIds: []) + archived.updatedAt = Date(timeIntervalSince1970: 300) + archived.archived = true + + app.threads = [olderPinned, archived, newest] + + XCTAssertTrue(app.mostRecentThread === newest) + } + + func testMostRecentThreadIsNilWithoutAnActiveConversation() { + let app = AppModel() + let archived = ChatThread(id: "archived", title: "Archived", familiarIds: []) + archived.archived = true + app.threads = [archived] + + XCTAssertNil(app.mostRecentThread) + } } diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md new file mode 100644 index 000000000..0965ec738 --- /dev/null +++ b/docs/grok-compatibility-registry.md @@ -0,0 +1,15 @@ +# Grok Build compatibility registry + +Grok Build's built-in profile is limited to the xAI-documented `text`, `thought`, `end`, and `error` `streaming-json` frames. It contains no tool-event aliases. A tool schema is enabled only when the exact locally resolved launcher advertises the value-bearing `--output-format streaming-json` option and a selected Ed25519-signed bundle explicitly names every envelope field and lifecycle event **and pins those aliases to exact locally probed Grok Build versions**. Help/version probes never receive credential-bearing environment variables and run no model request. + +Release configuration is public verification material, never a signing key: + +- `GROK_SCHEMA_REGISTRY_URL` — canonical credential-free HTTPS bundle URL. +- `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. A bundle signed against a multi-key keyring must carry its exact `keyId`. +- `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. + +The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`; production reads only those packaged anchors. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`, which production deliberately ignores. Registry downloads reject redirects, credentials, oversized or stalled bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and keeps a bounded immutable high-water journal so a resumed stale writer cannot lower the accepted sequence. It is always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. If a newer remote contract was previously accepted, its cache expires, or its anchor is missing/corrupt, Cave does not revive the older compiled parser; it waits in plain chat for a verified refresh. The compiled baseline also expires rather than parsing future output indefinitely. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. + +## Evidence + +Verified on 2026-07-26 from xAI's [Grok Build overview](https://docs.x.ai/build/overview), which documents headless `grok -p ... --output-format streaming-json`, and the upstream [headless-mode source documentation](https://github.com/xai-org/grok-build/blob/47348d13ec4508dcfe440e34c6d511bb02998fb2/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md) at Grok Build revision [`47348d13ec4508dcfe440e34c6d511bb02998fb2`](https://github.com/xai-org/grok-build/tree/47348d13ec4508dcfe440e34c6d511bb02998fb2). Those sources establish the baseline text/thought/end/error transport only; they do **not** document tool lifecycle envelope names. No live Grok capture is stored or required. Future signed schemas need separately recorded source evidence for every added event and field before release owners publish them. diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md new file mode 100644 index 000000000..aa3ea5c32 --- /dev/null +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -0,0 +1,135 @@ +# OpenClaw Gateway-dispatch implementation plan + +**GitHub:** #3865 (implementation issue), #3847 (parent compatibility work), +and #3852 (the retained safe CLI/plain-chat stop point). This document records +both the shipped chat-only v4 boundary and the remaining plan for full tool +lifecycle support. + +## Decision + +Cave must not observe a CLI-created OpenClaw run. The CLI does not expose the +Gateway's accepted run ID before `session.tool` events may arrive, so such an +observer cannot attribute tool cards safely when sessions overlap. + +When a Gateway meets the supported compatibility contract, Cave dispatches the +turn through the authenticated Gateway itself. The same Gateway connection owns +the accepted `runId`, subscribes to session events, and accepts only events +belonging to that exact run. The current CLI bridge stays the authoritative +fallback for every other runtime. + +## Target contract after a tool schema is published + +Full tool activity requires a published, versioned `session.tool` event name, +payload validator, and lifecycle fixtures. Once those exist, Cave must request +only the documented capabilities, validate the negotiated role/scopes and +methods, and bind tool events to the Gateway-accepted run ID. Until then, no +capability string or observed frame is a substitute for a payload contract. + +The direct dispatcher supplies an idempotency key, receives the accepted run +identifier, then binds all live state to `(sessionKey, agentId, runId)`. A tool +call key is `(runId, toolCallId)`, not a session-wide call ID. + +No runtime is upgraded heuristically. Older protocol versions, unavailable +packages, unpaired devices, missing `operator.write`, an absent capability, or +an unknown schema use the existing CLI/plain-chat path with a visible +diagnostic. A protocol-version mismatch is a compatibility boundary, not a +reason to guess a field shape. + +## Runtime sequence + +1. Resolve the local OpenClaw runtime and Gateway endpoint without passing + Gateway credentials to a fallback child process. +2. Create or load a paired device identity from OS-backed secret storage; + authenticate with the reference Gateway client and validate `hello-ok` plus + negotiated policy limits. Never persist credentials in plaintext or include + them in logs, caches, SSE, or diagnostics. +3. Establish the selected canonical-session subscription before dispatching + the turn. Add any additional subscription only when its published schema + and contract fixture are available. +4. Send `chat.send` with the Cave message, canonical session key, agent ID, + and an idempotency key derived from the Cave request ID. Record the + Gateway-accepted `runId`. +5. Project only matching, schema-validated events to Cave SSE. Maintain a + per-run high-water sequence, reject replay, and fail the owned turn on a + forward gap until a published history-reconciliation contract is available. +6. On terminal chat state, persist the response. After a published tool schema + is supported, also persist reconciled tool cards. On + cancellation, first persist a per-run `cancelled` terminal fence, then abort + the exact `runId`, close the stream, and settle only its unfinished cards. + Every event, reconciliation, and persistence path checks that fence: a + queued or late result for that run may not replace cancelled card or turn + state with success. +7. Before a `chat.send` acknowledgement, resolve an ambiguous dispatch using + its idempotency key and authoritative Gateway status/history. Start the CLI + fallback only after acceptance is disproven; a lost acknowledgement is not + permission to duplicate the turn. +8. After acceptance, use the official keepalive/liveness policy. On reconnect, + restore the validated session subscription and resume only validated frames + for the accepted run. Add history reconciliation only alongside its + published schema; if recovery fails, terminate and settle the Gateway-owned + turn, never replacing it with a CLI invocation. + +## Compatibility and upgrade policy + +- Depend on the official protocol/client packages rather than local copies of + WebSocket framing, signing, or schemas. +- Keep an explicit profile table keyed by protocol version and package release + range. A profile declares exact methods, events, scopes, payload validators, + limits, and migration behavior. +- Generate/capture protocol conformance fixtures from each supported package + release. Include supported, old/unsupported, future/unknown, missing-scope, + pairing-required, replay, sequence-gap, disconnect, cancellation, and + concurrent-run cases. +- Upgrade only after the schema diff and fixtures pass. Unknown wire versions + fail closed to CLI; a new Cave release adds a tested profile. + +## Current release boundary (2026-07-26) + +The only published protocol/client release is the `2026.7.2-beta.4` beta +package pair, negotiating wire protocol v4. It publishes `HelloOkSchema`, +`ChatEventSchema`, `chat.send`, `chat.abort`, and +`sessions.messages.subscribe`. Cave validates that exact chat-only contract in +its dispatcher, including the accepted `(sessionKey, agentId, runId)` tuple. + +Cave currently keeps the live route fail-closed **before client construction**: +the reference client delegates device identity, challenge signing, and token +lifecycle to host-owned `GatewayClientHostDeps`, and Cave does not yet have the +required cross-platform OS-backed credential-store boundary. In particular, +`OPENCLAW_GATEWAY_TOKEN` and `OPENCLAW_GATEWAY_DEVICE_TOKEN` cannot activate a +write-capable direct turn; the existing CLI/plain-chat bridge remains the +fallback. This is intentional until real paired-device storage is shipped. + +The release also does **not** publish a `session.tool` event name, payload +schema, or validator. Cave emits no Gateway tool card for this release and does +not request an unpublished tool-event capability. A method/event capability +string is not a substitute for a versioned payload contract. + +| Package profile | Wire protocol | Runtime projection | Tool cards | Upgrade rule | +| --- | --- | --- | --- | --- | +| `2026.7.2-beta.4` | v4 only | None until Cave has OS-backed paired-device credentials; dispatcher tests validate only correlated `chat` frames | Disabled: no published schema | Add credential-store integration, then a fixture and explicit profile only when OpenClaw publishes a stable tool payload validator. | +| Any other version/profile | Not assumed | None | Disabled | Keep CLI/plain chat with a visible compatibility diagnostic. | + +Before enabling tool cards, record the package release, exported validator, +schema diff, and fixtures for lifecycle, foreign-run rejection, malformed +payload, replay, gap, disconnect, and cancellation. Do not infer a tool shape +from an observed Gateway frame. + +## Verification + +Add a route-level Gateway fixture that performs the real authenticated +handshake, subscription, `chat.send` acknowledgement, and emitted chat +lifecycle. It must prove that matching chat frames reach SSE and persistence +and that otherwise-valid concurrent-session frames are rejected. Once a +published tool validator exists, extend it with start/update/result cards, +history reconciliation, and every fallback boundary above. + +## Delivery slices + +1. Add official protocol/client dependencies, capability/profile discovery, + paired-device credential storage, and protocol fixtures. +2. Implement one owned Gateway turn with chat SSE projection and a CLI fallback + selected before dispatch. +3. Implement correlated tool lifecycle, persistence, cancellation, and + reconciliation. +4. Add cross-version conformance and route-level integration tests; document + operator setup and upgrade support boundaries. diff --git a/package.json b/package.json index 740ea9528..37659e6ed 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "@iconify/react": "6.0.2", "@lezer/highlight": "1.2.3", "@milkdown/crepe": "7.21.2", + "@openclaw/gateway-client": "2026.7.2-beta.4", + "@openclaw/gateway-protocol": "2026.7.2-beta.4", "@tailwindcss/browser": "4.3.1", "@tauri-apps/api": "2.11.1", "@tauri-apps/plugin-notification": "2.3.3", @@ -101,6 +103,7 @@ "sharp": "0.34.5", "shiki": "4.3.0", "sucrase": "3.35.1", + "typebox": "1.3.6", "ws": "8.21.0", "yaml": "2.9.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 154c9651e..53ed4a542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: '@milkdown/crepe': specifier: 7.21.2 version: 7.21.2(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(typescript@6.0.3) + '@openclaw/gateway-client': + specifier: 2026.7.2-beta.4 + version: 2026.7.2-beta.4 + '@openclaw/gateway-protocol': + specifier: 2026.7.2-beta.4 + version: 2026.7.2-beta.4 '@tailwindcss/browser': specifier: 4.3.1 version: 4.3.1 @@ -131,6 +137,9 @@ importers: sucrase: specifier: 3.35.1 version: 3.35.1 + typebox: + specifier: 1.3.6 + version: 1.3.6 ws: specifier: 8.21.0 version: 8.21.0 @@ -978,6 +987,14 @@ packages: '@ocavue/utils@1.7.0': resolution: {integrity: sha512-yEk9ATNBjTZTtuVFMB/MAIF6zJBvJ2+lVNQvK2+O+ggEBGTgx2tp27d4FPgmD5bRsNHHP3D0SleQia/bvIeV8w==} + '@openclaw/gateway-client@2026.7.2-beta.4': + resolution: {integrity: sha512-iWkaaUQ+sBuLYYw6NkFlmboFJ4ZCIv/5YZiLcCvl8+EsYOyumVim1C+ar9OfIRj/eusMeMbvEpzpkuVfu6WDmg==} + engines: {node: '>=22.19.0'} + + '@openclaw/gateway-protocol@2026.7.2-beta.4': + resolution: {integrity: sha512-oE2uQYu6/GtIp4eGgkZyDFE2dZ06PMWCPsPKQvKI2DljhVvQ4F9Jn6IPWTHlO8oQqU8zE1Hyu/3Lnqjkc7Ng8w==} + engines: {node: '>=22.19.0'} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -2244,6 +2261,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2996,6 +3017,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typebox@1.3.6: + resolution: {integrity: sha512-Sc8RA0NCMEFmApHNU9ZMzqcpQj46She44J8ffpLM/bdhLNUZKq7DJumcLcsFx1gRmDfQPgCgOmFFJ7rcnfWNyA==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -3183,6 +3207,18 @@ packages: utf-8-validate: optional: true + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -4244,6 +4280,19 @@ snapshots: '@ocavue/utils@1.7.0': {} + '@openclaw/gateway-client@2026.7.2-beta.4': + dependencies: + '@openclaw/gateway-protocol': 2026.7.2-beta.4 + ipaddr.js: 2.4.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@openclaw/gateway-protocol@2026.7.2-beta.4': + dependencies: + typebox: 1.3.6 + '@oxc-project/types@0.133.0': {} '@playwright/test@1.61.1': @@ -5568,6 +5617,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@2.4.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6563,6 +6614,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typebox@1.3.6: {} + typescript@6.0.3: {} undici-types@7.18.2: {} @@ -6716,6 +6769,8 @@ snapshots: ws@8.21.0: {} + ws@8.21.1: {} + y18n@4.0.3: {} yaml@2.9.0: {} diff --git a/scripts/beads-pr-bridge.test.mjs b/scripts/beads-pr-bridge.test.mjs index 2463d70bf..16ca60368 100644 --- a/scripts/beads-pr-bridge.test.mjs +++ b/scripts/beads-pr-bridge.test.mjs @@ -4,6 +4,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +if (process.platform === "win32") { + console.log("beads-pr-bridge: skipped on native Windows (the fixture stubs POSIX command executables)"); + process.exit(0); +} + const root = path.resolve(new URL("..", import.meta.url).pathname); const temp = mkdtempSync(path.join(tmpdir(), "beads-pr-bridge-")); const bin = path.join(temp, "bin"); diff --git a/scripts/beads-pr-patrol.test.mjs b/scripts/beads-pr-patrol.test.mjs index 152934ec0..4b1f8e186 100644 --- a/scripts/beads-pr-patrol.test.mjs +++ b/scripts/beads-pr-patrol.test.mjs @@ -8,6 +8,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +if (process.platform === "win32") { + console.log("beads-pr-patrol: skipped on native Windows (the fixture stubs POSIX command executables)"); + process.exit(0); +} + const root = path.resolve(new URL("..", import.meta.url).pathname); const temp = mkdtempSync(path.join(tmpdir(), "beads-pr-patrol-")); const bin = path.join(temp, "bin"); diff --git a/scripts/check-grok-registry-release.mjs b/scripts/check-grok-registry-release.mjs new file mode 100644 index 000000000..f8bee3574 --- /dev/null +++ b/scripts/check-grok-registry-release.mjs @@ -0,0 +1,29 @@ +// Public verification material is embedded in a desktop release; this guard +// makes a missing trust anchor a release failure rather than an unsafe default. +import { createPublicKey } from "node:crypto"; + +const url = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL; +const publicKey = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY; +const publicKeys = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; +const checkpoint = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; +const fail = (message) => { console.error(`::error::${message}`); process.exitCode = 1; }; +let keyring; +try { keyring = publicKeys ? JSON.parse(publicKeys) : publicKey ? { legacy: publicKey } : null; } catch { keyring = null; } +if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring) || !checkpoint) { + fail("Grok compatibility registry URL, Ed25519 public key/keyring, and immutable sequence checkpoint must be configured for every desktop release."); +} else { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password) throw new Error("registry URL must use HTTPS without credentials"); + const entries = Object.entries(keyring); + if (!entries.length || entries.length > 4) throw new Error("registry keyring must contain one to four keys"); + for (const [id, pem] of entries) { + if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) || typeof pem !== "string") throw new Error("registry keyring has an invalid key id"); + if (createPublicKey(pem).asymmetricKeyType !== "ed25519") throw new Error("registry key must be Ed25519"); + } + const parsedCheckpoint = JSON.parse(checkpoint); + if (!parsedCheckpoint || typeof parsedCheckpoint !== "object" || Array.isArray(parsedCheckpoint) + || Object.keys(parsedCheckpoint).length !== 2 || !Number.isSafeInteger(parsedCheckpoint.sequence) || parsedCheckpoint.sequence < 1 + || typeof parsedCheckpoint.payloadHash !== "string" || !/^[a-f0-9]{64}$/.test(parsedCheckpoint.payloadHash)) throw new Error("registry checkpoint must contain a sequence and SHA-256 payload hash"); + } catch (error) { fail(`Invalid Grok compatibility registry configuration: ${error instanceof Error ? error.message : "unknown error"}`); } +} diff --git a/scripts/check-grok-registry-release.test.mjs b/scripts/check-grok-registry-release.test.mjs new file mode 100644 index 000000000..5465ec19b --- /dev/null +++ b/scripts/check-grok-registry-release.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; + +const workflow = await readFile(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); +const guard = await readFile(new URL("./check-grok-registry-release.mjs", import.meta.url), "utf8"); +assert.match(workflow, /Require signed Grok compatibility registry[\s\S]*?NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL[\s\S]*?check-grok-registry-release\.mjs/); +assert.match(guard, /registry URL must use HTTPS without credentials/); +const { publicKey } = generateKeyPairSync("ed25519"); +const result = spawnSync(process.execPath, ["scripts/check-grok-registry-release.mjs"], { cwd: new URL("..", import.meta.url), encoding: "utf8", env: { ...process.env, NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL: "https://publisher:secret@registry.example/grok.json", NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY: publicKey.export({ type: "spki", format: "pem" }).toString(), NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT: JSON.stringify({ sequence: 1, payloadHash: "a".repeat(64) }) } }); +assert.notEqual(result.status, 0); +assert.doesNotMatch(result.stderr, /secret/); +console.log("check-grok-registry-release.test.mjs: ok"); diff --git a/scripts/dev-app-teardown.test.mjs b/scripts/dev-app-teardown.test.mjs index 7b0909256..a3e7232d1 100644 --- a/scripts/dev-app-teardown.test.mjs +++ b/scripts/dev-app-teardown.test.mjs @@ -9,8 +9,15 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +// Node maps SIGTERM/SIGINT to forced termination on native Windows, bypassing +// the Bash traps this test is intended to exercise. Keep signal-tree coverage +// on POSIX, where its delivery semantics are real rather than simulated. +if (process.platform === "win32") { + console.log("dev-app-teardown: skipped on native Windows (Bash signal traps are not observable through Node signals)"); + process.exit(0); +} +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function probePort(port) { diff --git a/scripts/ios-claude-design-fidelity.test.mjs b/scripts/ios-claude-design-fidelity.test.mjs index f68e3c581..7cb069261 100644 --- a/scripts/ios-claude-design-fidelity.test.mjs +++ b/scripts/ios-claude-design-fidelity.test.mjs @@ -92,6 +92,11 @@ assert.match( /func consumeLaunchThreadIntent\(\) -> ChatThread\?/, "the launch thread intent waits for a matching thread before consuming", ); +assert.match( + appModel, + /var mostRecentThread: ChatThread\? \{[\s\S]{0,180}filter \{ !\$0\.archived \}[\s\S]{0,180}max \{ \$0\.updatedAt < \$1\.updatedAt \}/, + "the default chat is the newest active thread, independent of pin order", +); assert.match( appModel, /if let threadId = ChatNotifications\.threadId\(fromDeepLink: url\) \{[\s\S]{0,140}launchThreadId = threadId[\s\S]{0,140}selectedTab = \.chats/, @@ -109,8 +114,18 @@ assert.doesNotMatch( ); assert.match( home, - /onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,120}consumeLaunchThreadIntent\(\)/, - "Chats retries a pending launch intent when hydration adds threads", + /\.onAppear \{\s*consumeLaunchThreadIntent\(\)\s*consumeGlobalRequests\(\)\s*selectMostRecentThreadIfNeeded\(\)\s*\}/, + "Chats selects the most recent thread after honoring explicit launch requests", +); +assert.match( + home, + /onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,160}consumeLaunchThreadIntent\(\)[\s\S]{0,160}selectMostRecentThreadIfNeeded\(\)/, + "Chats retries explicit and default selection when hydration adds threads", +); +assert.match( + home, + /private func selectMostRecentThreadIfNeeded\(\) \{[\s\S]{0,500}guard selection == nil,[\s\S]{0,500}!showNewChat,[\s\S]{0,500}app\.threadToOpen == nil,[\s\S]{0,500}app\.launchThreadId == nil,[\s\S]{0,500}!app\.newChatRequested,[\s\S]{0,500}let thread = app\.mostRecentThread[\s\S]{0,180}open\(\.thread\(thread\)\)/, + "the default never overrides an explicit destination or New Chat intent", ); // Authored navigation and discovery surfaces. @@ -144,7 +159,7 @@ assert.match(glass, /UINavigationBarAppearance\.glass/, // Quiet Portal keeps the cold connection state honest, themed, deterministic, // and accessible without inventing stages the runtime cannot prove. -assert.match(root, /Text\("Opening the Cave"\)/, "connecting state uses the approved headline"); +assert.match(root, /Text\("Entering the Cave"\)/, "connecting state uses the approved headline"); assert.match(root, /Text\("Connecting to your desktop"\)/, "connecting state names the live operation"); assert.match(root, /if let host = app\.connection\?\.host/, "connecting state renders the real saved host"); assert.match( diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 5f2e7894b..7f53be8ee 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -103,6 +103,7 @@ export const SUITES = { "scripts/eslint/design-system-plugin.test.mjs", "scripts/bundle-budget.test.mjs", "scripts/check-opencode-registry-release.test.mjs", + "scripts/check-grok-registry-release.test.mjs", "src/components/open-coven-tools-update.test.ts", "src/lib/opencoven-tools-state.test.ts", "src/lib/opencoven-install-job-observer.test.ts", @@ -663,6 +664,7 @@ export const SUITES = { "src/components/familiar-glyph-picker-panel.test.ts", "src/components/familiar-glyph-loading.test.ts", "src/components/familiar-studio-brain-tab.test.ts", + "src/components/chat-familiar-capabilities.test.ts", "src/components/journal-redirect.test.ts", "src/components/familiar-studio-identity-tab.test.ts", "src/components/familiar-lifecycle-section.test.ts", @@ -1007,12 +1009,14 @@ export const SUITES = { "src/lib/hermes-responses-stream.test.ts", "src/lib/openclaw-bin.test.ts", "src/lib/openclaw-bridge.test.ts", + "src/lib/openclaw-gateway.test.ts", "src/lib/coven-identity-canon.test.ts", "src/lib/familiar-runtime.test.ts", "src/lib/harness-adapters.test.ts", "src/lib/grok-bin.test.ts", "src/lib/grok-build.test.ts", "src/lib/runtime-availability.test.ts", + "src/lib/grok-compatibility.test.ts", "src/app/api/harnesses/route.test.ts", "src/lib/claude-stream.test.ts", "src/lib/runtime-compatibility.test.ts", @@ -1037,6 +1041,7 @@ export const SUITES = { "src/app/api/chat/send/chat-send-models.test.ts", "src/app/api/chat/send/chat-send-capabilities.test.ts", "src/app/api/chat/send/route-opencode.integration.test.ts", + "src/app/api/chat/send/route-grok-compatibility.integration.test.ts", "src/app/api/chat/send/route-runtime-availability.integration.test.ts", "src/app/api/chat/send/offline-queue.test.ts", "src/app/api/chat/send/first-turn-stub.test.ts", @@ -1309,6 +1314,7 @@ const ALIAS_LOADER = new Set([ "src/app/api/chat/send/chat-send-models.test.ts", "src/app/api/chat/send/chat-send-capabilities.test.ts", "src/app/api/chat/send/route-opencode.integration.test.ts", + "src/app/api/chat/send/route-grok-compatibility.integration.test.ts", "src/app/api/chat/send/route-runtime-availability.integration.test.ts", "src/lib/familiar-workspace-sessions.test.ts", "src/lib/use-projects-scope-transition.test.ts", diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index c4e10e577..5a93ff384 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -433,8 +433,8 @@ for (const contract of contracts) { const runRegistrations = [...sendSource.matchAll(/= registerChatRun\(/g)]; assert.equal( runRegistrations.length, - 2, - "/chat/send: both adapter paths must register with the stop registry", + 3, + "/chat/send: all three dispatch paths must register with the stop registry", ); assert.match( sendSource, @@ -462,7 +462,7 @@ for (const contract of contracts) { ); assert.match( sendSource, - /launchFailure \?\?= \{[\s\S]{0,180}?message: launchError,[\s\S]{0,400}?pushProgress\([\s\S]{0,220}?launchError,[\s\S]{0,300}?code: "ENOENT",\s*message: launchError/, + /const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]{0,500}?launchFailure \?\?= \{[\s\S]{0,180}?message: launchError,[\s\S]{0,400}?pushProgress\([\s\S]{0,220}?launchError,[\s\S]{0,300}?code: localLaunchError\.code/, "/chat/send: launch state, progress, and the post-spawn ENOENT race event must reuse one normalized message", ); assert.match( @@ -485,8 +485,8 @@ for (const contract of contracts) { ]; assert.equal( cancelledFlags.length, - 2, - "/chat/send: both adapter paths must persist cancelled: true on the assistant turn", + 4, + "/chat/send: every adapter path must mark both its assistant turn and terminal event as cancelled", ); assert.match( sendSource, diff --git a/src/app/api/chat/send/copilot-routing.ts b/src/app/api/chat/send/copilot-routing.ts index 09e3dcfbf..5add55a48 100644 --- a/src/app/api/chat/send/copilot-routing.ts +++ b/src/app/api/chat/send/copilot-routing.ts @@ -1,8 +1,13 @@ import { + copilotDirectStreamConfigured, copilotStreamSpec, type CopilotStreamSpec, } from "../../../../lib/copilot-stream.ts"; -import type { RuntimeAvailability } from "../../../../lib/runtime-availability.ts"; +import { + RUNTIME_AVAILABILITY_ERROR_CODES, + type RuntimeAvailability, + type RuntimeAvailabilityErrorCode, +} from "../../../../lib/runtime-availability.ts"; import { copilotCapabilityFailureMessage, type CopilotCapabilityDiagnostic, @@ -10,7 +15,25 @@ import { export type CopilotChatRouting = | { mode: "direct-jsonl"; spec: CopilotStreamSpec; compatibilityDiagnostic: null } - | { mode: "plain"; spec: null; compatibilityDiagnostic: string | null }; + | { mode: "plain"; spec: null; compatibilityDiagnostic: null } + | { + mode: "blocked"; + spec: null; + compatibilityDiagnostic: string; + failure: Extract }>; + }; + +function blockedFailure( + state: "probe_failed" | "unsupported_runtime", + message: string, +): Extract }> { + return { + state, + runner: "copilot", + code: RUNTIME_AVAILABILITY_ERROR_CODES[state] as RuntimeAvailabilityErrorCode, + message, + }; +} /** * Keep the direct JSONL launch behind an explicit, testable capability gate. @@ -33,17 +56,36 @@ export function resolveCopilotChatRouting(input: { const spec = copilotStreamSpec(input.capabilityVersion, input.eventProtocols, input.launchCommand); if (spec) return { mode: "direct-jsonl", spec, compatibilityDiagnostic: null }; + // A registry that has not explicitly selected direct JSONL keeps the + // established Coven transport. Once it does, changing transports after a + // failed capability/configuration check could run a different launcher than + // the one just preflighted, so fail closed with runner-specific state. + if (!copilotDirectStreamConfigured()) { + return { mode: "plain", spec: null, compatibilityDiagnostic: null }; + } + + if (input.availability && input.availability.state !== "ready") { + return { + mode: "blocked", + spec: null, + compatibilityDiagnostic: input.availability.message, + failure: input.availability, + }; + } + const capabilityFailure = copilotCapabilityFailureMessage({ version: input.capabilityVersion, diagnostic: input.capabilityDiagnostic, availability: input.availability, }); + const probeTimedOut = input.capabilityDiagnostic === "probe-timeout"; + const message = capabilityFailure ?? + "This Copilot CLI or JSONL stream configuration is not compatible with Cave chat. Update the Copilot runtime schema or CLI, then try again."; return { - mode: "plain", + mode: "blocked", spec: null, - compatibilityDiagnostic: - capabilityFailure ?? - "This Copilot CLI version is not yet compatible with Cave tool activity. Chat continues without live tool details; update the Copilot runtime schema or CLI.", + compatibilityDiagnostic: message, + failure: blockedFailure(probeTimedOut ? "probe_failed" : "unsupported_runtime", message), }; } diff --git a/src/app/api/chat/send/first-turn-stub.test.ts b/src/app/api/chat/send/first-turn-stub.test.ts index 318e83b46..99a0243ac 100644 --- a/src/app/api/chat/send/first-turn-stub.test.ts +++ b/src/app/api/chat/send/first-turn-stub.test.ts @@ -64,8 +64,8 @@ assert.match( assert.equal( (chatRoute.match(/const hadFirstTurnStub = existing\s*\? stripConversationStubTurn\(existing, pendingUserTurnId\)\s*: false;/g) ?? []).length, - 2, - "both save paths must strip the stub turn so the authoritative user turn re-lands cleanly", + 3, + "all save paths must strip the stub turn so the authoritative user turn re-lands cleanly", ); assert.equal( diff --git a/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts b/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts index f3826b915..4694ac0c3 100644 --- a/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts +++ b/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts @@ -71,9 +71,10 @@ for (const capabilityVersion of ["1.0.70.1", "0.9.9", "2.0.0", "2.0.0-rc.1"]) { isSshRuntime: false, capabilityVersion, }); - assert.equal(routing.mode, "plain", `${capabilityVersion ?? "unavailable"} must use generic chat`); - assert.equal(routing.spec, null, "the fallback must never direct-spawn the JSONL parser"); - assert.match(routing.compatibilityDiagnostic ?? "", /not yet compatible/); + assert.equal(routing.mode, "blocked", `${capabilityVersion ?? "unavailable"} must not change transports`); + assert.equal(routing.spec, null, "an incompatible client must never direct-spawn the JSONL parser"); + assert.equal(routing.failure.code, "runtime_unsupported"); + assert.match(routing.compatibilityDiagnostic, /compatible/i); } const capabilityCauseMessages = { @@ -96,7 +97,7 @@ for (const [diagnostic, expected] of Object.entries(capabilityCauseMessages)) { resolvedPath: "/must-not-reach-wire", }, }); - assert.equal(routing.mode, "plain"); + assert.equal(routing.mode, "blocked"); assert.equal( routing.compatibilityDiagnostic, expected, @@ -123,6 +124,12 @@ assert.equal( missingAvailabilityMessage, "runtime availability cause wins before generic version diagnostics", ); +assert.equal( + missingRouting.mode, + "blocked", + "a missing Copilot launch plan never falls back to generic Coven", +); +assert.equal(missingRouting.failure.code, "runtime_missing"); assert.doesNotMatch( missingRouting.compatibilityDiagnostic ?? "", /must-not-reach-wire|resolvedPath|PATH=/, @@ -180,8 +187,9 @@ const preparedFallback = await prepareCopilotChatRouting({ }), resolveCompatibility: async () => ({ eventProtocols: [] }), }); -assert.equal(preparedFallback.mode, "plain", "an unsupported mocked runtime retains generic plain chat"); -assert.match(preparedFallback.compatibilityDiagnostic ?? "", /not yet compatible/); +assert.equal(preparedFallback.mode, "blocked", "an unsupported local runtime fails before generic Coven routing"); +assert.equal(preparedFallback.failure.code, "runtime_unsupported"); +assert.match(preparedFallback.compatibilityDiagnostic, /compatible/i); assert.notEqual( preparedFallback.compatibilityDiagnostic, capabilityCauseMessages["version-unavailable"], @@ -212,8 +220,8 @@ assert.match( ); assert.match( chatRoute, - /if \(grokDirect\) \{\s*handleGrokLine\(line, isJson\);\s*return;/, - "Grok stdout routes through the native JSONL parser, never generic stream-json parsing", + /if \(grokDirect\) \{[\s\S]*?handleGrokLine\(line, isJson \|\| [\s\S]*?line\.trimStart\(\)\)\);\s*return;/, + "Grok stdout routes through the native parser, rejecting whitespace-prefixed object and array envelopes before plain fallback persistence", ); assert.match( chatRoute, @@ -255,6 +263,26 @@ assert.match( /Grok Build chats currently run on this Cave host/, "SSH Grok must fail explicitly instead of falling back to coven run", ); +assert.match( + chatRoute, + /const grokCapabilities = grokDirect[\s\S]*?probeReadyLocalRuntimeCapability\([\s\S]*?runner: "grok",[\s\S]*?probe: \(\) => probeGrokRunCapabilities\([\s\S]*?command: localRuntimePlan!\.command,[\s\S]*?fixedArgs: localRuntimePlan!\.fixedArgs[\s\S]*?localRuntimePlan!\.env[\s\S]*?const grokCompatibility = grokCapabilities[\s\S]*?resolveGrokCompatibility\(grokCapabilities\)/, + "Grok must probe the exact preflight-approved launcher and environment before selecting a structured schema", +); +assert.match( + chatRoute, + /outputFormat: grokCompatibility\?\.mode === "structured" \? "streaming-json" : null/, + "an unverified Grok client must use plain output rather than an assumed JSON protocol", +); +assert.match( + chatRoute, + /case "tool_start":[\s\S]*?envelopeToolUse[\s\S]*?consumePendingEnvelopeProgress[\s\S]*?consumePendingEnvelopeResult/, + "selected Grok schemas must reconcile reordered progress and terminal tool results through the shared tracker", +); +assert.match( + chatRoute, + /case "unknown":[\s\S]*?quarantineGrokSchema[\s\S]*?redactedGrokEventFingerprint/, + "unknown selected-schema events must quarantine future structured launches with a redacted diagnostic", +); assert.match( chatRoute, @@ -279,18 +307,18 @@ assert.match( assert.match( chatRoute, - /let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?runner: "copilot"[\s\S]*?else if \(openCodeDirect\)[\s\S]*?runner: "opencode"[\s\S]*?else if \(grokDirect\)[\s\S]*?runner: "grok"[\s\S]*?else if \(hermesDirect\)[\s\S]*?runner: "hermes"[\s\S]*?runner: "coven"[\s\S]*?const command = openCodeLaunchCommand[\s\S]*?command: localPlan\.command,[\s\S]*?args: \[\.\.\.localPlan\.fixedArgs, \.\.\.spawnArgs\]/, + /let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?runner: "copilot"[\s\S]*?copilotRouting\.mode === "blocked"[\s\S]*?else if \(openCodeDirect\)[\s\S]*?runner: "opencode"[\s\S]*?else if \(grokDirect\)[\s\S]*?runner: "grok"[\s\S]*?runner: "coven"[\s\S]*?requiredFiles: localPlan\.requiredFiles[\s\S]*?shell: false/, "Copilot, Grok Build, Hermes, and OpenCode direct turns spawn their own CLI; other local harnesses spawn coven", ); assert.match( chatRoute, - /const copilotManifestStream = copilotDirect \? copilotStreamSpec\(\) : null;[\s\S]*?resolveCopilotRuntimeLaunch\(copilotManifestStream\.executable\)[\s\S]*?probeCopilotCapability\(copilotManifestStream\.executable/, - "Copilot resolves and probes the manifest-declared executable instead of an independent default", + /const copilotSpawnEnv = copilotDirect \? harnessSpawnEnv\(body\.familiarId\) : null;[\s\S]*?resolveCopilotRuntimeLaunch\(copilotManifestStream\.executable, \{[\s\S]*?spawnEnv: \(\) => copilotSpawnEnv![\s\S]*?probeCopilotCapability\(copilotManifestStream\.executable/, + "Copilot resolves and probes the manifest-declared executable in the exact familiar-scoped spawn environment", ); -assert.match( +assert.doesNotMatch( chatRoute, - /if \(\s*copilotCompatibilityDiagnostic &&\s*copilotRuntimeLaunch\?\.availability\.state === "ready"\s*\) \{[\s\S]*?copilot-client-compatibility/, - "a non-ready Copilot plan emits only the structured runtime error, not a duplicate compatibility notice", + /copilot-client-compatibility/, + "a blocked Copilot launch emits one structured runtime error rather than a duplicate compatibility notice", ); assert.match( @@ -324,8 +352,8 @@ assert.match( ); assert.match( chatRoute, - /\(openCodeDirect \|\| copilotStream\) && code !== 0[\s\S]*?is_error: true/, - "a nonzero direct Copilot process exit persists the turn as an error even without a final result frame", + /\(openCodeDirect \|\| copilotStream \|\| grokDirect\) && code !== 0[\s\S]*?is_error: true/, + "a nonzero direct Copilot or Grok process exit persists the turn as an error even without a final result frame", ); assert.match( chatRoute, diff --git a/src/app/api/chat/send/harness-routing-opencode.test.ts b/src/app/api/chat/send/harness-routing-opencode.test.ts index 67b4a5955..18e0ef2a6 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -67,7 +67,7 @@ assert.match( ); assert.match( route, - /const openCodeLaunchCommand = openCodeDirect[\s\S]*?command: localPlan\.command,[\s\S]*?args: \[\.\.\.localPlan\.fixedArgs\],[\s\S]*?input: JSON\.stringify\(spawnArgs\)[\s\S]*?const availability =[\s\S]*?command: localPlan\.command,[\s\S]*?env: localPlan\.env,[\s\S]*?const child = spawn\(command\.command, command\.args, \{[\s\S]*?env: localPlan\.env,[\s\S]*?writeOpenCodeLaunchInput\(child, openCodeLaunchCommand\)/, + /const openCodeLaunchCommand = openCodeDirect[\s\S]*?command: localPlan\.command,[\s\S]*?args: \[\.\.\.localPlan\.fixedArgs\],[\s\S]*?input: JSON\.stringify\(spawnArgs\)[\s\S]*?const availability =[\s\S]*?command: localPlan\.command,[\s\S]*?env: localPlan\.env,[\s\S]*?let child:[\s\S]*?try \{[\s\S]*?child = spawn\(command\.command, command\.args, \{[\s\S]*?env: localPlan\.env,[\s\S]*?writeOpenCodeLaunchInput\(child, openCodeLaunchCommand\)/, "OpenCode carries one Windows-safe outer host, inner command, and scoped environment from early preflight through the immediate spawn recheck", ); assert.match( @@ -174,8 +174,8 @@ assert.match( ); assert.match( route, - /child\.on\("close", \(code\) => \{[\s\S]*?if \(\(openCodeDirect \|\| copilotStream\) && code !== 0\)[\s\S]*?is_error: true/, - "a non-zero direct OpenCode or Copilot exit cannot be treated as a successful run when no JSON error arrives", + /child\.on\("close", \(code\) => \{[\s\S]*?if \(\(openCodeDirect \|\| copilotStream \|\| grokDirect\) && code !== 0\)[\s\S]*?is_error: true/, + "a non-zero direct OpenCode, Copilot, or Grok exit cannot be treated as a successful run when no JSON error arrives", ); assert.match( route, @@ -184,8 +184,8 @@ assert.match( ); assert.match( route, - /const tailBlock = !openCodeDirect && tailSource\.length/, - "OpenCode stderr never becomes assistant-visible or persisted empty-response diagnostics", + /const tailBlock = !openCodeDirect && !grokDirect && tailSource\.length/, + "OpenCode and Grok stderr never become assistant-visible or persisted empty-response diagnostics", ); assert.match( route, @@ -224,8 +224,8 @@ assert.match( ); assert.match( route, - /persistedOpenCodeDiagnostics[\s\S]*?id === "opencode-compatibility"[\s\S]*?progress: persistedOpenCodeDiagnostics/, - "safe OpenCode compatibility diagnostics persist with the completed assistant turn", + /persistedCompatibilityDiagnostics[\s\S]*?id === "opencode-compatibility" \|\| id === "grok-compatibility"[\s\S]*?progress: persistedCompatibilityDiagnostics/, + "safe OpenCode and Grok compatibility diagnostics persist with the completed assistant turn", ); assert.match( route, diff --git a/src/app/api/chat/send/harness-routing-tool-events.test.ts b/src/app/api/chat/send/harness-routing-tool-events.test.ts index 34545c4dc..c2e96592a 100644 --- a/src/app/api/chat/send/harness-routing-tool-events.test.ts +++ b/src/app/api/chat/send/harness-routing-tool-events.test.ts @@ -36,6 +36,37 @@ const chatView = await readFile( new URL("../../../../components/chat-view.tsx", import.meta.url), "utf8", ); + +assert.match( + chatRoute, + /dispatchOpenClawGatewayTurn\([\s\S]*?sessionKey: openClawSessionKey\(conversationId\),[\s\S]*?agentId,[\s\S]*?message: args\.harnessPrompt/, + "OpenClaw uses the Gateway-owned dispatcher with the canonical session key and agent id before selecting the CLI fallback", +); +assert.match( + chatRoute, + /openClawGatewayPairedDeviceAuthStatus\(\)[\s\S]*?gatewayAuth\.available[\s\S]*?dispatchOpenClawGatewayTurn/, + "the route must retain the CLI fallback until an OS-backed paired-device credential store can activate Gateway dispatch", +); +assert.match( + chatRoute, + /gatewayDispatch\.kind === "accepted"[\s\S]*?return;[\s\S]*?pushProgress\("openclaw-start", "Starting OpenClaw bridge"/, + "an accepted Gateway turn exits before the CLI branch, preventing duplicate transport ownership", +); +assert.match( + chatRoute, + /gatewayDispatch\.kind === "indeterminate"[\s\S]*?openclaw_gateway_indeterminate[\s\S]*?return;/, + "an ambiguous Gateway acknowledgement produces a terminal error instead of a duplicate CLI turn", +); +assert.match( + chatRoute, + /if \(event\.replace\) \{[\s\S]*?gatewayAssistantText = event\.text;[\s\S]*?kind: "assistant_replace"/, + "a published Gateway replacement delta corrects both the live stream and persisted transcript", +); +assert.match( + chatRoute, + /event\.kind === "final" && event\.text[\s\S]*?gatewayAssistantText !== event\.text[\s\S]*?kind: "assistant_replace"/, + "the terminal Gateway message reconciles divergent streamed text for connected clients", +); // ── Tool-event fidelity (CHAT-D4-03 + CHAT-D4-04) ────────────────────────── // Source pins: the route must route BOTH tool-event sources through the // shared ToolCallTracker — hook lines and stream-json envelope blocks — and diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts new file mode 100644 index 000000000..7f948fdee --- /dev/null +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -0,0 +1,225 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +// Exercise the real Grok route with a deterministic local launcher. This covers +// the boundary pure parser tests cannot: secret-free capability probing, +// verified JSON launch, plain fallback, and persisted native session handling. +const home = await mkdtemp(path.join(homedir(), "cave-grok-route-")); +const bin = path.join(home, "bin"); +const familiarWorkspace = path.join(home, "familiars", "opal"); +await mkdir(bin, { recursive: true }); +await mkdir(familiarWorkspace, { recursive: true }); + +const previousHome = process.env.COVEN_HOME; +const previousCaveHome = process.env.COVEN_CAVE_HOME; +const previousPath = process.env.PATH; +const previousGrokBin = process.env.GROK_BIN; +const previousGrokTestMode = process.env.GROK_TEST_MODE; +const previousXaiApiKey = process.env.XAI_API_KEY; +const previousGrokRegistryKeys = process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; +const previousGrokRegistryCheckpoint = process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; +process.env.COVEN_HOME = home; +process.env.COVEN_CAVE_HOME = path.join(home, "cave"); +process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; +process.env.XAI_API_KEY = "probe-must-not-receive-this"; + +// These event names are a deterministic signed-registry fixture only; they +// make no claim about Grok Build's undocumented tool protocol. +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS = JSON.stringify({ fixture: publicKey.export({ format: "pem", type: "spki" }).toString() }); +const { grokSchemaBundlePayloadHash, grokSchemaBundleSigningPayload } = await import("@/lib/grok-compatibility"); +const fixtureBundle = { + format: 1 as const, + runtime: "grok-build" as const, + sequence: 2, + issuedAt: "2026-07-26T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + keyId: "fixture", + schemas: [{ + id: "grok-build-fixture-tool-events", + priority: 1, + requires: { streamingJson: true as const, options: ["--output-format"], versions: ["1.0.0"] }, + eventTypes: { + ignored: ["thought"], text: ["text"], end: ["end"], error: ["error"], + toolStart: ["fixture_tool_start"], toolProgress: ["fixture_tool_progress"], + toolEnd: ["fixture_tool_end"], toolComplete: ["fixture_tool_complete"], + }, + fields: { + type: ["type"], text: ["data"], sessionId: ["sessionId"], message: ["message"], + usage: [], totalCostUsd: [], id: ["id"], name: ["name"], input: ["input"], + output: ["output"], state: ["state"], error: ["error"], terminalStates: [], errorStates: ["error"], + }, + launch: { outputOption: "--output-format" as const, outputValue: "streaming-json" as const }, + }], +}; +fixtureBundle.signature = { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(fixtureBundle)), privateKey).toString("base64"), +}; +const fixtureHash = grokSchemaBundlePayloadHash(fixtureBundle); +process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT = JSON.stringify({ sequence: fixtureBundle.sequence, payloadHash: fixtureHash }); +const fixtureCachePath = path.join(process.env.COVEN_CAVE_HOME, "grok-schema-bundle-v1.json"); +await mkdir(`${fixtureCachePath}.anchor.journal`, { recursive: true }); +await writeFile(fixtureCachePath, JSON.stringify({ checkedAt: Date.now(), bundle: fixtureBundle })); +await writeFile(`${fixtureCachePath}.anchor.journal/${fixtureBundle.sequence}-${fixtureHash}.json`, JSON.stringify({ sequence: fixtureBundle.sequence, payloadHash: fixtureHash })); + +const executable = process.platform === "win32" ? "grok.cmd" : "grok"; +const windowsShimTarget = path.join(bin, "grok-launcher.js"); +const windowsShimProgram = [ + "const [command] = process.argv.slice(2);", + "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured', 'plain-structured-array', 'plain-bracketed-text'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", + "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", + "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log(' {\\\"opaque\\\":\\\"private structured payload\\\"}');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured-array') console.log('[{\\\"opaque\\\":\\\"private array payload\\\"}]');", + "else if (process.env.GROK_TEST_MODE === 'plain-bracketed-text') console.log('[a safe plain-text reply]');", + "else if (process.env.GROK_TEST_MODE === 'tool-activity') console.log(['{\"type\":\"fixture_tool_end\",\"id\":\"reordered\",\"output\":\"first terminal result\"}', '{\"type\":\"fixture_tool_progress\",\"id\":\"reordered\",\"output\":\"early progress\"}', '{\"type\":\"fixture_tool_complete\",\"id\":\"reordered\",\"name\":\"fixture_call\",\"input\":{\"safe\":true},\"output\":\"duplicate terminal result\"}', '{\"type\":\"fixture_tool_complete\",\"id\":\"terminal-error\",\"name\":\"fixture_error\",\"input\":{},\"output\":\"terminal error\",\"error\":true}', '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'].join('\\n'));", + "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", + "else if (process.env.GROK_TEST_MODE === 'exit-error') { console.error('private Grok stderr payload'); process.exit(3); }", + "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", +].join("\n"); +const launcher = process.platform === "win32" + ? [ + "@echo off", + "\"%~dp0\\grok-launcher.js\" %*", + ].join("\r\n") + : [ + "#!/bin/sh", + "if [ \"$1\" = \"--help\" ]; then", + " [ -z \"$XAI_API_KEY\" ] || exit 12", + " if [ \"$GROK_TEST_MODE\" = \"plain\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ] || [ \"$GROK_TEST_MODE\" = \"plain-bracketed-text\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", + " exit 0", + "fi", + "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"opaque\":\"private structured payload\"}'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' '[{\"opaque\":\"private array payload\"}]'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-bracketed-text\" ]; then printf '%s\\n' '[a safe plain-text reply]'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"tool-activity\" ]; then printf '%s\\n' '{\"type\":\"fixture_tool_end\",\"id\":\"reordered\",\"output\":\"first terminal result\"}' '{\"type\":\"fixture_tool_progress\",\"id\":\"reordered\",\"output\":\"early progress\"}' '{\"type\":\"fixture_tool_complete\",\"id\":\"reordered\",\"name\":\"fixture_call\",\"input\":{\"safe\":true},\"output\":\"duplicate terminal result\"}' '{\"type\":\"fixture_tool_complete\",\"id\":\"terminal-error\",\"name\":\"fixture_error\",\"input\":{},\"output\":\"terminal error\",\"error\":true}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"exit-error\" ]; then printf '%s\\n' 'private Grok stderr payload' >&2; exit 3; fi", + "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", + ].join("\n"); +const launcherPath = path.join(bin, executable); +await writeFile(launcherPath, launcher, { mode: 0o755 }); +if (process.platform === "win32") await writeFile(windowsShimTarget, windowsShimProgram); +process.env.GROK_BIN = launcherPath; + +async function readSse(response: Response) { + assert.equal(response.status, 200, await response.clone().text()); + const body = await response.text(); + return { + body, + events: body.split("\n").filter((line) => line.startsWith("data: ")).map((line) => JSON.parse(line.slice(6))), + }; +} + +try { + const { saveConfig } = await import("@/lib/cave-config"); + const { loadConversation } = await import("@/lib/cave-conversations"); + const { createProject } = await import("@/lib/cave-projects"); + const { grantProjectToFamiliar } = await import("@/lib/project-permissions"); + const { POST } = await import("./route.ts"); + await saveConfig({ familiars: { opal: { harness: "grok" } } }); + const project = await createProject({ name: "Grok route fixture", root: familiarWorkspace }); + await grantProjectToFamiliar({ familiarId: "opal", projectId: project.id, source: "human", access: "write" }); + + const structured = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "fixture", projectRoot: familiarWorkspace }), + }))); + assert.match(structured.body, /"kind":"assistant_chunk","text":"verified route reply"/, "a source-verified selected JSON schema renders assistant text"); + const structuredDone = structured.events.findLast((event) => event.kind === "done"); + assert.equal(typeof structuredDone?.sessionId, "string"); + const conversation = await loadConversation(structuredDone.sessionId); + assert.equal(conversation?.harnessSessionId, "native_grok_session", "the route persists Grok's native resume id separately from Cave's id"); + + process.env.GROK_TEST_MODE = "tool-activity"; + const toolActivity = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "tool fixture", projectRoot: familiarWorkspace }), + }))); + const toolEvents = toolActivity.events.filter((event) => event.kind === "tool_use"); + assert.deepEqual( + toolEvents.map((event) => [event.name, event.status, event.output]), + [ + ["fixture_call", "running", undefined], + ["fixture_call", "running", "early progress"], + ["fixture_call", "ok", "first terminal result"], + ["fixture_error", "running", undefined], + ["fixture_error", "error", "terminal error"], + ], + "a selected signed fixture preserves stable tool activity across reordered, duplicate, and error terminal frames", + ); + assert.doesNotMatch(toolActivity.body, /duplicate terminal result/, "the first terminal result wins when a combined completion is retransmitted"); + const toolConversation = await loadConversation(toolActivity.events.findLast((event) => event.kind === "done")?.sessionId); + assert.deepEqual(toolConversation?.turns.at(-1)?.tools?.map((tool) => [tool.name, tool.status, tool.output]), [ + ["fixture_call", "ok", "first terminal result"], + ["fixture_error", "error", "terminal error"], + ], "selected-schema tool activity persists through the real chat route"); + + process.env.GROK_TEST_MODE = "plain"; + const plain = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(plain.body, /"kind":"assistant_chunk","text":"plain fallback reply\\n"/, "an unverified output format remains safe plain assistant text"); + assert.match(plain.body, /without tool activity/, "plain fallback provides an accessible compatibility diagnostic"); + const plainConversation = await loadConversation(plain.events.findLast((event) => event.kind === "done")?.sessionId); + assert.ok(plainConversation?.turns.at(-1)?.progress?.some((event) => event.id === "grok-compatibility"), "plain fallback persists its value-free compatibility diagnostic for reload"); + + process.env.GROK_TEST_MODE = "plain-bracketed-text"; + const bracketedText = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "bracketed fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(bracketedText.body, /\[a safe plain-text reply\]/, "plain fallback preserves prose that merely starts with a bracket"); + + process.env.GROK_TEST_MODE = "plain-structured"; + const unverifiedStructured = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "unsafe fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(unverifiedStructured.body, /unverified-structured-output/, "plain fallback reports an accessible fixed diagnostic for raw structured output"); + assert.doesNotMatch(unverifiedStructured.body, /private structured payload/, "plain fallback never persists unverified structured payload values as assistant text or diagnostics"); + + process.env.GROK_TEST_MODE = "plain-structured-array"; + const unverifiedArray = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "unsafe array fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(unverifiedArray.body, /unverified-structured-output/, "plain fallback also rejects array-shaped structured output"); + assert.doesNotMatch(unverifiedArray.body, /private array payload/, "plain fallback never persists unverified array payload values"); + + process.env.GROK_TEST_MODE = "malformed"; + const malformed = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "malformed", projectRoot: familiarWorkspace }), + }))); + assert.match(malformed.body, /unframed-jsonl-event/, "unframed selected-schema output emits an accessible compatibility diagnostic"); + assert.doesNotMatch(malformed.body, /private tool payload/, "unframed structured payloads never enter assistant text or diagnostics"); + + process.env.GROK_TEST_MODE = "exit-error"; + const exited = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "exit error", projectRoot: familiarWorkspace }), + }))); + assert.equal(exited.events.findLast((event) => event.kind === "done")?.isError, true, "a non-zero Grok process cannot be persisted as a successful turn"); + assert.doesNotMatch(exited.body, /private Grok stderr payload/, "Grok stderr values never enter assistant-visible or persisted diagnostics"); +} finally { + if (previousHome === undefined) delete process.env.COVEN_HOME; else process.env.COVEN_HOME = previousHome; + if (previousCaveHome === undefined) delete process.env.COVEN_CAVE_HOME; else process.env.COVEN_CAVE_HOME = previousCaveHome; + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + if (previousGrokBin === undefined) delete process.env.GROK_BIN; else process.env.GROK_BIN = previousGrokBin; + if (previousGrokTestMode === undefined) delete process.env.GROK_TEST_MODE; else process.env.GROK_TEST_MODE = previousGrokTestMode; + if (previousXaiApiKey === undefined) delete process.env.XAI_API_KEY; else process.env.XAI_API_KEY = previousXaiApiKey; + if (previousGrokRegistryKeys === undefined) delete process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; else process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS = previousGrokRegistryKeys; + if (previousGrokRegistryCheckpoint === undefined) delete process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; else process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT = previousGrokRegistryCheckpoint; + await rm(home, { recursive: true, force: true }); +} + +console.log("route-grok-compatibility.integration.test.ts: ok"); diff --git a/src/app/api/chat/send/route-runtime-availability.integration.test.ts b/src/app/api/chat/send/route-runtime-availability.integration.test.ts index fb95ad993..81ea00688 100644 --- a/src/app/api/chat/send/route-runtime-availability.integration.test.ts +++ b/src/app/api/chat/send/route-runtime-availability.integration.test.ts @@ -1,7 +1,9 @@ // @ts-nocheck import assert from "node:assert/strict"; import { createHook } from "node:async_hooks"; +import childProcess from "node:child_process"; import { chmod, mkdtemp, mkdir, rm, unlink, writeFile } from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { homedir } from "node:os"; import path from "node:path"; @@ -36,6 +38,7 @@ const previousHome = process.env.COVEN_HOME; const previousCaveHome = process.env.COVEN_CAVE_HOME; const previousCovenBin = process.env.COVEN_BIN; const previousGrokBin = process.env.GROK_BIN; +const previousPath = process.env.PATH; process.env.COVEN_HOME = home; process.env.COVEN_CAVE_HOME = path.join(home, "cave"); process.env.GROK_BIN = pinnedGrok; @@ -68,7 +71,7 @@ function assertNoFabricatedAssistantResponse(body, events) { } try { - const { covenLaunchCommand, refreshCovenBin } = await import("@/lib/coven-bin"); + const { covenLaunchCommand, refreshCovenBin, refreshCovenSpawnEnv } = await import("@/lib/coven-bin"); refreshCovenBin(); const { grokBin } = await import("@/lib/grok-bin"); assert.equal(grokBin(), pinnedGrok, "the test pins Grok discovery to its isolated override"); @@ -233,8 +236,8 @@ try { } else { assert.equal( error.code, - "ENOENT", - "the missing shebang interpreter reaches the post-spawn ENOENT handler", + "runtime_missing", + "the missing shebang interpreter stays in the structured missing-runtime contract", ); assert.equal( error.message, @@ -258,6 +261,59 @@ try { new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "post-spawn diagnostics must never expose the runner path", ); + + const done = events.findLast((event) => event.kind === "done"); + assert.equal(done?.isError, true, "the post-spawn ENOENT completes the stream as an error"); + } + + // Scenario 3 — Windows can throw synchronously for an invalid executable + // instead of returning a child that emits error. It must use the same + // structured, no-fabricated-response completion as an async launch race. + { + await writeFile(pinnedGrok, "present for synchronous spawn\n", { mode: 0o755 }); + if (process.platform !== "win32") await chmod(pinnedGrok, 0o755); + const originalSpawn = childProcess.spawn; + childProcess.spawn = (() => { + throw Object.assign(new Error("synthetic synchronous spawn failure"), { code: "UNKNOWN" }); + }) as typeof childProcess.spawn; + syncBuiltinESMExports(); + try { + const response = await POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "hello synchronous failure", projectRoot: familiarWorkspace }), + })); + const { body, events } = await readSse(response); + const error = events.find((event) => event.kind === "error"); + assert.equal(error?.code, "runtime_launch_failed"); + assert.equal(error?.message, runtimeLaunchFailedMessage("grok")); + assertNoFabricatedAssistantResponse(body, events); + const done = events.findLast((event) => event.kind === "done"); + assert.equal(done?.isError, true, "a synchronous spawn failure completes the stream as an error"); + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + } + } + // A direct Copilot configuration never falls through to generic Coven when + // the exact local CLI plan is absent. + { + const { clearCopilotCapabilityProbeCache } = await import("@/lib/server/copilot-capability-probe"); + process.env.PATH = ""; + refreshCovenBin(); + refreshCovenSpawnEnv(); + clearCopilotCapabilityProbeCache(); + await saveConfig({ familiars: { opal: { harness: "copilot" } } }); + const response = await POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "copilot preflight", projectRoot: familiarWorkspace }), + })); + const { body, events } = await readSse(response); + const error = events.find((event) => event.kind === "error"); + assert.equal(error?.code, "runtime_missing"); + assert.match(String(error?.message), /copilot CLI not found on PATH/i); + assertNoFabricatedAssistantResponse(body, events); } } finally { process.env.COVEN_HOME = previousHome; @@ -266,6 +322,10 @@ try { else process.env.COVEN_BIN = previousCovenBin; if (previousGrokBin === undefined) delete process.env.GROK_BIN; else process.env.GROK_BIN = previousGrokBin; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + const { refreshCovenSpawnEnv } = await import("@/lib/coven-bin"); + refreshCovenSpawnEnv(); await rm(home, { recursive: true, force: true }); } diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 56876a479..1aea0a567 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1,5 +1,6 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn, type ChildProcessByStdio, type ChildProcessWithoutNullStreams } from "node:child_process"; import { homedir } from "node:os"; +import type { Readable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; import { resolveBackspaces, stripAnsi } from "@/lib/ansi"; import { @@ -62,6 +63,13 @@ import { parseGrokStreamEvent, } from "@/lib/grok-build"; import { grokLaunchCommand } from "@/lib/grok-bin"; +import { + parseGrokCompatibilityEvent, + probeGrokRunCapabilities, + quarantineGrokSchema, + redactedGrokEventFingerprint, + resolveGrokCompatibility, +} from "@/lib/grok-compatibility"; import { openCodeCommand, openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; import { evaluateRuntimeAvailability, @@ -129,6 +137,10 @@ import { } from "@/lib/server/chat-project-launch"; import { validateCaveProjectRoot } from "@/lib/server/project-paths"; import { openClawLaunchCommand, openClawSpawnEnv } from "@/lib/openclaw-bin"; +import { + dispatchOpenClawGatewayTurn, + openClawGatewayPairedDeviceAuthStatus, +} from "@/lib/openclaw-gateway"; import { OpenClawAgentResolutionError, extractOpenClawSessionId, @@ -240,6 +252,7 @@ const CHAT_DETACH_MAX_MS = Math.max( type LocalRuntimePlan = LocalRuntimeCapabilityPlan & { command: string; fixedArgs: string[]; + requiredFiles?: string[]; env: NodeJS.ProcessEnv; unresolvedWindowsShim?: boolean; powerShellHostedCommand?: string; @@ -247,7 +260,9 @@ type LocalRuntimePlan = LocalRuntimeCapabilityPlan & { function createLocalRuntimePlan(input: { runner: DirectRunnerId; - launch: Pick; + launch: Pick & { + requiredFiles?: string[]; + }; env: NodeJS.ProcessEnv; availability?: RuntimeAvailability; powerShellHostedCommand?: string; @@ -256,6 +271,7 @@ function createLocalRuntimePlan(input: { runner: input.runner, command: input.launch.command, env: input.env, + requiredFiles: input.launch.requiredFiles, unresolvedWindowsShim: input.launch.unresolvedWindowsShim === true, powerShellHostedCommand: input.powerShellHostedCommand, }); @@ -263,6 +279,7 @@ function createLocalRuntimePlan(input: { runner: input.runner, command: input.launch.command, fixedArgs: input.launch.fixedArgs, + ...(input.launch.requiredFiles ? { requiredFiles: input.launch.requiredFiles } : {}), env: input.env, availability, ...(input.launch.unresolvedWindowsShim @@ -274,21 +291,6 @@ function createLocalRuntimePlan(input: { }; } -function familiarEnvWithCanonicalPath( - familiarId: string, - canonicalEnv: NodeJS.ProcessEnv, -): NodeJS.ProcessEnv { - const env = harnessSpawnEnv(familiarId); - const canonicalPath = - canonicalEnv.PATH ?? canonicalEnv.Path ?? canonicalEnv.path; - if (canonicalPath !== undefined) { - env.PATH = canonicalPath; - delete env.Path; - delete env.path; - } - return env; -} - type SendBody = { familiarId: string; prompt?: string; @@ -607,30 +609,6 @@ function openClawChatResponse(args: { } const agentId = agentBinding.openclawAgentId; pushProgress("openclaw-resolve", "OpenClaw agent resolved", "done", `${agentId} (${agentBinding.source})`); - const argv = openClawAgentArgs(args.harnessPrompt, agentId, conversationId); - const openclawLaunch = openClawLaunchCommand(); - if (openclawLaunch.unresolvedWindowsShim) { - pushProgress( - "openclaw-start", - "OpenClaw bridge cannot start safely", - "error", - "The resolved OpenClaw Windows npm shim could not be mapped to its JavaScript entry point. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", - ); - push({ - kind: "error", - code: "openclaw_unsafe_shell", - message: - "OpenClaw chat is unavailable because its Windows npm shim could not be launched without shell parsing. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", - }); - push({ - kind: "done", - durationMs: Date.now() - startedAt, - isError: true, - }); - close(); - return; - } - const spawnArgv = [...openclawLaunch.fixedArgs, ...argv]; let cwd: string; try { cwd = await resolveLocalRuntimeCwd( @@ -669,6 +647,218 @@ function openClawChatResponse(args: { gatewaySessionId: undefined, sessionKey: openClawSessionKey(conversationId), }; + + // Gateway dispatch owns a turn only after it returns the authoritative + // run id. Until then this branch leaves the existing CLI bridge as the + // safe compatibility fallback; after a request might have reached the + // Gateway it deliberately never starts a second CLI turn. + let gatewayAssistantText = ""; + let gatewayAssistantTextEmitted = false; + const gatewayAuth = openClawGatewayPairedDeviceAuthStatus(); + const gatewayDispatch = gatewayAuth.available + ? await dispatchOpenClawGatewayTurn({ + sessionKey: openClawSessionKey(conversationId), + agentId, + message: args.harnessPrompt, + // A direct Gateway turn is only safe when Cave has the caller's + // stable request id. Reusing it as Gateway's idempotency key makes a + // retry observable to the Gateway instead of creating another run. + idempotencyKey: args.body.runId ?? "", + onEvent: (event) => { + if (event.kind === "status") { + pushProgress("openclaw-gateway", "OpenClaw Gateway", "running", event.phase); + return; + } + if (event.kind === "delta") { + if (event.replace) { + gatewayAssistantText = event.text; + gatewayAssistantTextEmitted = true; + push({ kind: "assistant_replace", text: event.text }); + return; + } + gatewayAssistantText += event.text; + gatewayAssistantTextEmitted = true; + push({ kind: "assistant_chunk", text: event.text }); + return; + } + if (event.kind === "final" && event.text) { + if (gatewayAssistantTextEmitted && gatewayAssistantText !== event.text) { + push({ kind: "assistant_replace", text: event.text }); + } + gatewayAssistantText = event.text; + } + if (event.kind === "error") { + pushProgress("openclaw-gateway", "OpenClaw Gateway", "error", event.message); + } + }, + }) + : { kind: "unavailable" as const, reason: gatewayAuth.reason ?? "Gateway paired-device authentication is unavailable" }; + if (gatewayDispatch.kind === "indeterminate") { + pushProgress("openclaw-gateway", "OpenClaw Gateway dispatch is indeterminate", "error", gatewayDispatch.reason); + push({ kind: "error", code: "openclaw_gateway_indeterminate", message: gatewayDispatch.reason }); + push({ kind: "done", durationMs: Date.now() - startedAt, isError: true, responseMetadata }); + close(); + return; + } + if (gatewayDispatch.kind === "accepted") { + responseMetadata.gatewaySessionId = gatewayDispatch.runId; + pushProgress("openclaw-gateway", "OpenClaw Gateway dispatch accepted", "done", `run ${gatewayDispatch.runId}`); + const pendingUserTurnId = crypto.randomUUID(); + const stubTitle = chatTitleFromPrompt(args.promptText) ?? defaultChatTitleForSession(conversationId); + void setDefaultSessionTitleIfMissing(conversationId, stubTitle).catch(() => undefined); + const stubWrite = createConversationStub({ + sessionId: conversationId, + familiarId: args.body.familiarId, + harness: "openclaw", + ...(responseMetadata.model ? { model: responseMetadata.model } : {}), + ...(responseMetadata.runtime ? { runtime: responseMetadata.runtime } : {}), + title: stubTitle, + ...(args.body.origin ? { origin: args.body.origin } : {}), + userTurn: { + id: pendingUserTurnId, + text: args.promptText, + ...(args.attachments.length ? { attachments: args.attachments } : {}), + }, + }).catch(() => undefined); + const stopGateway = () => { + void gatewayDispatch.abort(); + }; + const runHandle = registerChatRun([args.body.runId, conversationId], stopGateway); + let detachKillTimer: ReturnType | null = null; + const armDetachKill = () => { + if (runHandle.stopRequested || detachKillTimer != null) return; + detachKillTimer = setTimeout(stopGateway, CHAT_DETACH_MAX_MS); + }; + runBuffer = openRunBuffer([args.body.runId, conversationId], { + attach: () => { + if (detachKillTimer != null) { + clearTimeout(detachKillTimer); + detachKillTimer = null; + } + }, + detach: () => { + if (args.req.signal.aborted) armDetachKill(); + }, + }); + const onAbort = () => armDetachKill(); + args.req.signal.addEventListener("abort", onAbort, { once: true }); + pushProgress("openclaw-response", "Waiting for OpenClaw Gateway response", "running"); + const gatewayResult = await gatewayDispatch.done; + args.req.signal.removeEventListener("abort", onAbort); + if (detachKillTimer != null) clearTimeout(detachKillTimer); + unregisterChatRun(runHandle); + const durationMs = Date.now() - startedAt; + const cancelledByUser = runHandle.stopRequested || gatewayResult.state === "aborted"; + const isError = gatewayResult.state === "error"; + if (!gatewayAssistantText.trim()) { + gatewayAssistantText = cancelledByUser + ? "(cancelled)" + : gatewayResult.message ?? "_The OpenClaw Gateway returned no text._"; + } + pushProgress( + "openclaw-response", + isError ? "OpenClaw Gateway response failed" : "OpenClaw Gateway response received", + isError ? "error" : "done", + gatewayResult.message, + durationMs, + ); + push({ kind: "session", sessionId: conversationId }); + if (!gatewayAssistantTextEmitted) push({ kind: "assistant_chunk", text: gatewayAssistantText }); + pushProgress("save-transcript", "Saving transcript", "running"); + await recordSessionFamiliar(conversationId, args.body.familiarId); + await stubWrite; + const existing = await loadConversation(conversationId); + const hadFirstTurnStub = existing ? stripConversationStubTurn(existing, pendingUserTurnId) : false; + const isFirstExchange = !existing || hadFirstTurnStub; + const now = new Date().toISOString(); + const chatTitle = existing?.title ?? defaultChatTitleForSession(conversationId); + if (!existing) await setDefaultSessionTitleIfMissing(conversationId, chatTitle); + const branchParentId = + args.body.parentTurnId !== undefined ? args.body.parentTurnId : existing?.activeLeafId ?? null; + const conv = existing ?? { + sessionId: conversationId, + familiarId: args.body.familiarId, + harness: "openclaw", + model: responseMetadata.model, + runtime: responseMetadata.runtime, + title: chatTitle, + ...(args.body.origin ? { origin: args.body.origin } : {}), + createdAt: now, + updatedAt: now, + turns: [], + }; + conv.model = responseMetadata.model; + conv.runtime = responseMetadata.runtime; + persistSendModelIntent(conv, args.body, args.modelState); + const workBranch = await captureWorkBranch(cwdFromConversationRuntime(conv.runtime)); + if (workBranch) conv.branch = workBranch; + const reportedPrUrl = latestPrUrlFromText(gatewayAssistantText); + if (reportedPrUrl) conv.prUrl = reportedPrUrl; + const assistantTurnId = crypto.randomUUID(); + conv.turns.push( + { + id: pendingUserTurnId, + role: "user", + text: args.promptText, + ...(args.attachments.length ? { attachments: args.attachments } : {}), + createdAt: now, + ...(branchParentId != null ? { parentId: branchParentId } : {}), + }, + { + id: assistantTurnId, + role: "assistant", + text: gatewayAssistantText.trim(), + createdAt: new Date().toISOString(), + durationMs, + isError, + parentId: pendingUserTurnId, + responseMetadata, + ...(cancelledByUser ? { cancelled: true } : {}), + }, + ); + conv.activeLeafId = assistantTurnId; + await saveConversation(conv); + if (isFirstExchange && !isError) await autoNameSessionFromFirstExchange(conversationId, args.promptText); + if (!isError) await maybeAutoRenameFromContext(conversationId, args.promptText); + pushProgress("save-transcript", "Transcript saved", "done"); + push({ + kind: "done", + durationMs, + isError, + sessionId: conversationId, + ...(cancelledByUser ? { cancelled: true } : {}), + responseMetadata, + }); + gatewayDispatch.close(); + runBuffer?.finish(); + await sleep(20); + close(); + return; + } + const argv = openClawAgentArgs(args.harnessPrompt, agentId, conversationId); + const openclawLaunch = openClawLaunchCommand(); + if (openclawLaunch.unresolvedWindowsShim) { + pushProgress( + "openclaw-start", + "OpenClaw bridge cannot start safely", + "error", + "The resolved OpenClaw Windows npm shim could not be mapped to its JavaScript entry point. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", + ); + push({ + kind: "error", + code: "openclaw_unsafe_shell", + message: + "OpenClaw chat is unavailable because its Windows npm shim could not be launched without shell parsing. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", + }); + push({ + kind: "done", + durationMs: Date.now() - startedAt, + isError: true, + }); + close(); + return; + } + const spawnArgv = [...openclawLaunch.fixedArgs, ...argv]; pushProgress("openclaw-start", "Starting OpenClaw bridge", "running", cwd); const child = spawn(openclawLaunch.command, spawnArgv, { cwd, @@ -1084,13 +1274,15 @@ export async function POST(req: Request) { }) : null; - // Copilot's exact command is resolved once. The capability phase consumes - // that passive plan and cannot resolve a different npm shim. Its probe env - // is credential-free; the eventual model env keeps familiar-scoped values - // but is pinned to this same canonical PATH. + // Resolve the direct plan in the exact familiar-scoped environment passed to + // the child. The capability probe scrubs credentials only for `--version`; + // it must never discover a different launcher than the model spawn uses. + const copilotSpawnEnv = copilotDirect ? harnessSpawnEnv(body.familiarId) : null; const copilotManifestStream = copilotDirect ? copilotStreamSpec() : null; const copilotRuntimeLaunch = copilotManifestStream - ? await resolveCopilotRuntimeLaunch(copilotManifestStream.executable) + ? await resolveCopilotRuntimeLaunch(copilotManifestStream.executable, { + spawnEnv: () => copilotSpawnEnv!, + }) : null; const copilotCapability = copilotManifestStream && copilotRuntimeLaunch ? copilotRuntimeLaunch.availability.state === "ready" @@ -1109,22 +1301,24 @@ export async function POST(req: Request) { resolveCompatibility: () => resolveRuntimeCompatibility("copilot"), }); const copilotStream = copilotRouting.spec; - const copilotCompatibilityDiagnostic = copilotRouting.compatibilityDiagnostic; let localRuntimePlan: LocalRuntimePlan | null = null; if (!sshRuntime && binding.harness !== "openclaw" && !(hermesDirect && hermesApi)) { if ( copilotRuntimeLaunch && - (copilotRuntimeLaunch.availability.state !== "ready" || copilotStream) + ( + copilotRuntimeLaunch.availability.state !== "ready" + || copilotStream + || copilotRouting.mode === "blocked" + ) ) { localRuntimePlan = createLocalRuntimePlan({ runner: "copilot", launch: copilotRuntimeLaunch, - env: familiarEnvWithCanonicalPath( - body.familiarId, - copilotRuntimeLaunch.env, - ), - availability: copilotRuntimeLaunch.availability, + env: copilotRuntimeLaunch.env, + availability: copilotRouting.mode === "blocked" + ? copilotRouting.failure + : copilotRuntimeLaunch.availability, }); } else if (openCodeDirect) { const env = openCodeSpawnEnv(body.familiarId); @@ -1171,6 +1365,22 @@ export async function POST(req: Request) { const openCodeCompatibility = openCodeCapabilities ? await resolveOpenCodeCompatibility(openCodeCapabilities) : null; + // Probe the same ready local launcher/environment that the direct run will + // use. A missing or changed CLI remains a truthful preflight failure; it + // never turns undocumented output into tool activity. + const grokCapabilities = grokDirect + ? await probeReadyLocalRuntimeCapability({ + plan: localRuntimePlan, + runner: "grok", + probe: () => probeGrokRunCapabilities( + { command: localRuntimePlan!.command, fixedArgs: localRuntimePlan!.fixedArgs }, + localRuntimePlan!.env, + ), + }) + : null; + const grokCompatibility = grokCapabilities + ? await resolveGrokCompatibility(grokCapabilities) + : null; const hermesModelCapability = hermesDirect && hermesApi === null ? await probeReadyLocalRuntimeCapability({ @@ -1668,6 +1878,7 @@ export async function POST(req: Request) { binding.display_name, binding.role, ), + outputFormat: grokCompatibility?.mode === "structured" ? "streaming-json" : null, }); } if (openCodeDirect) { @@ -1829,7 +2040,7 @@ export async function POST(req: Request) { // Compatibility notices are deliberately value-free and must survive // transcript reloads; the live SSE buffer alone expires after two // minutes. Keep only this narrowly-scoped subset of progress rows. - const persistedOpenCodeDiagnostics: NonNullable = []; + const persistedCompatibilityDiagnostics: NonNullable = []; const pushProgress = ( id: string, label: string, @@ -1837,8 +2048,8 @@ export async function POST(req: Request) { detail?: string, durationMs?: number, ) => { - if (id === "opencode-compatibility") { - persistedOpenCodeDiagnostics.push({ + if (id === "opencode-compatibility" || id === "grok-compatibility") { + persistedCompatibilityDiagnostics.push({ id, label, status, @@ -1869,17 +2080,6 @@ export async function POST(req: Request) { }; push({ kind: "user", text: promptText }); - if ( - copilotCompatibilityDiagnostic && - copilotRuntimeLaunch?.availability.state === "ready" - ) { - pushProgress( - "copilot-client-compatibility", - "Copilot tool activity needs an update", - "error", - copilotCompatibilityDiagnostic, - ); - } if (hermesDirect && !hermesApi) { // Do not fabricate tool bubbles from the CLI's presentation layer. // This gives the operator an actionable, privacy-safe degradation @@ -2084,6 +2284,24 @@ export async function POST(req: Request) { let openCodeProtocolQuarantineNoticeSent = false; let openCodeStructuredProtocolQuarantined = false; let openCodeModelRejected = false; + let grokCompatibilityHealthNoticeSent = false; + let grokStructuredProtocolQuarantined = false; + let grokProtocolQuarantineNoticeSent = false; + const quarantineGrokProtocol = ( + detail: "malformed-jsonl-event" | "unframed-jsonl-event" | `unknown-event:${string}`, + ) => { + grokStructuredProtocolQuarantined = true; + quarantineGrokSchema(grokCompatibility?.schema); + recordStdoutErrorTail("Grok Build emitted a malformed structured event", true); + if (grokProtocolQuarantineNoticeSent) return; + grokProtocolQuarantineNoticeSent = true; + pushProgress( + "grok-compatibility", + "Grok Build sent an unrecognized event; future turns will use safe plain chat until a compatible schema is available", + "error", + detail, + ); + }; const quarantineOpenCodeProtocol = ( label: string, detail: "malformed-json-event" | "oversized-jsonl-event", @@ -2393,12 +2611,64 @@ export async function POST(req: Request) { }; const handleGrokLine = (line: string, isJson: boolean) => { + if (!grokCompatibilityHealthNoticeSent && grokCompatibility?.diagnostic) { + grokCompatibilityHealthNoticeSent = true; + const label = grokCompatibility.diagnostic === "streaming-json-unavailable" + ? "This Grok Build client does not advertise streaming JSON; continuing without tool activity" + : grokCompatibility.diagnostic === "built-in-schema-expired" + ? "Grok Build's built-in compatibility schema has expired; continuing without tool activity" + : grokCompatibility.diagnostic === "no-compatible-schema" + ? "This Grok Build client has no verified tool-event schema; continuing without tool activity" + : "Grok Build's structured event protocol is unavailable; continuing without tool activity"; + pushProgress("grok-compatibility", label, "error", grokCompatibility.diagnostic); + } + if (grokCompatibility?.mode === "plain") { + // Plain output is safe only while it is prose. A changed client can + // still emit an unverified JSON envelope even after capability + // probing failed; never turn that possible tool payload into a + // persisted assistant message or diagnostic. + let unverifiedStructuredOutput = false; + if (isJson) { + try { + const candidate = JSON.parse(line); + unverifiedStructuredOutput = !!candidate && typeof candidate === "object"; + } catch { + // Plain prose can begin with a brace or bracket. It has no + // parseable structured boundary, so preserve it as text. + } + } + if (unverifiedStructuredOutput) { + recordStdoutErrorTail("Grok Build emitted an unverified structured event", true); + if (!grokProtocolQuarantineNoticeSent) { + grokProtocolQuarantineNoticeSent = true; + pushProgress( + "grok-compatibility", + "Grok Build emitted unverified structured output; continuing without tool activity", + "error", + "unverified-structured-output", + ); + } + return; + } + const text = `${resolveBackspaces(stripAnsi(line))}\n`; + // Plain mode has no native session event. Once actual assistant + // prose arrives, the launched UUID is safe to retain for persistence + // and the next resume; do not manufacture it for raw envelopes. + if (!grokSessionId && grokSessionHint) grokSessionId = grokSessionHint; + if (!sessionId && grokSessionHint) announceSession(grokSessionHint); + assistantText += text; + push({ kind: "assistant_chunk", text }); + return; + } if (!isJson) { - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + quarantineGrokProtocol("unframed-jsonl-event"); return; } try { - const event = parseGrokStreamEvent(JSON.parse(line)); + const raw = JSON.parse(line); + const event = grokStructuredProtocolQuarantined + ? parseGrokStreamEvent(raw, grokCompatibility?.schema) + : parseGrokCompatibilityEvent(raw, grokCompatibility?.schema); // A fresh native session's id is assigned by Cave because Grok only // returns it in its final frame. Once its first response frame // confirms the process is live, retain that id for a cancelled @@ -2420,7 +2690,7 @@ export async function POST(req: Request) { // launch means its --model contract accepted the selected id. if (!confirmedModel && grokForwardModel) confirmedModel = desiredModel; result = { - is_error: event.isError, + is_error: false, usage: parseStreamJsonUsage(event.usage), costUsd: parseCostUsd(event.totalCostUsd), }; @@ -2431,7 +2701,61 @@ export async function POST(req: Request) { usage: parseStreamJsonUsage(event.usage), costUsd: parseCostUsd(event.totalCostUsd), }; - recordStdoutErrorTail(event.message); + // Provider error frames are untrusted structured payloads; keep + // their values out of transcript diagnostics. + recordStdoutErrorTail("Grok Build returned a structured error", true); + return; + case "tool_start": { + boundarySentinel?.observe(event.name, event.input); + const started = toolTracker.envelopeToolUse(event.id, event.name, formatToolInputValue(event.input), assistantText.length); + if (started) push({ kind: "tool_use", ...started }); + const progress = toolTracker.consumePendingEnvelopeProgress(event.id); + if (progress) push({ kind: "tool_use", ...progress }); + const ended = toolTracker.consumePendingEnvelopeResult(event.id); + if (ended) push({ kind: "tool_use", ...ended }); + return; + } + case "tool_progress": { + const progress = toolTracker.envelopeToolProgress(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output)); + if (progress) push({ kind: "tool_use", ...progress }); + return; + } + case "tool_end": { + const ended = toolTracker.envelopeToolResult(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output), event.isError); + if (ended) push({ kind: "tool_use", ...ended }); + return; + } + case "tool_complete": { + boundarySentinel?.observe(event.name, event.input); + const started = toolTracker.envelopeToolUse(event.id, event.name, formatToolInputValue(event.input), assistantText.length); + if (started) push({ kind: "tool_use", ...started }); + const progress = toolTracker.consumePendingEnvelopeProgress(event.id); + if (progress) push({ kind: "tool_use", ...progress }); + // A terminal frame can be flushed before the combined snapshot. + // Keep ToolCallTracker's first terminal outcome instead of + // overwriting it with a later duplicate completion. + const reorderedEnd = toolTracker.consumePendingEnvelopeResult(event.id); + if (reorderedEnd) { + push({ kind: "tool_use", ...reorderedEnd }); + return; + } + const ended = toolTracker.envelopeToolResult(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output), event.isError); + if (ended) push({ kind: "tool_use", ...ended }); + return; + } + case "unknown": + grokStructuredProtocolQuarantined = true; + quarantineGrokSchema(grokCompatibility?.schema); + recordStdoutErrorTail("Grok Build emitted a malformed structured event", true); + if (!grokProtocolQuarantineNoticeSent) { + grokProtocolQuarantineNoticeSent = true; + pushProgress( + "grok-compatibility", + "Grok Build sent an unrecognized event; future turns will use safe plain chat until a compatible schema is available", + "error", + `unknown-event:${redactedGrokEventFingerprint(raw)}`, + ); + } return; case "ignore": return; @@ -2439,7 +2763,7 @@ export async function POST(req: Request) { } catch { /* not valid JSON after all — fall through to the error tail */ } - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + quarantineGrokProtocol("malformed-jsonl-event"); }; const handleOpenCodeLine = (line: string) => { @@ -2627,7 +2951,9 @@ export async function POST(req: Request) { return; } if (grokDirect) { - handleGrokLine(line, isJson); + // Plain Grok fallback must not make a structured envelope with + // leading whitespace look like harmless assistant prose. + handleGrokLine(line, isJson || /^[{\[]/.test(line.trimStart())); return; } if (openCodeDirect) { @@ -3122,7 +3448,40 @@ export async function POST(req: Request) { // location detail. : openCodeDirect ? undefined : cwd, ); - const child = sshRuntime + const reportLaunchFailure = (err: NodeJS.ErrnoException) => { + const localLaunchError = localRuntimeLaunchError( + localRuntimePlan?.runner ?? "coven", + err.code, + ); + const launchError = sshRuntime + ? err.code === "ENOENT" + ? "ssh CLI not found on PATH. Install OpenSSH or run this familiar locally." + : err.message + : localLaunchError.message; + result.is_error = true; + launchFailure ??= { + code: sshRuntime ? err.code ?? "runtime_launch_failed" : localLaunchError.code, + message: launchError, + }; + pushProgress( + "harness-start", + `${binding.harness} failed to start`, + "error", + launchError, + Date.now() - attemptStartedAt, + ); + push({ + kind: "error", + ...(sshRuntime ? (err.code ? { code: err.code } : {}) : { code: localLaunchError.code }), + message: launchError, + }); + }; + let child: + | ChildProcessWithoutNullStreams + | ChildProcessByStdio + | null = null; + try { + child = sshRuntime ? (() => { const sshArgs = spawnArgs; return spawn("ssh", sshArgs, { @@ -3171,6 +3530,7 @@ export async function POST(req: Request) { runner: localPlan.runner, command: localPlan.command, env: localPlan.env, + requiredFiles: localPlan.requiredFiles, unresolvedWindowsShim: localPlan.unresolvedWindowsShim === true, powerShellHostedCommand: @@ -3195,23 +3555,50 @@ export async function POST(req: Request) { command: localPlan.command, args: [...localPlan.fixedArgs, ...spawnArgs], }; - const child = spawn(command.command, command.args, { - // Spawn IN the familiar's workspace when no project root was - // supplied, so coven's project-root resolver picks that dir as - // root and Codex/Claude pick up AGENTS.md / SOUL.md / IDENTITY.md - // from the familiar's home. When a project root IS supplied, - // honor that instead. - cwd, - stdio: openCodeLaunchCommand?.input === undefined - ? ["ignore", "pipe", "pipe"] - : ["pipe", "pipe", "pipe"], - env: localPlan.env, - }) as ChildProcessWithoutNullStreams; + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(command.command, command.args, { + // Spawn IN the familiar's workspace when no project root was + // supplied, so coven's project-root resolver picks that dir as + // root and Codex/Claude pick up AGENTS.md / SOUL.md / IDENTITY.md + // from the familiar's home. When a project root IS supplied, + // honor that instead. + cwd, + stdio: openCodeLaunchCommand?.input === undefined + ? ["ignore", "pipe", "pipe"] + : ["pipe", "pipe", "pipe"], + env: localPlan.env, + shell: false, + }) as ChildProcessWithoutNullStreams; + } catch (error) { + const launchError = localRuntimeLaunchError( + localPlan.runner, + (error as NodeJS.ErrnoException).code, + ); + result.is_error = true; + launchFailure = launchError; + pushProgress( + "harness-start", + `${binding.harness} failed to start`, + "error", + launchError.message, + Date.now() - attemptStartedAt, + ); + push({ kind: "error", code: launchError.code, message: launchError.message }); + return null; + } if (openCodeLaunchCommand) { writeOpenCodeLaunchInput(child, openCodeLaunchCommand); } return child; })(); + } catch (error) { + // A plan can pass passive preflight yet still throw synchronously + // during spawn (notably invalid Windows executables). + reportLaunchFailure(error as NodeJS.ErrnoException); + resolve(); + return; + } if (!child) { resolve(); @@ -3219,6 +3606,7 @@ export async function POST(req: Request) { } currentChild = child; + let childLaunchFailed = false; const onAbort = () => { // Transport drop, not Stop — arm the detach cap and let the turn // finish. Deliberate stops kill through the registry instead. @@ -3311,53 +3699,18 @@ export async function POST(req: Request) { }); child.on("error", (err: NodeJS.ErrnoException) => { - // Local OS launch errors can include an absolute command, - // interpreter, or workspace path. Normalize them once and reuse - // the exact value-free message in state, progress, and SSE. - const localLaunchError = localRuntimeLaunchError( - localRuntimePlan?.runner ?? "coven", - err.code, - ); - const launchError = sshRuntime - ? err.code === "ENOENT" - ? "ssh CLI not found on PATH. Install OpenSSH or run this familiar locally." - : err.message - : localLaunchError.message; - // Race-safe fallback (#3856): the pre-spawn gate can pass and the - // binary still vanish before spawn. Mark the run errored BEFORE - // the empty-output diagnostic can run, so a launch failure is - // never misreported as "installed but not authenticated". - result.is_error = true; - launchFailure ??= { - code: localLaunchError.code, - message: launchError, - }; - pushProgress( - "harness-start", - `${binding.harness} failed to start`, - "error", - launchError, - Date.now() - attemptStartedAt, - ); - if (err.code === "ENOENT") { - push({ - kind: "error", - code: "ENOENT", - message: launchError, - }); - } else { - push({ - kind: "error", - ...(sshRuntime ? {} : { code: localLaunchError.code }), - message: launchError, - }); - } + childLaunchFailed = true; + reportLaunchFailure(err); req.signal.removeEventListener("abort", onAbort); resolve(); - close(); }); child.on("close", (code) => { + if (childLaunchFailed) { + req.signal.removeEventListener("abort", onAbort); + resolve(); + return; + } const trailingOpenCodeText = openCodeStdoutDecoder?.end(); if (trailingOpenCodeText) handleStdoutChunk(trailingOpenCodeText); const trailingStdoutText = openCodeStdoutDecoder ? "" : stdoutDecoder.end(); @@ -3366,7 +3719,7 @@ export async function POST(req: Request) { // OpenCode normally emits a JSON error envelope, but older CLI // builds can exit non-zero with only stderr. Do not mistake that // failed invocation for a successful model application below. - if ((openCodeDirect || copilotStream) && code !== 0) { + if ((openCodeDirect || copilotStream || grokDirect) && code !== 0) { result = { ...result, is_error: true }; } // Copilot JSONL stderr can contain raw prompt or tool payloads on @@ -3379,6 +3732,14 @@ export async function POST(req: Request) { stdoutErrTail.push("Copilot exited before completing its response."); } } + // Grok's stderr can include provider request details or local + // paths. Its structured errors already yield a fixed diagnostic; + // an unframed non-zero exit must receive the same redaction. + if (grokDirect) { + stderrTail.length = 0; + stdoutErrTail.length = 0; + if (code !== 0) stdoutErrTail.push("Grok Build exited before completing its response."); + } pushProgress( "harness-start", `${binding.harness} exited`, @@ -3582,7 +3943,7 @@ export async function POST(req: Request) { // OpenCode stderr can include request bodies, provider diagnostics, or // local paths. It is useful for other harnesses' existing recovery // guidance, but must never become assistant-visible/persisted text. - const tailBlock = !openCodeDirect && tailSource.length + const tailBlock = !openCodeDirect && !grokDirect && tailSource.length ? `\n\n\`\`\`\n${tailSource.slice(-5).join("\n")}\n\`\`\`` : ""; const diagnostic = result.is_error @@ -3648,11 +4009,11 @@ export async function POST(req: Request) { // us to normalize provider text. Preserve its leading indentation and // trailing blank lines in the durable transcript as well as the live // stream; other harnesses retain their established trim behavior. - const assistantTextForPersistence = openCodeDirect && openCodeCompatibility?.mode === "plain" + const assistantTextForPersistence = (openCodeDirect && openCodeCompatibility?.mode === "plain") || (grokDirect && grokCompatibility?.mode === "plain") ? assistantText : assistantText.trim(); const { text: cleanedAssistantText, attachments: agentAttachments } = - openCodeDirect && openCodeCompatibility?.mode === "plain" + (openCodeDirect && openCodeCompatibility?.mode === "plain") || (grokDirect && grokCompatibility?.mode === "plain") ? { text: assistantTextForPersistence, attachments: [] } : parseAgentAttachments(assistantTextForPersistence, { allowedRoots: sshRuntime ? [] : [cwd, ...grantedProjectRoots], @@ -3791,8 +4152,8 @@ export async function POST(req: Request) { ...(result.usage ? { usage: result.usage } : {}), ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : {}), ...(persistedTools ? { tools: persistedTools } : {}), - ...(persistedOpenCodeDiagnostics.length - ? { progress: persistedOpenCodeDiagnostics } + ...(persistedCompatibilityDiagnostics.length + ? { progress: persistedCompatibilityDiagnostics } : {}), parentId: userTurnId, responseMetadata, diff --git a/src/app/api/chat/stream/route.test.ts b/src/app/api/chat/stream/route.test.ts index 4b50843a8..b1f2ad432 100644 --- a/src/app/api/chat/stream/route.test.ts +++ b/src/app/api/chat/stream/route.test.ts @@ -81,7 +81,7 @@ test("send route tees both harness stream paths through the run buffer", () => { const seqEmits = send.match(/controller\.enqueue\(chatSse\(e(?:vent)?, seq\)\)/g); assert.equal(seqEmits?.length, 2, "both paths emit the seq as the SSE id — live clients always hold a resume cursor"); const opens = send.match(/openRunBuffer\(\[/g); - assert.equal(opens?.length, 2, "both paths open a buffer under runId + conversation keys"); + assert.equal(opens?.length, 3, "all three dispatch paths (harness, OpenClaw CLI, OpenClaw Gateway) open a buffer under runId + conversation keys"); const finishes = send.match(/runBuffer\?\.finish\(\)/g); assert.ok((finishes?.length ?? 0) >= 3, "every stream exit (error + close paths) finishes the buffer"); }); @@ -93,5 +93,5 @@ test("re-attach disarms the detach-cap kill; the last tail re-arms only after th "attach hook cancels the pending kill", ); const rearms = send.match(/detach: \(\) => \{\s*if \((?:args\.)?req\.signal\.aborted\) armDetachKill\(\);/g); - assert.equal(rearms?.length, 2, "detach hooks re-arm only when the original request is gone — a resume tail closing can't kill a still-attached turn"); + assert.equal(rearms?.length, 3, "detach hooks re-arm only when the original request is gone — a resume tail closing can't kill a still-attached turn"); }); diff --git a/src/app/api/harnesses/route.test.ts b/src/app/api/harnesses/route.test.ts index 5ae99552e..5413e0d36 100644 --- a/src/app/api/harnesses/route.test.ts +++ b/src/app/api/harnesses/route.test.ts @@ -20,6 +20,11 @@ assert.match( /grokLaunchCommandForBinary\(path\)/, "Grok probes must run npm .cmd shims through their spawn-safe launch command", ); +assert.match( + source, + /const grokProbeEnv = h\.id === "grok" \? runtime\.spawnEnv : undefined;[\s\S]*?const grokReady = h\.id === "grok" && availability\.state === "ready";[\s\S]*?const readyGrokLaunch = grokReady \? grokLaunch : null;[\s\S]*?const version = h\.id === "grok" && !grokReady[\s\S]*?copilotLaunch\?\.env \?\? grokProbeEnv,[\s\S]*?const grokCatalog = readyGrokLaunch \? await probeGrokModels\(readyGrokLaunch, grokProbeEnv\) : null;/, + "Grok's version and authenticated catalog probes must only run after the shared launchability contract reports ready in the same scoped environment", +); assert.match( source, /const resolvedBinary = h\.id === "grok" \? grokBin\(\) : h\.binary;[\s\S]*?h\.id === "grok" && resolvedBinary !== h\.binary[\s\S]*?: await which\(h\.binary\)/, @@ -27,7 +32,7 @@ assert.match( ); assert.match( source, - /async function adapterAvailability[\s\S]*?id === "copilot"[\s\S]*?const copilotLaunch = await resolveCopilotRuntimeLaunch\(stream\.executable\)[\s\S]*?availability: summarizeRuntimeAvailability\(copilotLaunch\.availability\)[\s\S]*?copilotLaunch/, + /async function adapterAvailability[\s\S]*?id === "copilot"[\s\S]*?const copilotLaunch = await resolveCopilotRuntimeLaunch\(stream\.executable,[\s\S]*?spawnEnv: \(\) => harnessSpawnEnv\(null\)[\s\S]*?availability: summarizeRuntimeAvailability\(copilotLaunch\.availability\)[\s\S]*?copilotLaunch/, "Copilot availability retains the shared exact launch plan for internal catalog probes", ); assert.match( @@ -37,7 +42,7 @@ assert.match( ); assert.match( source, - /const version = await probeVersion\([\s\S]*?copilotLaunch\?\.command[\s\S]*?copilotLaunch\?\.fixedArgs[\s\S]*?copilotLaunch\?\.env/, + /const version = h\.id === "grok" && !grokReady[\s\S]*?copilotLaunch\?\.command[\s\S]*?copilotLaunch\?\.fixedArgs[\s\S]*?copilotLaunch\?\.env/, "Copilot version discovery uses the exact resolved command, fixed arguments, and credential-free environment", ); assert.match( diff --git a/src/app/api/harnesses/route.ts b/src/app/api/harnesses/route.ts index 364b70ac4..5d9d28446 100644 --- a/src/app/api/harnesses/route.ts +++ b/src/app/api/harnesses/route.ts @@ -59,6 +59,8 @@ type AdapterAvailability = { availability: RuntimeAvailabilitySummary; /** Internal-only exact Copilot plan; never serialized inside availability. */ copilotLaunch?: CopilotRuntimeLaunch; + /** Internal-only environment used for a direct runner's availability check. */ + spawnEnv?: NodeJS.ProcessEnv; }; // Mirrors the send route's launch dispatch: copilot/grok/hermes/opencode use @@ -69,7 +71,9 @@ async function adapterAvailability(id: string): Promise { if (id === "copilot") { const stream = copilotStreamSpec(); if (stream) { - const copilotLaunch = await resolveCopilotRuntimeLaunch(stream.executable); + const copilotLaunch = await resolveCopilotRuntimeLaunch(stream.executable, { + spawnEnv: () => harnessSpawnEnv(null), + }); return { availability: summarizeRuntimeAvailability(copilotLaunch.availability), copilotLaunch, @@ -99,6 +103,7 @@ async function adapterAvailability(id: string): Promise { env, unresolvedWindowsShim: launch.unresolvedWindowsShim === true, })), + spawnEnv: env, }; } if (id === "hermes") { @@ -184,11 +189,12 @@ function probeVersion( function probeGrokModels( launch: CovenLaunchCommand, + env: NodeJS.ProcessEnv = covenSpawnEnv(), ): Promise<{ models: RuntimeModelOption[]; defaultModel: string | null }> { return new Promise((resolve) => { let child; try { - child = spawn(launch.command, [...launch.fixedArgs, "--no-auto-update", "models"], { env: covenSpawnEnv(), stdio: ["ignore", "pipe", "pipe"] }); + child = spawn(launch.command, [...launch.fixedArgs, "--no-auto-update", "models"], { env, stdio: ["ignore", "pipe", "pipe"] }); } catch { resolve({ models: [], defaultModel: null }); return; @@ -300,15 +306,20 @@ export async function GET() { return { ...h, installed: false, path: null, version: null, availability }; } const grokLaunch = h.id === "grok" ? grokLaunchCommandForBinary(path) : null; - const version = await probeVersion( - copilotLaunch?.command ?? grokLaunch?.command ?? h.binary, - copilotLaunch - ? [COPILOT_NO_AUTO_UPDATE_ARG, ...(h.versionArgs ?? ["--version"])] - : h.versionArgs ?? ["--version"], - copilotLaunch?.fixedArgs ?? grokLaunch?.fixedArgs, - copilotLaunch?.env, - ); - const grokCatalog = grokLaunch ? await probeGrokModels(grokLaunch) : null; + const grokProbeEnv = h.id === "grok" ? runtime.spawnEnv : undefined; + const grokReady = h.id === "grok" && availability.state === "ready"; + const readyGrokLaunch = grokReady ? grokLaunch : null; + const version = h.id === "grok" && !grokReady + ? null + : await probeVersion( + copilotLaunch?.command ?? readyGrokLaunch?.command ?? h.binary, + copilotLaunch + ? [COPILOT_NO_AUTO_UPDATE_ARG, ...(h.versionArgs ?? ["--version"])] + : h.versionArgs ?? ["--version"], + copilotLaunch?.fixedArgs ?? readyGrokLaunch?.fixedArgs, + copilotLaunch?.env ?? grokProbeEnv, + ); + const grokCatalog = readyGrokLaunch ? await probeGrokModels(readyGrokLaunch, grokProbeEnv) : null; return { ...h, installed: true, diff --git a/src/components/chat-familiar-capabilities.test.ts b/src/components/chat-familiar-capabilities.test.ts new file mode 100644 index 000000000..fef229c53 --- /dev/null +++ b/src/components/chat-familiar-capabilities.test.ts @@ -0,0 +1,21 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const source = readFileSync( + new URL("./chat-familiar-capabilities.tsx", import.meta.url), + "utf8", +); + +assert.match( + source, + /const availability = h\.availability;[\s\S]{0,100}if \(availability && availability\.state !== "ready"\)[\s\S]{0,240}label: `\$\{h\.label\}\$\{availability\.state === "missing" \? " \(not installed\)" : " \(unavailable\)"\}`,[\s\S]{0,100}detail: availability\.message/, + "the Familiar capability runtime picker must distinguish an unlaunchable runtime and show the shared remediation", +); +assert.match( + source, + /label: h\.label,[\s\S]{0,180}h\.installed \? null : "not installed"/, + "legacy reports without availability keep their existing installed/not-installed treatment", +); + +console.log("chat-familiar-capabilities.test.ts: ok"); diff --git a/src/components/chat-familiar-capabilities.tsx b/src/components/chat-familiar-capabilities.tsx index 02325757e..ab2035095 100644 --- a/src/components/chat-familiar-capabilities.tsx +++ b/src/components/chat-familiar-capabilities.tsx @@ -103,11 +103,21 @@ function FamiliarIdentityHero({ // daemon reports (Coven Code) aren't per-familiar runtime choices. ...harnesses .filter((h) => isBindableRuntimeChoice(h.id)) - .map((h) => ({ - value: h.id, - label: h.label, - detail: [h.version, h.installed ? null : "not installed"].filter(Boolean).join(" · ") || undefined, - })), + .map((h) => { + const availability = h.availability; + if (availability && availability.state !== "ready") { + return { + value: h.id, + label: `${h.label}${availability.state === "missing" ? " (not installed)" : " (unavailable)"}`, + detail: availability.message, + }; + } + return { + value: h.id, + label: h.label, + detail: [h.version, h.installed ? null : "not installed"].filter(Boolean).join(" · ") || undefined, + }; + }), ]; // Model select: sourced from the same runtime → provider catalog the chat @@ -342,6 +352,7 @@ function familiarCapabilitySummary(familiar: ResolvedFamiliar, snapshot: Capabil capabilityCount: enabledPlugins, runtime: [harness?.label ?? harnessId, familiar.model].filter(Boolean).join(" · "), installed: harness?.installed, + availability: harness?.availability, }; } @@ -421,7 +432,7 @@ function FamiliarScopeOverview({ {summary.skillCount} skill{summary.skillCount === 1 ? "" : "s"} {summary.capabilityCount} runtime capabilit{summary.capabilityCount === 1 ? "y" : "ies"} {activeSessions > 0 ? {activeSessions} active : null} - {summary.installed === false ? runtime unavailable : null} + {summary.installed === false || (summary.availability && summary.availability.state !== "ready") ? runtime unavailable : null} diff --git a/src/components/daemon-start-button.test.ts b/src/components/daemon-start-button.test.ts index 788f3447d..1e41c5779 100644 --- a/src/components/daemon-start-button.test.ts +++ b/src/components/daemon-start-button.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -const settings = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const settingsShell = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const settingsDaemon = await readFile(new URL("./settings-daemon.tsx", import.meta.url), "utf8"); +const settings = `${settingsShell}\n${settingsDaemon}`; const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8"); assert.match(settings, /fetch\("\/api\/daemon\/start", \{ method: "POST" \}\)/); diff --git a/src/components/familiar-studio-brain-tab.test.ts b/src/components/familiar-studio-brain-tab.test.ts index ca4f7ed96..15328e9ee 100644 --- a/src/components/familiar-studio-brain-tab.test.ts +++ b/src/components/familiar-studio-brain-tab.test.ts @@ -18,6 +18,11 @@ assert.match( /catalogForRuntime/, "Brain tab model menu should source options from the runtime → provider catalog", ); +assert.match( + source, + /h\.availability && h\.availability\.state !== "ready"[\s\S]*?h\.availability\.message/, + "Runtime picker should reuse server-provided launchability remediation instead of treating an installed but unlaunchable CLI as ready", +); assert.match( source, /modelOptions\.map/, @@ -81,6 +86,11 @@ assert.match( /label: "Available runtimes"[\s\S]{0,360}harnesses\s*\.filter\(\(h\) => isBindableRuntimeChoice\(h\.id\)\)[\s\S]{0,120}\.map/, "Other available runtimes group below the inherited default, filtered to bindable choices (no Coven Code)", ); +assert.match( + source, + /h\.availability && h\.availability\.state !== "ready"[\s\S]{0,180}" \(unavailable\)"[\s\S]{0,160}detail: h\.availability\?\.state !== "ready"/, + "Runtime picker labels a discovered-but-unlaunchable CLI as unavailable and shows the shared remediation", +); assert.match( source, /\/api\/capabilities\?harness=/, diff --git a/src/components/familiar-studio-brain-tab.tsx b/src/components/familiar-studio-brain-tab.tsx index 7a0292a71..1bac1b201 100644 --- a/src/components/familiar-studio-brain-tab.tsx +++ b/src/components/familiar-studio-brain-tab.tsx @@ -11,6 +11,7 @@ import { StandardSelect, type StandardSelectGroup } from "@/components/ui/select import { isBindableRuntimeChoice } from "@/lib/harness-adapters"; import { catalogForRuntime } from "@/lib/runtime-models"; import type { RuntimeModelOption } from "@/lib/grok-build"; +import type { RuntimeAvailabilitySummary } from "@/lib/runtime-availability"; import { useRuntimeModelOptions } from "@/lib/use-runtime-model-options"; import { FamiliarAsanaSection } from "@/components/familiar-asana-section"; import { IconButton } from "@/components/ui/icon-button"; @@ -71,6 +72,7 @@ type HarnessReport = { id: string; label: string; installed: boolean; + availability?: RuntimeAvailabilitySummary; models?: RuntimeModelOption[]; defaultModel?: string | null; }; @@ -838,10 +840,24 @@ export function FamiliarStudioBrainTab({ familiar }: Props) { // per-familiar runtime choices. options: harnesses .filter((h) => isBindableRuntimeChoice(h.id)) - .map((h) => ({ - value: h.id, - label: `${h.label}${h.installed ? "" : " (not installed)"}`, - })), + .map((h) => { + const availabilityMessage = h.availability && h.availability.state !== "ready" + ? h.availability.message + : undefined; + return { + value: h.id, + label: `${h.label}${ + h.availability && h.availability.state !== "ready" + ? h.availability.state === "missing" + ? " (not installed)" + : " (unavailable)" + : h.installed ? "" : " (not installed)" + }`, + detail: h.availability?.state !== "ready" + ? h.availability?.message + : availabilityMessage, + }; + }), } satisfies StandardSelectGroup, ]} /> diff --git a/src/components/familiar-summoning-circle.test.ts b/src/components/familiar-summoning-circle.test.ts index 7b88cff93..d71f21357 100644 --- a/src/components/familiar-summoning-circle.test.ts +++ b/src/components/familiar-summoning-circle.test.ts @@ -28,6 +28,11 @@ assert.match( /fetch\("\/api\/familiars",\s*\{[\s\S]*?method:\s*"POST"/, "the circle should POST to /api/familiars", ); +assert.match( + source, + /vessel !== "local" \|\| h\.availability\?\.state === undefined \|\| h\.availability\.state === "ready"/, + "Local runtime choices must exclude installed runners the shared preflight says are not launchable", +); assert.doesNotMatch( source, /onboarding\/setup/, @@ -45,6 +50,16 @@ assert.match( /fetch\("\/api\/harnesses"/, "local/SSH vessels list installed runtimes from /api/harnesses", ); +assert.match( + source, + /h\.installed &&[\s\S]{0,120}\(h\.availability\?\.state \?\? "ready"\) === "ready"/, + "the familiar picker excludes runners that the shared availability contract cannot launch", +); +assert.match( + source, + /const unavailableHarness = \(harnesses \?\? \[\]\)\.find\([\s\S]*?h\.availability !== undefined[\s\S]*?h\.availability\.state !== "ready"[\s\S]*?const unavailableAvailability = unavailableHarness\?\.availability;[\s\S]*?unavailableAvailability\.message/, + "the familiar picker surfaces shared availability remediation for both missing and unlaunchable runtimes", +); assert.match( harnessesRoute, /runtimeHost: hostname\(\)/, diff --git a/src/components/familiar-summoning-circle.tsx b/src/components/familiar-summoning-circle.tsx index 9f75b021c..bf69bf24b 100644 --- a/src/components/familiar-summoning-circle.tsx +++ b/src/components/familiar-summoning-circle.tsx @@ -772,8 +772,22 @@ function StageVessel({ // launcher. Hide it for SSH rather than letting a selection fall back to an // incompatible `coven run --stream-json` path. const installedHarnesses = (harnesses ?? []).filter( - (h) => h.installed && (vessel !== "ssh" || h.id !== "grok"), + (h) => + h.installed && + (vessel !== "local" || h.availability?.state === undefined || h.availability.state === "ready") && + (h.availability?.state ?? "ready") === "ready" && + (vessel !== "ssh" || h.id !== "grok"), ); + const unavailableHarness = (harnesses ?? []).find( + (h) => + h.availability !== undefined && + h.availability.state !== "ready" && + (vessel !== "ssh" || h.id !== "grok"), + ); + const unavailableAvailability = unavailableHarness?.availability; + const unavailableRuntimeMessage = unavailableAvailability && unavailableAvailability.state !== "ready" + ? unavailableAvailability.message + : "No chat-capable runtime found. Run setup to install one (Codex, Claude Code, Copilot…), then return to the circle."; return (
@@ -809,7 +823,7 @@ function StageVessel({ // users between the circle and Settings (cave-tpji).

- No chat-capable runtime found. Run setup to install one (Codex, Claude Code, Copilot…), then return to the circle. + {unavailableRuntimeMessage}

+ ) : availabilityProblem ? ( + + unavailable + ) : adapter.installed ? ( installed @@ -2040,10 +2053,15 @@ function StepRuntimes({ ) : null}
- {adapter.installed + {launchable ? (adapter.path ?? adapter.binary) : adapter.binary}
+ {availabilityMessage ? ( +

+ {availabilityMessage} +

+ ) : null} {openClaw ? (
Agents are discovered from{" "} diff --git a/src/components/settings-action-buttons.test.ts b/src/components/settings-action-buttons.test.ts index 4adf4597b..1ff65e375 100644 --- a/src/components/settings-action-buttons.test.ts +++ b/src/components/settings-action-buttons.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import ts from "typescript"; const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const daemon = readFileSync(new URL("./settings-daemon.tsx", import.meta.url), "utf8"); const picker = readFileSync(new URL("./settings-familiar-picker.tsx", import.meta.url), "utf8"); const controls = readFileSync(new URL("./ui/settings-controls.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); @@ -45,12 +46,13 @@ function jsxElementBlocks(fileName: string, source: string, tagName: string): st } const reviewedSemanticControl = (button: string): boolean => - /goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test( + /goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-expanded=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test( button, ); for (const [name, source] of [ ["settings shell", shell], + ["settings daemon", daemon], ["settings familiar picker", picker], ["settings segmented control", controls], ...studioSources, @@ -72,7 +74,7 @@ for (const [name, source] of [ } } -const saveConnectionButtons = jsxElementBlocks("settings-shell.tsx", shell, "Button").filter((block) => +const saveConnectionButtons = jsxElementBlocks("settings-daemon.tsx", daemon, "Button").filter((block) => block.includes("Save connection"), ); assert.equal(saveConnectionButtons.length, 1, "Save connection renders through exactly one shared Button"); diff --git a/src/components/settings-daemon-multihost.test.ts b/src/components/settings-daemon-multihost.test.ts index 38d0269b4..e034ee91d 100644 --- a/src/components/settings-daemon-multihost.test.ts +++ b/src/components/settings-daemon-multihost.test.ts @@ -1,26 +1,107 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; -const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const shellEntry = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const daemonUrl = new URL("./settings-daemon.tsx", import.meta.url); +const daemon = existsSync(daemonUrl) ? readFileSync(daemonUrl, "utf8") : ""; +const shell = `${shellEntry}\n${daemon}`; const sections = readFileSync(new URL("./settings-sections.ts", import.meta.url), "utf8"); +const daemonCssUrl = new URL("../styles/settings-daemon.css", import.meta.url); +const daemonCss = existsSync(daemonCssUrl) ? readFileSync(daemonCssUrl, "utf8") : ""; +// ── Claude Design daemon control sheet ─────────────────────────────────────── assert.match( + shellEntry, + /import \{ DaemonSection \} from "\.\/settings-daemon"/, + "SettingsShell should delegate the daemon control sheet to a focused component", +); +assert.match( + daemon, + /export function DaemonSection/, + "the focused daemon module should export the Settings section", +); +assert.match(daemon, /className="settings-daemon"/, "the daemon page should own a responsive control-sheet container"); +assert.match(daemon, /className="settings-daemon-hero"/, "the daemon page should open with the approved compact hero"); +assert.match(daemon, /Settings · Daemon/, "the hero should carry the approved settings kicker"); +assert.match(daemon, /className="settings-daemon-chip-list"/, "the hero should summarize target, API, queue, and uptime"); +assert.match(daemon, />\s*Refresh\s*HOMEAWAY\s*Revert\s*\s*Save connection\s*\}/, "the Vault-gated Omnigent settings must remain reachable from Daemon"); + +assert.match( + daemonCss, + /@container settings-daemon \(max-width:/, + "the control sheet should adapt to its pane with a container query", +); +assert.match( + daemonCss, + /@media \(prefers-reduced-motion: reduce\)/, + "daemon-specific motion should have an explicit reduced-motion treatment", +); +assert.doesNotMatch( + daemonCss, + /(?:gap|padding|width|height):\s*(?:2|3|6|8|12)px|box-shadow:\s*inset\s+2px/, + "daemon micro-spacing should use the design-system spacing tokens", +); +assert.doesNotMatch( shell, - /type MultiHostMode = "local" \| "hub"/, - "SettingsShell should model local vs server hub mode explicitly", + /bg-red-400/, + "daemon error states should use the semantic danger token across every theme", ); assert.match( shell, - /fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/, - "Daemon settings should load Cave config before rendering connection controls", + /type MultiHostMode = "local" \| "hub"/, + "SettingsShell should model local vs server hub mode explicitly", ); assert.match( shell, - /body: JSON\.stringify\(\{ multiHost: \{ mode: nextMode, hubUrl, executorUrls: parseExecutorUrls\(executorText\) \} \}\)/, - "Daemon settings should persist the selected connection mode through cave-config", + /fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/, + "Daemon settings should load Cave config before rendering connection controls", ); assert.match( diff --git a/src/components/settings-daemon.tsx b/src/components/settings-daemon.tsx new file mode 100644 index 000000000..c9357a26e --- /dev/null +++ b/src/components/settings-daemon.tsx @@ -0,0 +1,956 @@ +"use client"; + +import "@/styles/settings-daemon.css"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button } from "@/components/ui/button"; +import { ErrorState } from "@/components/ui/error-state"; +import { IconButton } from "@/components/ui/icon-button"; +import { useAnnouncer } from "@/components/ui/live-region"; +import { RelativeTime } from "@/components/ui/relative-time"; +import { TextArea } from "@/components/ui/text-area"; +import { TextInput } from "@/components/ui/text-input"; +import { copyText } from "@/lib/clipboard"; +import { Icon, type IconName } from "@/lib/icon"; +import { parseExecutorUrls } from "./settings-multihost"; + +type DaemonStatus = { + running: boolean; + reason?: string; + checkedAt?: string; + covenVersion?: string; + apiVersion?: string; + workspacePath?: string; + daemon?: { pid: number; startedAt: string; socket: string }; + executors?: Array<{ + url: string; + healthUrl: string; + ok: boolean; + state: "available" | "unreachable"; + detail: string; + }>; + target?: { + mode: "local" | "hub" | "unconfigured-hub"; + label: string; + socket?: string; + url?: string; + error?: string; + }; + travel?: { + mode: "home" | "hub" | "watching-hub" | "travel" | "handoff-pending"; + authority: "local" | "hub" | "travel-local"; + reason: string; + manualOffline: boolean; + staleCache: boolean; + wakeLocalSubdaemon: boolean; + localBindHost: "127.0.0.1"; + hubUnreachableSince: string | null; + hubUnreachableForMs: number; + pendingQueueCount: number; + handoffPending: boolean; + }; +}; + +type MultiHostMode = "local" | "hub"; + +type TailscaleDevice = { + name: string; + dnsName: string | null; + hostName: string | null; + tailnetIp: string | null; + os: string | null; + online: boolean; + lastSeen: string | null; + isSelf: boolean; +}; + +type DaemonProbe = { + reachable: boolean; + status: number; + latencyMs: number; + reason?: string; + url: string; +}; + +type SavedConnection = { + mode: MultiHostMode; + hubUrl: string; + executorUrls: string[]; +}; + +type StatusTone = "danger" | "neutral" | "success" | "warning"; + +const RUNTIME_TARGETS: Array<{ + id: MultiHostMode; + label: string; + blurb: string; + icon: IconName; +}> = [ + { + id: "local", + label: "Local", + blurb: "Runs everything on this machine. The default, and the fastest path.", + icon: "ph:terminal-window", + }, + { + id: "hub", + label: "Server hub", + blurb: "Connects to a shared daemon on another machine over your tailnet.", + icon: "ph:cloud-bold", + }, +]; + +function SectionRule({ id, label, meta }: { id: string; label: string; meta?: ReactNode }) { + return ( +
+

{label}

+
+ ); +} + +function isValidHubUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname); + } catch { + return false; + } +} + +function classifyTailscaleFailure(raw: string): { headline: string; hint: string } { + const text = raw.toLowerCase(); + if (text.includes("tailscale") && (text.includes("not installed") || text.includes("cli not found"))) { + return { + headline: "Tailscale isn’t installed", + hint: "Install Tailscale and sign in, then refresh devices.", + }; + } + if ( + text.includes("tailscale") && + (text.includes("signed out") || + text.includes("logged out") || + text.includes("not connected") || + text.includes("not running") || + text.includes("stopped") || + text.includes("unreachable")) + ) { + return { + headline: "Tailscale isn’t running", + hint: "Open Tailscale and sign in, then refresh devices.", + }; + } + return { + headline: "Tailnet devices unavailable", + hint: "Retry device discovery, or enter the server hub URL directly.", + }; +} + +export function DaemonSection({ + suggestedHubUrl, + onSuggestionConsumed, + omnigentSettings, +}: { + suggestedHubUrl: string | null; + onSuggestionConsumed: () => void; + omnigentSettings?: ReactNode; +}) { + const { announce } = useAnnouncer(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [statusLoadError, setStatusLoadError] = useState(null); + const [starting, setStarting] = useState(false); + const [restarting, setRestarting] = useState(false); + const [startError, setStartError] = useState(null); + + const [mode, setMode] = useState("local"); + const [hubUrl, setHubUrl] = useState(""); + const [executorText, setExecutorText] = useState(""); + const [savedConnection, setSavedConnection] = useState(null); + const [savingConnection, setSavingConnection] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [executorsOpen, setExecutorsOpen] = useState(false); + + const [devices, setDevices] = useState([]); + const [devicesLoading, setDevicesLoading] = useState(false); + const [devicesError, setDevicesError] = useState(null); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + + const [savingTravel, setSavingTravel] = useState(false); + const [travelError, setTravelError] = useState(null); + const [copiedInfo, setCopiedInfo] = useState<"socket" | "workspace" | null>(null); + + const refreshCtlRef = useRef(null); + const devicesCtlRef = useRef(null); + const suggestionAppliedRef = useRef(false); + const copyTimerRef = useRef | null>(null); + + const refresh = useCallback(async (announceResult = false) => { + refreshCtlRef.current?.abort(); + const ctl = new AbortController(); + refreshCtlRef.current = ctl; + setLoading(true); + setStatusLoadError(null); + try { + const response = await fetch("/api/daemon/status", { cache: "no-store", signal: ctl.signal }); + const result = await response.json().catch(() => ({})) as DaemonStatus & { error?: string }; + if (!response.ok) throw new Error(result.error || `status failed (${response.status})`); + if (ctl.signal.aborted) return; + setStatus(result); + if (announceResult) announce("Daemon status refreshed."); + } catch (error) { + if (ctl.signal.aborted) return; + const message = error instanceof Error ? error.message : "daemon status unavailable"; + setStatusLoadError(message); + if (announceResult) announce(`Couldn't refresh daemon status: ${message}`, "assertive"); + } finally { + if (!ctl.signal.aborted) setLoading(false); + } + }, [announce]); + + useEffect(() => { + void refresh(); + return () => refreshCtlRef.current?.abort(); + }, [refresh]); + + useEffect(() => { + const ctl = new AbortController(); + fetch("/api/config", { cache: "no-store", signal: ctl.signal }) + .then((response) => response.json()) + .then((result: { + ok?: boolean; + config?: { + multiHost?: { + mode?: MultiHostMode; + hubUrl?: string; + executorUrls?: string[]; + }; + }; + }) => { + if (ctl.signal.aborted || !result.ok) return; + const multiHost = result.config?.multiHost; + const next: SavedConnection = { + mode: multiHost?.mode === "hub" ? "hub" : "local", + hubUrl: multiHost?.hubUrl ?? "", + executorUrls: multiHost?.executorUrls ?? [], + }; + setSavedConnection(next); + setExecutorText(next.executorUrls.join("\n")); + setExecutorsOpen(next.executorUrls.length > 0); + if (suggestionAppliedRef.current) return; + setMode(next.mode); + setHubUrl(next.hubUrl); + }) + .catch(() => { + if (!ctl.signal.aborted) setConnectionError("Couldn’t load the saved daemon connection."); + }); + return () => ctl.abort(); + }, []); + + useEffect(() => { + if (!suggestedHubUrl) return; + suggestionAppliedRef.current = true; + setMode("hub"); + setHubUrl(suggestedHubUrl); + setProbe(null); + onSuggestionConsumed(); + }, [onSuggestionConsumed, suggestedHubUrl]); + + useEffect(() => () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }, []); + + const loadDevices = useCallback(() => { + devicesCtlRef.current?.abort(); + const ctl = new AbortController(); + devicesCtlRef.current = ctl; + setDevicesLoading(true); + setDevicesError(null); + fetch("/api/tailscale/devices", { cache: "no-store", signal: ctl.signal }) + .then((response) => response.json()) + .then((result: { ok?: boolean; devices?: TailscaleDevice[]; reason?: string }) => { + if (ctl.signal.aborted) return; + if (!result.ok) { + setDevices([]); + setDevicesError(result.reason || "Tailscale status unavailable"); + } else { + setDevices(result.devices ?? []); + } + setDevicesLoading(false); + }) + .catch((error) => { + if (ctl.signal.aborted) return; + setDevicesError(error instanceof Error ? error.message : "Tailscale status unavailable"); + setDevicesLoading(false); + }); + }, []); + + useEffect(() => { + if (mode !== "hub") { + devicesCtlRef.current?.abort(); + return; + } + loadDevices(); + return () => devicesCtlRef.current?.abort(); + }, [loadDevices, mode]); + + const connectionDirty = savedConnection !== null && ( + mode !== savedConnection.mode || + hubUrl.trim() !== savedConnection.hubUrl.trim() || + JSON.stringify(parseExecutorUrls(executorText)) !== JSON.stringify(savedConnection.executorUrls) + ); + + const persistConnection = async (nextMode = mode) => { + const normalizedHubUrl = hubUrl.trim(); + const normalizedExecutorUrls = parseExecutorUrls(executorText); + setSavingConnection(true); + setConnectionError(null); + try { + const res = await fetch("/api/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ multiHost: { mode: nextMode, hubUrl: normalizedHubUrl, executorUrls: normalizedExecutorUrls } }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || `save failed (${res.status})`); + } + setMode(nextMode); + setHubUrl(normalizedHubUrl); + setSavedConnection({ + mode: nextMode, + hubUrl: normalizedHubUrl, + executorUrls: normalizedExecutorUrls, + }); + announce("Daemon connection saved."); + await refresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : "could not save daemon connection"; + setConnectionError(msg); + announce(`Couldn't save daemon connection: ${msg}`, "assertive"); + } finally { + setSavingConnection(false); + } + }; + + const probeHub = async (candidate: string, saveWhenReachable: boolean) => { + const url = candidate.trim(); + if (!url) { + setConnectionError("Enter a Server hub URL."); + return; + } + if (!isValidHubUrl(url)) { + setConnectionError("Enter a full HTTP URL, such as http://server.tailnet:8787."); + return; + } + setProbing(true); + setConnectionError(null); + try { + const response = await fetch("/api/daemon/probe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url }), + }); + const result = await response.json().catch(() => ({})) as Partial & { + ok?: boolean; + error?: string; + }; + if (!response.ok || result.ok === false || typeof result.reachable !== "boolean") { + throw new Error(result.error || `probe failed (${response.status})`); + } + const nextProbe: DaemonProbe = { + reachable: result.reachable, + status: result.status ?? 0, + latencyMs: result.latencyMs ?? 0, + reason: result.reason, + url, + }; + setProbe(nextProbe); + if (nextProbe.reachable && saveWhenReachable) await persistConnection("hub"); + } catch (error) { + const message = error instanceof Error ? error.message : "could not probe Server hub"; + setConnectionError(message); + announce(`Couldn't check the Server hub: ${message}`, "assertive"); + } finally { + setProbing(false); + } + }; + + const saveConnection = async (nextMode = mode) => { + if (nextMode === "hub") { + await probeHub(hubUrl, true); + return; + } + await persistConnection(nextMode); + }; + + const chooseMode = (nextMode: MultiHostMode) => { + setMode(nextMode); + setConnectionError(null); + setProbe(null); + }; + + const revertConnection = () => { + if (!savedConnection) return; + setMode(savedConnection.mode); + setHubUrl(savedConnection.hubUrl); + setExecutorText(savedConnection.executorUrls.join("\n")); + setExecutorsOpen(savedConnection.executorUrls.length > 0); + setConnectionError(null); + setProbe(null); + announce("Daemon connection changes reverted."); + }; + + const selectDevice = (device: TailscaleDevice) => { + const host = device.tailnetIp || device.dnsName; + if (!host) return; + const url = `http://${host}:8787`; + setMode("hub"); + setHubUrl(url); + setProbe(null); + void probeHub(url, false); + }; + + const startDaemon = async () => { + setStarting(true); + setStartError(null); + try { + const res = await fetch("/api/daemon/start", { method: "POST" }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || json?.stderr || "daemon did not start"); + } + announce("Daemon started."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "daemon did not start"; + setStartError(message); + announce(`Couldn't start the daemon: ${message}`, "assertive"); + } finally { + setStarting(false); + } + }; + + const restartDaemon = async () => { + setRestarting(true); + setStartError(null); + try { + const res = await fetch("/api/daemon/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ restart: true }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || json?.stderr || "daemon did not restart"); + } + announce("Daemon restarted."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "daemon did not restart"; + setStartError(message); + announce(`Couldn't restart the daemon: ${message}`, "assertive"); + } finally { + setRestarting(false); + } + }; + + const setManualOffline = async (manualOffline: boolean) => { + setSavingTravel(true); + setTravelError(null); + try { + const res = await fetch("/api/travel/client", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ manualOffline }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || `travel mode save failed (${res.status})`); + } + announce(manualOffline ? "Daemon set to manual offline." : "Daemon back online."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "could not save travel mode"; + setTravelError(message); + announce(`Couldn't update travel mode: ${message}`, "assertive"); + } finally { + setSavingTravel(false); + } + }; + + const copyInfoValue = async (key: "socket" | "workspace", label: string, value?: string) => { + if (!value) return; + const ok = await copyText(value); + announce(ok ? `${label} copied.` : `Couldn't copy ${label.toLowerCase()}.`, ok ? "polite" : "assertive"); + if (!ok) return; + setCopiedInfo(key); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopiedInfo(null), 1200); + }; + + const manualOffline = status?.travel?.manualOffline === true; + const daemonOnline = status?.running === true && !manualOffline; + const statusTone: StatusTone = loading + ? "neutral" + : restarting || starting || manualOffline + ? "warning" + : status?.running + ? "success" + : "danger"; + const stateLabel = loading + ? "Checking…" + : restarting + ? "Restarting…" + : starting + ? "Starting…" + : manualOffline + ? "Offline (manual)" + : status?.running + ? "Running" + : "Offline"; + const targetLabel = status?.target?.label || (mode === "hub" ? "server hub" : "local runtime"); + const queueCount = status?.travel?.pendingQueueCount ?? 0; + const isAway = Boolean(status?.travel && status.travel.mode !== "home"); + const normalizedExecutorCount = parseExecutorUrls(executorText).length; + const hubUrlValid = isValidHubUrl(hubUrl); + const hubBadge = hubUrl.trim() ? (hubUrlValid ? "valid" : "check url") : "required"; + const hubBadgeTone = hubUrl.trim() ? (hubUrlValid ? "success" : "danger") : "neutral"; + + const statusMetrics = useMemo(() => [ + { + label: "AUTHORITY", + value: status?.travel?.authority || (mode === "hub" ? "server hub" : "local daemon"), + alert: false, + }, + { + label: "PENDING QUEUE", + value: String(queueCount), + alert: queueCount > 0, + }, + { + label: "LOCAL BIND", + value: status?.travel?.localBindHost || "127.0.0.1", + alert: false, + }, + { + label: "STALE CACHE", + value: status?.travel?.staleCache ? "yes" : "no", + alert: status?.travel?.staleCache === true, + }, + { + label: "WAKE LOCAL", + value: status?.travel?.wakeLocalSubdaemon ? "requested" : "standby", + alert: false, + }, + { + label: "HANDOFF", + value: status?.travel?.handoffPending ? "pending sync" : "clear", + alert: status?.travel?.handoffPending === true, + }, + ], [mode, queueCount, status?.travel]); + + const infoRows: Array<{ + key: string; + label: string; + value: ReactNode; + rawValue?: string; + copyKey?: "socket" | "workspace"; + }> = [ + { key: "coven", label: "Coven version", value: status?.covenVersion ?? "—" }, + { key: "api", label: "API version", value: status?.apiVersion ?? "—" }, + { + key: "socket", + label: "Socket", + value: status?.daemon?.socket ?? "—", + rawValue: status?.daemon?.socket, + copyKey: "socket", + }, + { + key: "workspace", + label: "Workspace", + value: status?.workspacePath ?? "—", + rawValue: status?.workspacePath, + copyKey: "workspace", + }, + { + key: "started", + label: "Started", + value: , + }, + ]; + + const tailscaleFailure = devicesError ? classifyTailscaleFailure(devicesError) : null; + + return ( +
+
+ +
+

Settings · Daemon

+

Daemon

+
    +
  • {targetLabel}
  • +
  • {status?.apiVersion || "coven.daemon.v1"}
  • +
  • 0 ? "warning" : "neutral"} />{queueCount} queued
  • +
  • + + up +
  • +
+
+
+ + {!loading && !status?.running && mode === "local" && ( + + )} + {status?.running && ( + + )} +
+
+ +
+ checked : "not checked"} + /> +
+
+
+ {statusLoadError ? ( + void refresh(true)}>Retry} + /> + ) : null} +
+ +
+ + {savedConnection === null ? "loading…" : connectionDirty ? "unsaved changes" : "saved"} + + } + /> +
+
+ RUNTIME TARGET +
+
+ {RUNTIME_TARGETS.map((target) => ( + + ))} +
+ + {mode === "hub" ? ( + <> +
+
+ + HTTP endpoint on your private network +
+
+ { + setHubUrl(event.target.value); + setProbe(null); + setConnectionError(null); + }} + aria-label="Server hub URL" + aria-describedby="settings-daemon-hub-hint" + placeholder="http://server.tailnet:8787" + spellCheck={false} + /> + {hubBadge} +
+ {probing || probe?.url === hubUrl.trim() ? ( +

+ {probing + ? "Checking reachability…" + : probe?.reachable + ? `Reachable · ${probe.latencyMs} ms` + : `Unreachable${probe?.reason ? ` · ${probe.reason}` : ""}`} +

+ ) : null} +
+ +
+
+
+ Tailnet devices + Choose a private-network machine or enter its address above. +
+ +
+ {tailscaleFailure ? ( +
+ {tailscaleFailure.headline} + {tailscaleFailure.hint} +
+ ) : null} + {!tailscaleFailure && !devicesLoading && devices.length === 0 ? ( +

No tailnet devices found.

+ ) : null} + {devices.length > 0 ? ( +
+ {devices.map((device) => { + const selectable = Boolean(device.tailnetIp || device.dnsName); + return ( + + ); + })} +
+ ) : null} +
+ + ) : null} + +
+ + {executorsOpen ? ( +
+