diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 26e9ddc5c..e96b4e76e 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1,5 +1,7 @@ # Troubleshooting +- `.papercuts/` is ignored even when its troubleshooting file is present in the PR branch, so persisting a required update needs an explicit `git add -f`. +- Layout stabilization must race `animation.finished` against a short timeout because paused or infinite document animations never settle; keep geometry polling as the authoritative E2E readiness check. - Pi 0.80.10 can choose the oldest oversized user turn as `firstKeptEntryId`, leaving both summary inputs empty and producing a no-op checkpoint. When the journal has a newer turn, retry `prepareCompaction` with a minimal retained-tail budget; still refuse the checkpoint if both summary inputs remain empty. - `Session.getEntries()` includes abandoned branches. Synchronization markers must be read from `Session.getBranch()` or a rolled-back partial write can still look committed. - Child-runtime unit tests load outside Electron. Keep usage accounting behind an injected callback (with a production-only dynamic import) instead of statically importing the Electron-backed singleton into the reusable child registry. @@ -28,6 +30,7 @@ - An Electron E2E teardown deadline must exceed the app's sequential bounded shutdown phases. A 10-second fixture timeout can kill and report a healthy process while foreground, subagent, and packaged-soak drains are still inside their documented 6s + 5s + 5s ceilings. - Image generation can return a baked checkerboard or an opaque/RGB file even when asked for transparent onboarding art. Inspect the generated pixels, dimensions, and alpha channel before copying it into `renderer/assets/onboarding/`; extract the real background and resample only after visual inspection. - GitHub release create/edit requests can return HTTP 503 after committing server-side state. Publication must re-read the exact tag, target SHA, draft state, and asset set before retrying or reconciling; never treat an unavailable lookup as a missing release. +- A main-process fallback that reads `settings.lastProviderId` as "the app's last provider" is reading a dead key: the UI persists its real selection in renderer localStorage (`aiden-agent.providerId`/`aiden-agent.model`) and only the Telegram flow ever wrote the settings key. Any main-process consumer (scheduler, tools) must either receive the selection explicitly or have attended chat starts seed the settings fallback. - A physical XCTest transport spike can keep secrets out of the project and scheme: use a private temporary Derived Data directory, create an injected `.xctestrun` copy beside its `Build/Products` payload, inject an ephemeral canonical pairing-bootstrap JSON into that copy, then use `test-without-building`. Xcode still requires the physical device to remain unlocked through preflight and launch. - A copied `.xctestrun` resolves `__TESTROOT__` relative to its own location. Keep the injected copy beside `Build/Products` (or deliberately rewrite every relative product path), and derive the advertised LAN address from the default-route interface instead of assuming Wi-Fi is `en0`; otherwise Xcode reports a missing test product or the phone silently times out against a link-local adapter. - Simulator networking does not prove iOS Local Network privacy readiness. A direct physical LAN request fails as `Local network prohibited` when the host app omits `NSLocalNetworkUsageDescription`; lock both that key and the canonical `NSBonjourServices` value with an XCTest that inspects the built application bundle. @@ -379,6 +382,8 @@ owns; reopen the terminal before judging the final live state. - Zsh does not split scalar loop values by default; use explicit delimiters in pairwise merge probes so branch names are not accidentally concatenated. - Standalone green PRs still conflicted in shared settings, test registries, and UI fixtures. Assemble the exact combined stack and retain every feature's test registration before merging to main. +- UX review (2026-09-05): the active Xcode installation rejects tools until its license is accepted. Git and desktop C helpers can use the separately installed Command Line Tools via `DEVELOPER_DIR=/Library/Developer/CommandLineTools`; helper build scripts replace the child environment, so this run compiled their unchanged C sources with the same flags directly. iOS physical-device discovery/test remains blocked; do not claim it passed. +- Electron E2E failure diagnostics called `app.process()` outside their try/catch; a closed Electron target hid the original launch error. Keep that call within the best-effort diagnostic block. The isolated E2E profile also cannot establish native Bot Keychain authority; the editor test injects a test-owned IPC catalog and captures the submitted access, while storage/authority tests run separately. ## 2026-09-10 — Google catalog PR validation @@ -386,3 +391,41 @@ The main checkout's shared node_modules matched Pi's pinned version but lacked postcss-value-parser and @xterm/addon-web-links required by this worktree. The resulting type errors disappeared after replacing the temporary dependency symlink with this checkout's own npm ci. Full type-check and lint then passed. + +## 2026-09-09 — Draft chat planning + +- The checkout has no `.memory/` directory despite AGENTS.md referencing it; used current source and the plan index for project context. +- Native verification: no physical iOS device is online and local Java/Android SDK tools are unavailable. Run generic iOS build-for-testing and shared Remote contract suites; device XCTest and Android runtime acceptance remain unavailable locally. +- Draft lifecycle regression tests intercepted `chats:appendMessage` for first-send failures; updated that fault injection to the new atomic `chats:createWithFirstMessage` boundary. +- Empty-chat migration must distinguish header-only Pi journals (created by the old Todo snapshot read even before Send) from real private records; preserving every journal would leave ordinary abandoned chats behind. +- Completed Pi v3-to-v4 promotion adds lane/navigation records even for a header-only source. Empty cleanup must validate the real receipt, backup digest, and exact migration scaffolding rather than treating all promoted records as user history. + +## 2026-09-10 — PR #102 readiness + +- The initial source-scanning theory incorrectly credited explicit 1x encode arguments that are already Electron's defaults. Exercise the actual fix with a valid oversized PNG through `providers:save`, relaunch, and verify the recovered, decodable 64px-or-smaller result. +- Treat user-supplied provider PNGs as original-color artwork; an alpha mask turns fully opaque icons into solid squares and disagrees with native clients. +- Model Pad animation settling must ignore infinite animations and retain a bounded timeout so hosted Electron runs cannot wait forever. +- The cold hosted responsive matrix can reach its last 390px case only as the shared 90-second test budget expires, while a warm retry passes in 24 seconds. Give this exhaustive case an explicit bounded 180-second budget without relaxing geometry assertions. +- On hosted Electron, Playwright `fill("")` can leave a controlled search unchanged; use the native value setter plus a bubbling input event for deterministic test cleanup. + +## 2026-09-10 — PR96 readiness rebase + +- The branch predated the unified Settings work and conflicted in headings, accessible switch names, shared test fixtures, and the tracked-but-ignored papercut log. Resolve these contracts additively and use `git add -f` for the already tracked `.papercuts/troubleshooting.md`. +- A parent save handler showed a toast but resolved its promise, making the editor's inline retry state unreachable. Propagate the rejection after the toast so the review dialog keeps the user's choices and exposes the error. +- Progressive disclosure made two inherited E2E locators inaccessible: tests must open the exact Remote or Telegram details before asserting the controls inside, rather than spending the full timeout waiting for hidden semantics. +- A single rollback `try` coupled external Tailscale route cleanup to local listener/state cleanup; keep independently knowable cleanup steps best-effort and report external versus local uncertainty separately. +- Distinct cleanup messages need branch-specific regressions: cover both newly enabled access being disabled and pre-existing access staying enabled when route removal fails. +- Hosted Electron can leave a controlled scheduled-task search unchanged after Playwright `fill("")`; use the native value setter plus a bubbling input event for deterministic cleanup. + +## 2026-09-10 — PR #81 readiness rebase + +- The stale terminal migration conflicted with newer browser-link integration and expanded package scripts; preserve current `main` scripts and link routing, then layer the Ghostty-specific test/build hooks back in before regenerating the lockfile. +- `npm ci` completed without Electron's macOS payload, and the first focused Playwright command omitted this repo's explicit config; install the payload with `node node_modules/electron/install.js` and pass `--config=playwright.config.ts`. +- Canvas terminal link detection and host navigation policy had separate truth sources, so unsupported file-like text gained a dead click affordance. Pass the host policy into the surface and filter hover and activation together. +- Ghostty correctly encodes modified keys, but Meta chords belong to the host; suppress unhandled Meta press/release pairs after terminal copy and paste handling. Do not key this off `navigator.platform`: Chromium may reduce it even in a macOS Electron renderer. +- The terminal Playwright fixture launches compiled renderer output; rebuild before interpreting a focused E2E failure after source edits, or the test exercises the previous bundle. + +## 2026-09-11 — 0.40.0 integration + +- E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions. +- `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index de65c6199..d4c358ae6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,6 +12,29 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Ghostty / libghostty-vt + +Aiden's in-app terminal uses a WebAssembly build of Ghostty's `libghostty-vt` +(`renderer/lib/ghostty-terminal/vendor/ghostty-vt.wasm`) plus a 112-byte PTY +callback trampoline. The browser host is adapted from T3 Code's MIT-licensed +`libghostty-vt` adapter. + +Ghostty: Copyright (c) 2024-2026 Mitchell Hashimoto and Ghostty contributors +T3 Code adapter: Copyright (c) 2026 T3 Tools Inc. + +MIT License. See `renderer/lib/ghostty-terminal/GHOSTTY-LICENSE` and +https://github.com/pingdotgg/t3code/blob/main/LICENSE + +## Symbols Nerd Font Mono + +Vendored as `renderer/lib/ghostty-terminal/fonts/SymbolsNerdFontMono-Regular.woff2` +for terminal prompt glyphs. + +Copyright (c) 2014 Ryan L McIntyre + +MIT License. See `renderer/lib/ghostty-terminal/fonts/LICENSE` + + ## Chart.js Chart.js is vendored into `resources/generative-ui` for sandboxed Generative UI artifacts. diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt index ab75319ab..bbf038979 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -21,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization @@ -183,21 +186,30 @@ fun AidenPairingScreen( // Pair New Mac Section Text( - text = "Pair New Mac", + text = "Connect your Mac", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = palette.secondary ) Spacer(modifier = Modifier.height(8.dp)) - // M3 Expressive 3-Tab Pill Segmented Group + Text( + text = "On your Mac, open Settings → Aiden On The Go → Connect a device. Then scan its code here.", + style = MaterialTheme.typography.bodyMedium, + color = palette.secondary + ) + Spacer(modifier = Modifier.height(12.dp)) + + // QR first, with a camera-free setup code fallback. Surface( color = palette.raised, shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth() ) { Row( - modifier = Modifier.padding(4.dp) + modifier = Modifier + .padding(4.dp) + .selectableGroup() ) { // Tab 0: Scan QR Surface( @@ -205,7 +217,11 @@ fun AidenPairingScreen( shape = RoundedCornerShape(16.dp), modifier = Modifier .weight(1f) - .tactilePress { selectedTab = 0 } + .selectable( + selected = selectedTab == 0, + role = Role.Tab, + onClick = { selectedTab = 0 } + ) ) { Box( contentAlignment = Alignment.Center, @@ -226,7 +242,11 @@ fun AidenPairingScreen( shape = RoundedCornerShape(16.dp), modifier = Modifier .weight(1f) - .tactilePress { selectedTab = 1 } + .selectable( + selected = selectedTab == 1, + role = Role.Tab, + onClick = { selectedTab = 1 } + ) ) { Box( contentAlignment = Alignment.Center, @@ -241,31 +261,16 @@ fun AidenPairingScreen( } } - // Tab 2: Paste JSON - Surface( - color = if (selectedTab == 2) palette.accent else Color.Transparent, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .weight(1f) - .tactilePress { selectedTab = 2 } - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.padding(vertical = 8.dp) - ) { - Text( - text = "Paste JSON", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = if (selectedTab == 2) Color.White else palette.secondary - ) - } - } + } } Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { selectedTab = if (selectedTab == 2) 0 else 2 }) { + Text(if (selectedTab == 2) "Back to scanning" else "Advanced: paste connection details") + } + errorMessage?.let { msg -> Surface( color = palette.danger.copy(alpha = 0.12f), @@ -325,7 +330,7 @@ fun AidenPairingScreen( colors = sbtbiswas.AidenOnTheGo.ui.theme.aidenTextFieldColors(), value = endpointUrl, onValueChange = { endpointUrl = it }, - label = { Text("Mac Address (HTTPS Endpoint)") }, + label = { Text("Mac address") }, singleLine = true, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() diff --git a/docs/aiden-on-the-go-remote-access.md b/docs/aiden-on-the-go-remote-access.md index 958dd183b..49bd89cab 100644 --- a/docs/aiden-on-the-go-remote-access.md +++ b/docs/aiden-on-the-go-remote-access.md @@ -1,14 +1,15 @@ # Aiden On The Go remote access -Aiden Agent can expose a small authenticated API to Aiden On The Go on iPhone and iPad. Remote Access is off by default. Aiden must remain running on the Mac, although its window may be closed. +Aiden Agent can expose a small authenticated API to Aiden On The Go on phones and tablets. Phone access is off by default. Aiden must remain running on the Mac, although its window may be closed. ## Local Network setup -1. Open **Settings → Remote Access** in Aiden Agent. -2. Choose **Local Network** or **Local Network + Tailscale**. -3. Turn on **Enable Remote Access**. -4. Add only the folders the phone or iPad may explore. Selecting the entire home directory requires a second confirmation on the Mac; the filesystem root is never allowed. -5. Choose **Pair over Local Network** and scan the one-time QR code in Aiden On The Go. +1. Open **Settings → Aiden On The Go** in Aiden Agent. +2. Choose **On the same Wi-Fi**, then **Connect a device**. +3. Review what Aiden will enable and choose **Enable and show code**. +4. Scan the code in Aiden On The Go. If the camera is unavailable, use the setup code instead. + +After choosing the method, setup takes two desktop actions. Scanning and any phone permissions are additional steps. Existing ready connections can add a device directly. Under **Workspace access**, approve any additional folders the phone may browse; existing workspace access is unchanged. Approving the whole home folder requires a separate confirmation. The Mac advertises `_aiden-agent._tcp` with Bonjour only while Local Network access is running. LAN traffic uses a per-install P-256 HTTPS identity. The QR contains the private CA trust anchor and the server public-key pin so the mobile client can validate the hostname, certificate chain, and pinned key. A certificate renewal keeps the server key; an identity-key change requires pairing again. @@ -16,19 +17,22 @@ The Mac advertises `_aiden-agent._tcp` with Bonjour only while Local Network acc Tailscale supplies reachability and network encryption, but Aiden still requires its own device credential on every request. -1. Install Tailscale on the Mac and sign in to the intended tailnet. -2. Ensure HTTPS certificates are available for the tailnet. Aiden reports this prerequisite rather than enabling it silently. -3. In **Settings → Remote Access**, select **Tailscale** or **Local Network + Tailscale** and enable Remote Access. -4. Review the exact command-equivalent route preview, then choose **Connect**. -5. Pair with **Pair over Tailscale** after the stable `https://…ts.net/api/aiden/v1` address appears. +1. Install Tailscale on the Mac and phone, sign in to the intended network, and make sure HTTPS is authorized for the Mac’s Tailscale name. +2. Open **Settings → Aiden On The Go** and choose **Away from home**. +3. Choose **Connect a device → Enable and show code**. Aiden turns on access, sets up its private connection, checks it, and shows the one-time code. +4. Scan the code on your phone. + +Aiden checks installation, sign-in, HTTPS availability, and route ownership before setup. Missing prerequisites remain user actions. Conflicts and uncertain changes direct you to the advanced **Connection** controls; setup never silently replaces another route. If setup fails, Aiden removes only access introduced by that attempt where the outcome is known. An uncertain external change remains available for explicit verification. + +**This Mac settings** contains the Mac name and enable switch; **Connection** contains the saved mode and technical controls. Closing the code window stops pairing; phone access remains enabled until switched off. Removing a device’s access is separate from turning off all phone access. Aiden owns only `/api/aiden/v1`, proxies it to the loopback-only HTTP listener's matching `/api/aiden/v1` base, and verifies the resulting route. The matching target base is required because Tailscale strips the public `--set-path` prefix before proxying. On macOS, Aiden invokes Tailscale's shared app executable in its documented explicit CLI mode, so Finder and Dock launches do not depend on terminal environment variables. First-time connection works from an empty Serve configuration only after the node's exact Tailscale certificate domain proves HTTPS was already authorized. Aiden never enables Tailscale Funnel, never runs `tailscale serve reset`, never completes Tailscale authorization for you, and never changes unrelated Serve handlers. **Disconnect** removes only the exact route and target recorded by Aiden. A conflict is reported instead of being overwritten. ## Devices, credentials, and revocation -Each phone or iPad receives a separate random credential. Aiden persists only a fast lookup digest, a salted scrypt digest, and redacted device metadata—not the credential or pairing secret. Pairing QR codes expire after five minutes and work once. +Each phone or tablet receives a separate random credential. Aiden persists only a fast lookup digest, a salted scrypt digest, and redacted device metadata—not the credential or pairing secret. Pairing QR codes expire after five minutes and work once. -Use **Revoke** beside a paired device to invalidate it immediately. Revocation does not rotate model-provider credentials or affect other paired devices. Pair the device again to restore access. +Use **Remove access** beside a paired device to invalidate it immediately. Revocation does not rotate model-provider credentials or affect other paired devices. Pair the device again to restore access. ## Offline behavior diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 5b43ab151..d70262074 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -148,6 +148,8 @@ The OpenAPI document owns exact request/response shapes. This section owns behav ### Bootstrap/device +Pairing accepts `iphone`, `ipad`, `mac`, and `linux` device types. This additive request vocabulary does not change existing response DTOs, grant inventories, or device-owned stream authority. A desktop receives the same legacy grants unless it separately negotiates existing Bot capabilities; being a desktop does not authorize controlling another device's streams or terminals. Older servers reject desktop types, so desktop callers must report an update requirement rather than impersonating a phone. + - The locally displayed QR encodes the OpenAPI `PairingPayload` envelope as canonical JSON. Its `PairingBootstrap` contains protocol version, instance ID, HTTPS API endpoint, P-256 SPKI SHA-256 fingerprint, high-entropy single-use secret, and expiry; its trust member selects the bundled private LAN CA or system trust for Tailscale. The phone must decode and validate the complete envelope, configure hostname plus SPKI verification, and only then exchange the secret. A fingerprint learned from `/pairing/exchange` is confirmation, never the trust bootstrap. - `POST /pairing/manual-bootstrap`: returns that exact canonical `PairingPayload` encrypted with AES-256-GCM. A uniformly random 100-bit Crockford Base32 setup code is shown only through local Electron IPC and derives the encryption key with HKDF-SHA256. The client sends `{}` to the selected exact endpoint, validates the bounded response, derives and authenticates the envelope locally, requires the decrypted endpoint and expiry to match, and then uses the normal pinned `/pairing/exchange`. The setup code never appears in a URL, request, log, persistent state, Bonjour record, or public status projection. LAN users select a discovered Mac; Tailscale users provide its canonical private endpoint. QR and manual entry share one window and one synchronously consumed exchange secret. - `GET /health`: minimal readiness and protocol version. diff --git a/docs/design/desktop-connections-proposal.html b/docs/design/desktop-connections-proposal.html new file mode 100644 index 000000000..2dd2d0fa2 --- /dev/null +++ b/docs/design/desktop-connections-proposal.html @@ -0,0 +1,37 @@ + + + + + +Aiden — Connections proposal + +
UI proposal · Sample data only. No devices are contacted.
+
+ + +

Connections

+

Devices you can control from here

Studio Mac
Connected
Linux workstation
Offline · Last connected 12 minutes ago

Chats and files stay on the device where they were created. Turning a connection off disconnects this client; it does not stop work already running there.

+
+

Review API changes

Check the API changes in this workspace.
I’m checking the routes and their tests.Running on Studio Mac · Reviewing files
Existing chats stay on their original device. Choose New chat to select another device.
+
+ + diff --git a/docs/plans/README.md b/docs/plans/README.md index 54218fa9e..d487e5a76 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -6,6 +6,10 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Current state | | -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Scheduled-Task Provider and Pi Rollout Recovery](scheduled-provider-and-pi-rollout-recovery-plan.md) | Implemented | Attended chat starts seed the scheduler's provider fallback, tasks pin providers explicitly (editor picker + prefilled drafts + honest `schedule_task` gating), Pi-rollout-ineligible chats generate journalless over in-memory sessions with the fail-closed contract preserved, and remote request journal events carry method/route evidence — green in the recovery worktree; release-owner stage advance (B1) and machine remediation remain. | +| [Desktop multi-host control](desktop-multi-host-control-plan.md) | Active | Outbound connection foundation and regression coverage implemented. Full sidebar/chat/runtime integration remains incomplete; interactive UI proposal approved on 2026-09-09. | +| [Draft Agent Chats](draft-agent-chats-plan.md) | Implemented | Transient desktop drafts, atomic first-message creation, and the one-time legacy empty-chat migration are implemented with Electron regression coverage; PR CI and merge pending. | +| [Nontechnical User Journey UX](nontechnical-user-journey-ux-plan.md) | Active | Approved ten-journey UX pass implemented for review: guided phone setup, four AI choices, two-step Create a bot, setup acknowledgements, recovery, and native pairing copy. Broader audit backlog and physical-device acceptance remain open. | | [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | | [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | | [Unified Workspace Sidebar](unified-workspace-sidebar-plan.md) | Active | Phases 1 and 2 ship the unified workspace/chat outline plus a feature-negotiated, transcript-free paginated summary read on Electron, iOS/iPadOS, and Android; physical-device performance acceptance remains open. | @@ -16,6 +20,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.84.4. | | [Generative UI Artifacts](generative-ui-artifacts-plan.md) | Active | Phases 0–6 shipped: chat-scoped `render_artifact`, strict sandboxed preview/export hosts, verified vendored Chart.js/Plotly/KaTeX, permission-aware `/visualize`, crash-recoverable authoritative storage/copies, descriptor-relative workspace reads, one-iframe handoff/expansion, visible failure states, and route-stable Responding/Visualizing activity. Three-agent PR review findings are remediated with focused regression coverage. | | [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | +| [Libghostty workspace terminal](libghostty-terminal-plan.md) | Implemented | The workspace drawer uses Ghostty's official `libghostty-vt` WASM (T3-style runtime, PTY trampoline, canvas surface); node-pty sessions are unchanged. Packaged Mac acceptance remains. | | [Logging and Diagnostics Upgrade](logging-and-diagnostics-upgrade-plan.md) | Implemented | Phases 0–7 are implemented: bounded typed desktop journals, main-owned renderer evidence, local support export/delete, native categorical parity, and CI/release gates. Signed/notarized `v0.35.0` passed packaged diagnostics acceptance; physical-device termination receipts remain. | | [Long-thread payload upgrades](long-thread-payload-upgrade-plan.md) | Partial | Investigation complete: T3’s O(N²) stdout store does not exist here. No-op `toolRunning` timeline republish is skipped; Remote gzip, stream-journal debounce, chat JSON/attachments, and transcript windowing remain planned. | | [Model Insights](model-insights-plan.md) | Partial | A dedicated benchmark-only OpenRouter key, manual fetch, exact source-aware offline cache, metric-selectable collision-free capability suggestions, progressive canvas-first Pad UX, axis provenance, attribution, and direct-AA retirement ship; device-local pace signals remain. | diff --git a/docs/plans/desktop-multi-host-control-plan.md b/docs/plans/desktop-multi-host-control-plan.md new file mode 100644 index 000000000..9137026f0 --- /dev/null +++ b/docs/plans/desktop-multi-host-control-plan.md @@ -0,0 +1,174 @@ +# Desktop multi-host control + +Status: Active — outbound connection foundation implemented; full multi-host experience incomplete; UI proposal approved on 2026-09-09. +Date: 2026-09-09 +Source baseline: `e42b147925e0d6ecbe050687eeb272e2e841233e`. + +## Intended outcome + +Any Aiden desktop can act as the user's control surface for its own work and multiple paired Aiden installations. Chats, providers, tools, files, and execution remain authoritative on their originating host. The client aggregates authorized views and sends actions to that host. A machine can be both client and server; no permanent primary machine or central account is required. + +The requested experience includes remote chat discovery and filtering in the existing sidebar, a subtle globe on remote chats, live Aiden activity and control, new chats on another host, and a composer host selector followed by that host's workspace/folder selection. macOS and Linux share the protocol; supported actions depend on host capabilities. + +The user authorized implementation and a PR on 2026-09-09 after three GPT-5.6 Sol Medium exploration lanes and two GPT-6 Astra Medium planning lanes. The user approved the Connections/sidebar/composer proposal on 2026-09-09; material departures still require signoff. The first implementation slice is documented below. + + +## Implementation checkpoint — 2026-09-09 + +Implemented in this branch: + +- Main-process outbound registry with encrypted atomic storage, pinned HTTPS, authenticated installation identity, independent host dispatch and bounded request/queue limits. +- Desktop pairing from the canonical QR payload and manual-payload cryptographic decoder. Mac/Linux client classifications preserve existing grants. Manual bootstrap network acquisition is not yet wired. +- Typed IPC for paired-host management and a closed set of existing remote API operations, with document cancellation and credential-free renderer views. Every response is checked against the bundled API schema with structural/byte limits; validators are compiled lazily and cached. +- Bounded HTTPS JSON and linear SSE byte framing (1 MiB per frame, 16 MiB/16,384 frames per session, 30-second frame deadline and five-minute absolute session cap). Stream subscription IPC, replay/reconnect reconciliation and renderer consumption remain outstanding. +- Focused transport/registry tests, including real Remote API pairing over HTTPS, native manual crypto fixture, persistence failure, shutdown, cancellation and host isolation. +- An interactive [Connections and sidebar proposal](../design/desktop-connections-proposal.html), using sample data only, approved by the user on 2026-09-09. It is not production UI. + +Not implemented: multi-host sidebar aggregation/filtering, ChatPane adapter and host-scoped cache migration, production host/folder selector, cross-origin live run observation/control, terminal leases, remote Environment surfaces, onboarding, and Linux combined-branch validation. Existing remote stream ownership checks and grants remain unchanged. This foundation alone does not deliver desktop-to-desktop chat control in the app. + +Validation: TypeScript, lint, production build, remote contract/service suites and focused Android client tests pass (one legacy-port test skipped because port 65535 was occupied). The physical-iPad XCTest attempt failed before execution because Xcode could not mount the developer disk image; simulator use is prohibited by ios/AGENTS.md. Two fresh-context adversarial/security reviewers examined the foundation; their protocol, idempotency, document-lifetime, pairing-lock and shutdown/cancellation findings received fixes and regression coverage. + +## Existing foundations and gaps + +| Area | Existing source and behavior | Required change | +| --- | --- | --- | +| Reachability and trust | `main/services/aiden-remote-service.ts`, `aiden-remote-pairing.ts`, `aiden-remote-tls-identity.ts`, `aiden-remote-tailscale.ts`; per-device credentials, pinned LAN HTTPS, Tailscale route, one-use QR/manual pairing | Desktop outbound client and encrypted paired-host registry; broaden mobile-only client classifications compatibly | +| Mobile client precedent | `ios/AidenOnTheGo/Models/AidenInstallation.swift`; Android installation store and remote client | Reuse installation identity, activation leases, rollback, pinning, bounded decoders, and reconnect semantics | +| Chat discovery | `main/services/aiden-remote-chats.ts`; feature-negotiated, transcript-free summaries, 100 default/200 maximum per page | Multi-host aggregation and event-driven invalidation; no full-transcript list polling | +| Chat execution | Existing atomic idempotent remote turn admission, SSE, attachments, cancellation and approvals | Normalize local and remote operations behind one desktop session interface | +| Observe another origin | `aiden-remote-streams.ts:592` restricts streams to their creating device; `chat-generation-owner.ts` distinguishes renderer/device ownership | Separate host-run observer and controller authority, including locally initiated work | +| Desktop state | `renderer/lib/ipc.ts`, `queries.ts`, `workspace-context.tsx`, `renderer/main/chat-pane.tsx` use local APIs and bare IDs | Host-scoped references, routes, caches, drafts, async leases and action dispatch | +| Composer/sidebar | `renderer/components/composer.tsx` has static Local text; `chat-sidebar.tsx` already has workspace/recent projections and search | Approved host selector, host filter, remote indicator and source-aware actions | +| Files and Git | Existing authenticated workspace/browser/file/Git endpoints | Adapt existing surfaces; never pass remote paths to local APIs | +| Terminals | `main/services/terminal.ts`, `main/handlers/terminal.ts` bind PTYs to renderer documents | Host session ownership, attach/control leases, resumable output and dedicated grants | +| Browser/subagents | Existing desktop Environment surfaces; remote API does not provide full parity | Explicit per-surface remote contracts or visible capability limitations; no implicit local execution | + +Ordinary chat summaries exclude reserved Assistant records and Bot homes. “All chats” must account for existing conversation areas and their grants; it must not silently mean only regular chats or expose internal child transcripts. + +## Linux evidence + +Live branch investigation on 2026-09-09 found: + +- [PR #71](https://github.com/sambitcreate/aiden-agent/pull/71), `6a397578cb2f127284ef17d1b12359ef47d77022`, open and conflicting with current main. The branches have 178 main-only and 222 Linux-only commits. Its earlier successful checks do not validate today's combined state. +- [PR #89](https://github.com/sambitcreate/aiden-agent/pull/89), `e0f4836667316d9921ab91d02985ac30f0b6a102`, draft follow-up with failing Linux/verification checks at inspection. It includes Wayland Vulkan and Tailscale operator-error handling. +- The branch contains AppImage/deb/rpm support for x64/arm64, X11/Wayland handling, Linux credential-store safeguards and Tailscale executable discovery. Inspect its `docs/linux.md` and `main/services/host-platform-capabilities.ts` during integration. +- Its capability gates disable Bots, computer use, Apple Foundation Models, accessibility paste, hold-to-talk dictation and some native integrations. Do not advertise them merely because a Mac controller supports them. + +Build the protocol/platform seams against current main. Validate against an isolated combined Linux checkout before declaring Linux support. Branch reconciliation is a dependency, not an already completed deliverable. + +## Backend architecture decisions + +### Host identity and dispatch + +- Use authenticated server `instanceId` as remote identity. Display name and endpoint are mutable metadata, not identity. Local execution has a stable adapter identity; reject accidental pairing back to the same installation. +- Every chat, workspace, run, terminal, attachment, browser/artifact reference, query, draft and navigation selection carries `{hostId, resourceId}`. Include host identity in persisted UI state and event envelopes. +- Place a typed host router and remote connection manager in Electron main. The renderer gets safe metadata and typed operations; it never receives bearer credentials or implements trust exceptions. +- Retain the local IPC fast path through the same interface. Do not send local actions through HTTP. Adapt one ChatPane rather than creating separate local and remote chat products. +- Capture host identity when an operation starts. Switching selection cannot retarget it. Fence late responses with connection/activation generations and cancel obsolete reads. +- Sidebar filter, selected conversation host and new-chat composer host are separate state. Never implement a global IPC destination toggle: it would also retarget unrelated windows, delayed mutations and local Settings. +- Existing chats remain host-bound in the recommended baseline. Selecting another host affects a new chat context; migration, replication and automatic failover require separate requirements. +- Model selection, skills, tools and workspace permissions come from the execution host. Never substitute a similarly named local provider or copy inference credentials. + +### Connections and security + +- Reuse LAN/Tailscale and current manual pairing first. Tailscale supplies reachability; Aiden's revocable credential still authorizes each request. +- Persist credentials through the platform credential abstraction; Linux must reject insecure plaintext fallback. Publish a paired registry entry only after credential persistence succeeds, with rollback on failure. +- Validate canonical endpoints, TLS identity, response bounds and negotiated features. Do not forward authorization across redirects or automatically replace a changed server identity. +- Pairing direction is explicit: A controlling B does not authorize B to control A or C. Outbound connection enablement is separate from inbound Remote Access enablement. +- Keep protocol v1 endpoints and existing mobile grants intact. Add negotiated host observation/control/terminal capabilities; neither client type nor an old broad chat grant silently implies new authority. +- On old hosts, use existing supported reads/actions and explicitly identify unavailable capabilities. Never simulate host-wide control by weakening device-owned stream checks. + +### Live work and control + +- Add a host-run registry fed by the main-owned runtime lifecycle. Include local desktop and paired-device origins; inventory Bot, schedule, Telegram and child-run paths so visible activity is complete within granted conversation scope. +- Keep generation ownership separate from observation and control. New host-level observers receive safe projections; controllers send commands validated against current grants, run identity, revision and original owner. +- Preserve current stream device checks. Do not forge a renderer owner or convert another device's credential into the controller's authority. +- Stop and approval decisions require atomic race handling. Repeated requests use the same idempotency identity; conflicting approval decisions resolve once. Host-only approvals remain host-only unless their exact policy is deliberately extended. +- Disconnection detaches the view and does not cancel a running turn. Revoking an observer removes its authority without cancelling unrelated local work. Restart reports actual interrupted/recoverable state and never retries inference automatically. +- Add resumable host metadata events with epoch, sequence and a snapshot watermark. A journal gap triggers an explicit bounded resync; opening the snapshot and subscription cannot lose intervening changes. + +### Terminals and process meaning + +The proposed baseline covers Aiden-managed work: chat runs, parent/subagent activity and interactive terminals. Arbitrary OS process management is a separate scope decision. + +Terminal scope remains pending the user's answer. The existing remote security contract explicitly excludes generic remote terminals/client-authored shell execution. Including interactive terminals therefore requires a deliberate new capability, updated threat model and version-negotiated contract; existing chat grants cannot acquire shell authority implicitly. + +- Introduce host-owned terminal sessions with local and remote attachment adapters. Maintain workspace and grant validation before input/resize/close operations. +- Define a single input/resize controller lease; additional authorized clients can observe. Lease loss, transfer and competing input need deterministic behavior. +- Keep sequence-numbered bounded output, replay-gap signals, input/output byte limits, per-host/per-device caps and slow-consumer backpressure. +- Disconnecting a client does not kill its remote PTY. Session close, explicit stop, expiry policy, revocation and host shutdown have separate tested semantics. Output history is not a surviving process after reboot. +- Add safe subagent summary/control operations through the existing coordinator if required by the signed-off scope; do not expose private child prompts, credentials or journals. + +### Full surface audit + +| Surface | Routing and scope requirement | +| --- | --- | +| Chat history, search, rename, delete, archive, retry/edit | Host-qualified reads/actions with complete pagination; enumerate actual supported remote operations before enabling controls | +| Composer, models, skills and tools | Execution-host inventory and authority; atomic remote start; host-qualified drafts and attachment staging | +| Runs, approvals and structured questions | Host-run discovery, audience-safe projection and exactly-once decisions; some sensitive questions/approvals may remain host-only | +| Subagents | New scoped coordinator adapter for agreed status/stop/retry/steer behavior, preserving mobile's exclusion of child internals | +| Files, Git and artifacts | Existing file/Git contracts plus authenticated bounded artifact retrieval; local download/preview is an explicit client action, never remote-path interpretation | +| Terminal | New session ownership, observation, writer arbitration and security contract as described above | +| Browser tabs | Existing `WebContentsView` is local native UI. Remote folder browsing is unrelated. Viewing/controlling a remote browser requires a separate capture/input design; do not promise parity or quietly open a local tab as if remote | +| Bots and schedules | Reuse existing APIs where advertised, preserve separate product areas and host-specific availability; include their runs in authorized observation | +| Settings | Appearance/shortcuts/connections remain client-local. Execution settings belong to the selected host; full remote Settings administration needs an explicit scope decision | +| Voice, computer use and native integrations | Capability-gate by actual execution host. Remote screen control and host microphone access are not implied by chat control | + +The complete first-release action matrix must be reviewed before UI implementation. An unsupported action needs approved treatment rather than a silent local fallback. Remote browser viewing and full remote Settings administration are unresolved scope, not hidden implementation promises. + +## UI signoff inventory + +The user supplied three Connections Settings screenshots as visual direction. Reuse their separation between inbound and outbound control, compact device rows, status, Add, toggles and revoke/reconnect actions, adapted to Aiden's settings system. Use platform-neutral wording where appropriate. SSH and keep-awake are pictured but are not automatically included features. + +| Decision | Approved proposal direction | +| --- | --- | +| Settings organization | Connections with Control this device and Control other devices; decide whether to rename/relocate Remote Access | +| Sidebar | All authorized hosts in the existing workspace/recent projection with a host filter; exact default/filter placement pending | +| Remote marker | Small soft-colored globe on remote chat rows; placement, tooltip/accessibility name and duplicate host names need approval | +| Composer | Turn existing Local label into host selector; show the selected host's workspaces and folder browser | +| Host changes | Existing chat stays host-bound; decide whether unsent text follows the user or each host restores its own draft | +| Offline/reconnect | Cached content remains identifiable as stale; mutations unavailable; approve presentation and retry/re-pair states | +| Process/control scope | Confirm Aiden-managed terminals/subagents versus arbitrary OS processes, and authority over work started elsewhere | +| Conversation areas | Confirm whether “all chats” includes Bots and Assistant in their existing surfaces; maintain conversation access boundaries | +| Optional features | SSH, keep-awake, chat migration, remote screen control and headless daemon are separate decisions | +| Environment scope | Confirm remote browser viewing and full remote Settings administration separately from Aiden run/file/Git control | + +Before implementation, provide concrete mockups of Settings, sidebar/filter, composer/folder selection, and offline/capability states for review of any material departures. Follow `docs/design-guide.md`, `docs/settings-design-system.md`, `docs/chatgpt-desktop-ui-inspiration.md` and `docs/chatgpt-ui-element-specimen.html`. Preserve semantic tokens, shared action shapes and focus behavior. The Connections/sidebar/composer proposal was approved on 2026-09-09; material departures require fresh approval. + +## Delivery sequence and gates + +1. **Contract and surface inventory.** Freeze host/resource identity, operation coverage, ownership, capabilities, recovery and numeric resource limits. Map every action reachable from a remote chat. Record approved UI scope separately. Exit: explicit supported/unsupported surface matrix and old-client compatibility fixtures. +2. **Desktop connection core.** Implement encrypted registry, pairing, bounded HTTP/SSE client, main-process routing and activation leases. Exit: two test servers with colliding IDs, pin/revocation failures, connection switching and interrupted pairing pass without renderer secrets. +3. **Local adapter and remote chat vertical slice.** Refactor existing local behavior behind host interfaces, then connect summary/detail/model/workspace reads and atomic create/send/cancel/approval flows. Exit: same ChatPane handles both adapters; no reachable action silently runs on the wrong host. +4. **Host-wide live work.** Add runtime observation, metadata feed, explicit cross-origin control and authority races. Address stream persistence amplification in this slice. Exit: a locally started run on B is observed and controlled from A with correct grants; legacy clients retain their isolation. +5. **Environment parity.** Integrate remote files/Git/attachments/artifacts and agreed subagent/terminal contracts. Exit: signed-off process scope works, remote resources never open through unintended local handlers, all unsupported surfaces have approved capability treatment. +6. **Approved UI and onboarding.** Implement the approved Connections, selector, sidebar and state designs over completed backend contracts. Update onboarding and the data-driven tour with a dedicated optimized transparent 1024×1024 illustration only when the feature ships; approve illustration/UI beforehand. +7. **Combined platform acceptance.** Integrate with the reconciled Linux branch and test Mac↔Mac, Mac→Linux, Linux→Mac and Linux→Linux, including a client that also serves another peer. Exit: functional, security, recovery, performance and packaged operator evidence pass. + +These are implementation milestones, not permission to call the first remote chat demo complete. Host-wide observation/control and agreed Environment support are part of the requested outcome. + +## Efficiency requirements + +- One shared lightweight host feed per enabled connected host, not one stream per sidebar row. Share main-process connections between windows/surfaces. Fetch detailed transcript/run/terminal content only while needed. +- Use paginated summary indexes, bounded caches and bounded concurrent host refresh. Never scan transcript files to fill sidebar metadata or recursively crawl folders on host selection. +- Disabled connections generate zero traffic. Suspend detailed hidden-view subscriptions; foreground reconnection is coalesced with exponential backoff and jitter. Retain only lightweight background notifications required by the approved behavior. +- Bound cache bytes, detailed subscriptions, queues, active hosts, requests and replay retention explicitly before implementation. Slow consumers cannot grow host memory without limit. +- Current remote stream storage clones/persists growing snapshots during event append. Fix or amortize this before multi-host load; flush durable control/terminal boundaries and preserve idempotency/recovery. Document bounded RAM-only text replay loss if batching is chosen. +- Current full chat response has a 1 MiB cap. Add feature-negotiated bounded transcript paging/recent-window reads for large desktop histories; do not simply lift the global response limit. Preserve legacy endpoints. +- Batch token rendering and metadata invalidations. Do not poll full chat bodies, launch per-chat Git watchers or repeatedly enumerate providers while merely showing remote status. +- Measure production-equivalent local-only and 1/5/10-host cases: startup, idle CPU/wakeups, memory after eviction, wire bytes, disk bytes during streaming, metadata freshness and slow-client recovery. Set numerical baseline-relative release budgets from those measurements; do not invent hardware performance claims. + +## Verification matrix + +- Register new tests in `package.json`; run focused remote/protocol/router/stream/pairing/workspace/terminal/sidebar/composer suites and normal required checks during implementation. +- Update normative API docs/schema/shared fixtures and inspect/test both Swift and Kotlin consumers for shared contract or transcript/activity changes. Old grants and strict decoder behavior must remain valid. +- Two servers with identical chat/workspace/provider names and IDs; rename host; same host at a verified alternate address; self-pair; A→B→A late responses; forget/revoke then re-pair. +- Local-owner, remote-owner, Bot/schedule/Telegram origin coverage as authorized; observer-only denial; cross-owner stop; two-controller approval races; host-only approval restrictions; revoked subscriptions and attachment handles. +- Lost acknowledgement and same-key retry without duplicate turn; no offline mutation queue that silently runs later; host crash; sleep/resume; journal gap; terminal detach, replay/backpressure, lease conflicts and restart truthfulness. +- Remote root policy changes, symlink replacement, expired handles and revision conflicts; attachments upload to the chosen host; paths/artifacts/browser actions cannot cross host boundaries. +- Existing local-only behavior remains unchanged when no peers are configured. Confirm Quick View/Environment state stays separate and source-scoped. +- Packaged real two-machine LAN and Tailscale checks, then combined Linux X11/Wayland and secure credential-store checks. No claim of Linux completion from separate historical CI. + +## Work performed for this plan + +Source and live branch investigation only. No implementation, deployment, branch checkout, or functional test run. Updated this plan and its index, and logged the missing project-memory directory and agent handoff friction in `.papercuts/troubleshooting.md`. diff --git a/docs/plans/draft-agent-chats-plan.md b/docs/plans/draft-agent-chats-plan.md new file mode 100644 index 000000000..f854570b0 --- /dev/null +++ b/docs/plans/draft-agent-chats-plan.md @@ -0,0 +1,33 @@ +# Draft agent chats + +Status: Implemented; pull-request CI and merge pending. + +Ordinary desktop workspace chats stay transient until the first user message is durably saved. Opening New Agent, entering an empty workspace, or opening a fresh worktree must not install an empty chat or sidebar history entry. Leaving an unsent draft discards it. A committed message remains saved even if generation fails. + +## Implementation + +- Use an explicit renderer draft record with a stable future chat ID. Keep it outside persisted chat lists and query caches. Model, workspace, title, Computer Use, attachments and composer options remain local until Send. +- Freeze first-send input and settings while saving. Commit a complete nonempty chat through an additive desktop IPC, preserving existing workspace authority, attachment quotas, skill leases, turn admission and durability recovery. +- Store a private first-message receipt. Matching retries confirm the same message; mismatched identifier reuse fails. Reconciliation prevents a lost or uncertain receipt from duplicating a chat. +- Promote the draft in place using the same chat/component identity. Publish one sidebar entry and start generation once. Navigation wins over late completion; release a pending turn if its conversation is no longer open. +- Preserve existing Bot, Assistant, scheduled, Telegram and runtime child-agent lifecycles. Remote HTTP contracts remain unchanged; inspect native consumers and validate applicable shared contracts. + +## Authorized legacy migration + +The owner authorized deleting existing empty chats in this migration. At startup, after recovery and before clients can write, snapshot readable zero-message chat identities and fingerprints before consulting workspace, schedule, artifact, or private-history eligibility stores. Only eligible ordinary chats in registered workspaces are deleted. Exclude Bot/Assistant/Telegram conversations, scheduled task and run references, unreadable records, and chats with staged artifacts or private execution history. A message object counts as history even if its text is empty. Validated header-only Pi journals created by the old Todo snapshot read, including completed empty v3-to-v4 promotions with matching backup/receipt and migration-only scaffolding, do not count as history; body records and uncertain journal state remain protected. + +Persist the exact candidate set and per-chat fingerprints before deletion, recheck each candidate and fingerprint, and remove via the existing cross-store deletion service. Checkpoint progress and mark completion once. Interrupted migration resumes only original candidates; subsequent launches never sweep newer empty chats created through unchanged remote APIs. Unknown/corrupt migration state fails closed. If the initial index enumeration or snapshot save fails, startup stops before admitting writers. Unreadable payloads and uncertain eligibility preserve the affected candidates and allow checkpointed completion; they never cause a later resweep. The final cross-store deletion assertion checks only the frozen chat fingerprint and zero-message state, without reopening already-deleted private stores. + +## Verification gates + +- Draft abandonment through all creation paths creates no chat payload or sidebar row. +- First send failure preserves composer payload; successful promotion persists one message and runs once. +- Duplicate submission, changed-payload retry, navigation during saving, workspace removal, document invalidation and post-install storage failure are covered. +- Migration deletes empty payload/index entries, preserves real history and special conversations, resumes safely, and runs once across restarts. +- Focused unit/contract tests, Electron draft/migration acceptance, TypeScript, lint and PR exact-head CI must pass before delivery. + +No new setup capability or onboarding asset is introduced; this corrects the existing new-chat lifecycle. + +## Local verification + +The Electron acceptance tests cover: draft abandonment and in-place promotion, one-time migration across restart, definite first-save failure and retry, a delayed receipt after navigation, and corruption-safe migration completion. Shared Remote tests and a generic iOS build-for-testing also pass. Native iOS devices were offline and the local Android toolchain was unavailable; no physical-device acceptance is claimed. The PR checks are the source of truth for final exact-head CI. diff --git a/docs/plans/libghostty-terminal-plan.md b/docs/plans/libghostty-terminal-plan.md new file mode 100644 index 000000000..6df10ab8d --- /dev/null +++ b/docs/plans/libghostty-terminal-plan.md @@ -0,0 +1,17 @@ +# Libghostty workspace terminal + +Status: Implemented + +Replace the workspace drawer's xterm.js emulator with Ghostty's official +`libghostty-vt` WebAssembly C ABI, following T3 Code's browser adapter: +runtime + write-pty trampoline, core snapshots, canvas renderer, and surface +input (IME, selection, scrollback, mouse reporting). + +PTY spawn, `TERM=xterm-256color`, snapshot hydrate, and session limits stay in +`main/services/terminal.ts`. + +## Remaining + +- Packaged/physical drawer acceptance on a signed Mac build. +- Optional later native Metal embed if Ghostty publishes a stable headless + surface API. diff --git a/docs/plans/nontechnical-user-journey-ux-plan.md b/docs/plans/nontechnical-user-journey-ux-plan.md new file mode 100644 index 000000000..fa123a0e1 --- /dev/null +++ b/docs/plans/nontechnical-user-journey-ux-plan.md @@ -0,0 +1,372 @@ +# Make Aiden easier to start, understand, and recover + +Date: 2026-09-04 +Status: **Active — approved UX implementation in review; broader journey backlog and physical-device usability validation remain open.** +Baseline: `d40d00f1d` +Deliverable: UX audit, journey chart, remote-setup proposal, and implementation handoff. + +Visual review: [Now vs proposed — interactive HTML](../ux/now-vs-proposed.html). Ten key journeys, with simulated setup/recovery actions. Open the HTML in a browser; it runs locally without dependencies or external requests. Current screens are simplified source-based reconstructions, not screenshots. + + +## Approved implementation in this PR + +The user approved the HTML with two copy requirements: show **ChatGPT, LM Studio, Ollama, Other Custom Provider**, followed by **Other ways**, and keep **Create a bot** throughout. + +| Journey | Implemented change | Practical boundary | +| --- | --- | --- | +| Connect a phone | Two setup cards; one acknowledgement; main-owned setup enables access, prepares the owned route, verifies pairing prerequisites, and issues a code. Rollback, stale review, owner cancellation, and concurrent setup are guarded. | Two desktop actions after choosing the connection method. Changing the selected method adds one choice. Installation, sign-in, HTTPS authorization, scanning, and OS permissions remain external steps. | +| Connect AI / first chat | Four primary provider choices, additional services under Other ways, custom provider model validation, actionable composer readiness link. | Existing profile/tour and first-chat surfaces remain; no new automatic account sign-in or benchmark fetch. | +| Create a bot | Two pages; optional appearance and detailed capability controls; explicit model/access review; fresh desktop drafts start Custom with no file, shell, connection, skill, or extra capability grants. Failed saves retain the draft. | Existing bots preserve their saved access. Native bot editors retain their existing defaults in this desktop editor change; no bot-first phase is advanced. | +| Telegram | Three groups, acknowledgement before enabling, a single enable/connect action, owner pairing status, persistent connect errors; disconnect turns the service off. | BotFather token creation and Telegram owner pairing are still required. Full unattended authority is disclosed before connecting. | +| Voice | Audio destination labels and persistent recorder/transcription recovery beside the draft, with a direct Voice settings action. | Local model download and cloud credentials remain explicit; recording never sends the draft as a chat. | +| Computer Use | Plain explanation and acknowledgement before enable; Mac permission action; provider screenshot/text disclosure up front. | Per-chat opt-in, macOS permission gates, and per-control approval remain. | +| Scheduled work | What / when / access groups followed by a final review; failed saves retain choices. | Existing Create with Aiden natural-language entry remains primary. Script, Full, and MCP authority restrictions remain. | +| Plugins | Connection details disclosed progressively; Connect verifies tool availability; saved credentials are not labelled as a verified connection. | External authorization remains explicit. An unavailable endpoint leaves a persistent error. | +| Recovery | Phone setup rollback, preserved pairing lifecycle, composer voice recovery, and bot/schedule draft retention. | Existing native cache/reconnection contracts remain; no automatic mutation replay or new offline-writing contract. | +| Find settings / mobile pairing | Aiden On The Go destination, natural-language search aliases, updated Mac instructions, QR-first mobile navigation with manual/advanced fallbacks. | Stable settings route IDs and mobile wire protocol are unchanged. | + +The 39-row audit below remains the backlog rather than a claim that every possible branch has been redesigned. Release gates and evidence are tracked in [the implementation review](../ux/implementation-review.md). + +## Design direction + +Make the user's intended outcome the entry point. Aiden should assemble the required settings, explain the consequences once, and carry the user through to a verified result. + +Start with **Aiden On The Go**: two setup cards, **Connect your phone** and **Scan to finish**, followed by a connected-device summary. When Tailscale is already ready, the target is **two desktop clicks from the setup page to a usable QR code**. Scanning and any phone/OS permissions are additional actions. A fresh Tailscale installation cannot honestly be a two-click end-to-end experience; it needs guided installation, sign-in, and possibly administrator authorization. + +Then apply the same pattern to first chat, provider connection, permissions, Bots, Telegram, voice, plugins, and scheduled work. Reduce technical decisions and context switches; do not remove meaningful control over data, access, spending, or destructive actions. + +## What this audit establishes + +This is a source-based review of desktop entry points, all 15 Settings destinations, onboarding, chat/workspace controls, and representative iOS and Android pairing, connection, and task surfaces. It includes first use, repeat use, failure, recovery, and removal. The journey inventory below covers the shipped capability families visible in this checkout; it is not an exhaustive traversal of every conditional screen or every OS/account configuration. + +Current labels, control dependencies, and state branches are code observations. Assessments of confusion and proposed improvements are UX hypotheses, not measured user behavior. At audit time, no live application walkthrough, external account connection, physical-device test, or user study was performed. The implementation review now records automated Electron walkthroughs; external-account, physical-device, and user-study gates remain open. Release availability, actual timings, and platform-specific system dialogs still need verification before publishing setup instructions. + +Existing strengths to preserve: + +- Desktop onboarding already has three stages and explicit provider deferral. +- Settings already has search; many technical remote controls are already in disclosures. +- Remote pairing already has expiry, one-use codes, authenticated completion, per-device removal, and safe route ownership checks. +- Bots already have a guided editor and a review step. +- Schedules already support ordinary repeat/time controls and natural-language creation through the Assistant. +- Chat drafts, mobile caches, retry states, accessibility options, and local diagnostics have substantial existing support. + +The main shortcoming is how these pieces join together. A disclosure can hide a prerequisite without helping the user complete it. A wizard can still demand five difficult decisions. A successful connection does not necessarily mean the user knows what to do next. + +## Journey chart + +Priority: **P0** = first useful outcome or accurate understanding of access/data; **P1** = common repeat work or recovery; **P2** = specialist convenience. These are UX priorities, not vulnerability ratings. “Gap” is the source-informed hypothesis to validate. Evidence IDs link to the source register below. + +### Start and find your way + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J01 | Launch → profile → provider → feature tour → app | A name, detailed search disclosure, provider choice, and large feature inventory precede first value. | Keep three stages; make optional profile detail deferrable, explain the AI connection, then offer a first task. Retain the full tour as optional exploration. Success: a first useful reply. | P0 · [S1](#s1) | +| J02 | Connect AI during onboarding or Providers | API keys, browser sign-in, local servers, and custom Tailscale models require different expertise. | Show “Sign in,” “Use an API key,” and “Use a local or custom model”; progressively reveal relevant fields. Label the actual account/service and costs where known. Success: connection validated and one visible, usable model selected. | P0 · [S1](#s1), [S3](#s3) | +| J03 | Skip provider → finish setup → try to chat | Deferral is explicit, but reaching the app can be mistaken for chat readiness. | Preserve browsing; place “Connect AI to send your first message” at the composer with a return-to-draft setup action. No automatic paid test prompt. | P0 · [S1](#s1), [S4](#s4) | +| J04 | New Agent → workspace/context controls → message | “Agent,” “chat,” “workspace,” and “scratch folder” require a mental model too early. | Start with “New chat”; offer “Just chat” and “Work with a folder.” Explain where generated files are saved, including the existing scratch folder behavior. | P0 · [S4](#s4), [S5](#s5) | +| J05 | Sidebar → workspaces/chats, Bots, Scheduled, Assistant dock | Multiple conversation entry points can look interchangeable. | Explain in empty states: Chat = a task; Bot = a reusable helper with its own ongoing conversation; Scheduled = repeated work; Assistant = help with Aiden. Keep recent work easy to resume. | P1 · [S5](#s5), [S6](#s6), [S8](#s8) | +| J06 | Settings → search section titles/keywords → section | Search currently filters destinations, not individual fixes; “Android” and ordinary “connect my phone” wording are not explicit Remote Access keywords. | Add intent aliases and result links to exact actions, including phone, sign-in, microphone, update, and missing folder. Preserve existing routes. | P1 · [S2](#s2) | + +### Chat and local work + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J07 | Choose provider/model; optional Pad and reasoning controls | A large technical inventory makes the first choice hard. | First show current and pinned models with supported capability labels; offer a clearly identified default from the connected inventory. Keep full search/Pad available. Success: user can explain which service receives the message. | P0 · [S3](#s3), [S4](#s4) | +| J08 | Pick No access / Ask first / Full access; handle approvals | Users must understand scope, and “Full” can sound like a quality setting. | Retain enforced scopes; describe concrete file/command consequences and name the folder. Explain each approval with action, affected resource, and allow-once/deny choices. Full access remains an explicit consequential choice. | P0 · [S4](#s4), [S10](#s10) | +| J09 | Attach photo/file → model compatibility → send | Ordinary composer can skip images for unsupported models with a toast. User may think the photo was included. | Keep a persistent attachment-level explanation; offer an explicit compatible-model choice without changing recipients silently. Preserve supported attachments and text. Bots keep their separate companion-vision contract. | P0 · [S4](#s4), [S6](#s6) | +| J10 | Send → streaming answer, tools, reasoning, subagents/todos | Several kinds of activity compete with the actual outcome. | One plain-language current status; expand details when needed. Approval waiting, stopped, failed, and completed must remain distinct. Preserve current cancellation and durable activity semantics. | P1 · [S10](#s10) | +| J11 | Provider error, interrupted generation, retry | A generic retry can conceal sign-in, quota, network, or uncertain side effects. | Map known failures to “Sign in again,” “Try again,” or an explicit model change. Preserve draft and originating context; do not automatically resend an action with an unknown result. | P0 · [S4](#s4), [S10](#s10) | +| J12 | Open Files / Review / Quick View / Environment | Container names and Git-only states can obscure the simple goal of finding a result. | Lead with “Files” and “Changes” actions beside relevant output. A non-Git folder should lead to Files with a useful explanation, not an apparent dead end. | P1 · [S11](#s11) | +| J13 | Open generated artifact → expand/export | The interactive result and the saved deliverable are different objects. | Make preview, export, destination, and export failure clear. Success means a verified usable file, not merely an open preview. | P1 · [S11](#s11) | +| J14 | Branch/worktree → review → commit → push | Specialist Git vocabulary; save and publish can be confused. | Keep optional developer tools. Add short explanations: commit saves a version locally; push sends commits to the named remote. Preserve separate confirmations, stale-state checks, and conflict handling. | P2 · [S11](#s11) | +| J15 | Find/rename/delete chats or remove a worktree | Removing a conversation, a saved location, and actual files have different consequences. | Use object-specific removal copy and show exactly what survives. Offer undo only where backend recovery is real; never imply deleted files can be restored without evidence. | P1 · [S5](#s5) | + +### Reuse, connect, and automate + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J16 | Create Bot → Identity → Access → Model → Capabilities → Review | Five stages and independent model/capability choices before a conversation. | Two core cards: “What should your bot do?” and “Review model and access.” Start a new Bot with a supported minimal custom scope; advanced customization stays available. Model remains explicitly pinned. | P1 · [S6](#s6) | +| J17 | Edit Bot, customize avatar, enable vision, bind Telegram | Durable identity, optional decoration, and external access are different tasks. | Allow optional avatar editing after first chat. Explain that model changes affect this Bot's ongoing conversation; connect Telegram or vision only on explicit intent, with recipient/access review. | P1 · [S6](#s6), [S7](#s7) | +| J18 | Telegram profile → token → enable → connect/poll → owner pairing → workspace/model | Multiple toggles and technical descriptions; independent Bot binding can require a second trip to Settings. | Three cards: “Connect Telegram,” “Choose what it can use,” “Send a message to finish.” Resume after BotFather; combine Aiden-owned enable/connect steps after acknowledgement. Verify the authorized owner before claiming readiness. | P1 · [S7](#s7) | +| J19 | Plugins catalog → preset → credential/authorization → save/test | “Connect” and “Test” may represent different readiness; generic editor exposes commands/headers. | Known plugin → permission/recipient summary → sign in or paste key → supported non-mutating connection verification. Distinguish “Saved” from “Ready.” Keep custom server setup under Advanced. | P1 · [S9](#s9) | +| J20 | Create/enable skill → invoke with `$` or model use | Difference between skill instructions, executable tools, and Bots is implicit. | Explain “Reusable instructions”; offer a simple example/template and a visible composer picker. Say when instructions are applied; do not claim enabling a skill guarantees invocation. | P2 · [S9](#s9) | +| J21 | Scheduled → editor or Ask Aiden → timing, scope, model/tools → confirm | Existing ordinary time controls are helpful, but run context is extensive. | Default to “What” and “When,” then one concrete review showing model, folder, access, time zone, and next run. Preserve advanced scripts/cron. Success: saved task with confirmed next run. | P1 · [S8](#s8) | +| J22 | Run/pause/resume schedule; inspect failure | A schedule can be mistaken for a cloud service that runs while the Mac is unavailable. | Keep “Runs while Aiden is open on this Mac” beside next run. Explain the actual missed-run policy, attention state, and pause status; never imply catch-up behavior without checking scheduler rules. | P1 · [S8](#s8) | +| J23 | Web Search on/off → provider catalog → routing/setup | Advanced fallback and recipient policy dominates a basic search preference. | First show On/Off and current recipient(s), with concise data disclosure. Keep custom routing below “Search options.” Changing recipients or unattended use remains explicit. | P1 · [S12](#s12) | +| J24 | Model Pad → benchmark credential/fetch → arrange models; Providers → catalog update | Optional evaluation data may look necessary for chat or become confused with model availability. | Describe it as optional model comparison. Keep manual source-specific fetch actions, provenance, and incomplete-data labels. Never fetch benchmarks or models.dev during setup or ordinary browsing. | P2 · [S3](#s3), [S12](#s12) | + +### Use Aiden on another device + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J25 | Remote Access → enable → Connection → method → Tailscale Connect → Add device | Primary action is gated by prerequisites the user must find and order. | Two-card setup described below; explicit acknowledgement enables the selected connection and opens pairing after verification. | P0 · [S13](#s13), [S14](#s14) | +| J26 | Mobile onboarding → prepare Mac → choose connection → camera/manual entry | iOS repeats network choices already encoded in QR; Android puts Paste JSON beside Scan QR. | “Scan the code on your Mac” is primary. Manual setup remains accessible as fallback; payload import becomes Advanced. No second transport decision for a valid QR. | P0 · [S15](#s15), [S16](#s16) | +| J27 | Pair successfully → choose Bot/workspace; approve browsing folders on Mac | “Connected” can lead to an empty workspace; folder browsing roots and existing workspaces have different scopes. | Show existing permitted content, then “Add a folder on your Mac” only when relevant. Explain precisely that browsing roots govern discovery/addition; do not suggest all existing workspaces are hidden by default. | P0 · [S13](#s13), [S14](#s14), [S17](#s17) | +| J28 | Leave Wi-Fi, sleep/quit Mac, lose connection → reconnect | Off, unreachable, Tailscale not ready, and revoked are different states. | “Can't reach your Mac” with known facts, preserved drafts/cache, and one relevant next action. Label cached content with freshness; show “Nearby only” for a LAN pairing. Do not assert the Mac is asleep without evidence. | P0 · [S14](#s14), [S17](#s17) | +| J29 | Pair another Mac/phone → switch installations | Similar Mac names and cached content can conceal which machine will run work. | Keep active Mac visible on action surfaces and approval cards. Verify every newly paired device independently; preserve installation/device-scoped caches and revocation. | P1 · [S13](#s13), [S17](#s17) | +| J30 | Revoke on Mac or remove saved Mac on phone | Stopping service, removing one credential, and deleting local cached data are different. | Use “Pause phone access,” “Remove device access,” and “Remove this Mac from this phone” with exact consequences. Local removal must not claim server-side revocation unless performed and verified. | P0 · [S13](#s13), [S16](#s16), [S17](#s17) | + +### Voice, permissions, maintenance, and help + +| ID | User goal and current path | Gap / likely hurdle | Proposed path and completion signal | Priority / evidence | +|---|---|---|---|---| +| J31 | Voice settings → provider/model/download → microphone or dictation shortcut | On-device engine setup and cloud credentials precede an apparently simple microphone action. | First microphone use opens relevant setup: show audio destination, download size if needed, and one setup action. Capture only after explicit record intent. Success: editable transcript, not automatic message sending. | P1 · [S18](#s18) | +| J32 | Mobile speech → native or paired Mac → optional Parakeet setup | Where speech is processed and why the Mac must be online can be unclear. | Label “On this device” / “On your Mac” according to actual supported processing; disclose native service behavior accurately. Show Mac model download progress and retain typed fallback. | P1 · [S14](#s14), [S18](#s18) | +| J33 | Enable Computer Use → OS Accessibility/Screen Recording → per-chat opt-in → Allow once | Global readiness, OS permissions, and chat authority are separate gates; copy names the driver. | “Let Aiden help in Mac apps” → plain privacy review → request missing OS permissions in order → return to originating chat. Preserve per-chat opt-in and approval before control actions. | P0 · [S19](#s19) | +| J34 | Memory settings → automatic compaction engine + global/workspace memory | Conversation shortening and durable remembered facts are presented together. | Explain “Keep long chats working” separately from “Remember useful information.” Put experimental engine selection under Advanced. Any future fact viewer/delete action needs actual storage support. | P1 · [S20](#s20) | +| J35 | Appearance / shortcuts → customization and conflict handling | Useful existing controls need to remain discoverable through a simpler information architecture. | Keep system defaults, text size, contrast, reduced motion, and shortcut conflict repair accessible; no prerequisite customization tour. Test keyboard-only and screen readers across setup. | P1 · [S21](#s21) | +| J36 | Profile → usage/date range → share snapshot | Tokens, estimates, and actual provider bills can be confused; profile sharing includes a name. | Explain request/usage totals, cost coverage and missing prices; do not present estimates as invoices. Keep preview and explicit sharing with the included personal data visible. | P1 · [S22](#s22) | +| J37 | About/sidebar → update → download/retry/restart | App restart can interrupt an ongoing task; failures need a durable next action. | Clear progress and “Restart to update” when safe; retain existing active-work guards and retry. Distinguish installed version from downloaded update. | P1 · [S5](#s5), [S23](#s23) | +| J38 | About → reopen onboarding vs reset onboarding; diagnostics → export/delete | “Reset onboarding” sounds like replaying a tutorial but its description clears profile setup/preferences. | Rename by actual consequence; separate “Show setup again,” scoped repairs, and destructive reset. Support export explains local contents; sensitive dumps remain a separate explicit choice. | P0 · [S23](#s23) | +| J39 | Ask Assistant for help setting up the app | Assistant settings explicitly say it cannot inspect live settings/projects or use connected tools. | Initially provide accurate guidance and links. A future “Help me set this up” capability must use a bounded reviewed setup operation and confirmation; do not advertise it as shipped. | P1 · [S8](#s8) | + +## The Aiden On The Go proposal + +### Two setup cards, then a useful connected state + +Use **Aiden On The Go** as the user-facing destination, with “Remote Access” retained as a searchable alias. Settings, onboarding's optional feature tile, and the existing connection popover should open the same setup state. + +| Card | What the user sees | Primary action | What Aiden handles | +|---|---|---|---| +| **1. Connect your phone** | “Use your Bots and workspaces from your phone or tablet while Aiden is running on this Mac.” Connection choice: **Away from home — uses Tailscale on both devices** or **On the same Wi-Fi — no Tailscale needed**. Show the available recommended route, never hide its requirement. | **Connect a device** opens the acknowledgement below. | Read current settings and local readiness. Prepare a summary of the exact proposed changes. No service or route mutation merely from opening the page. | +| **2. Scan to finish** | After confirmation: compact preparation progress, then QR; “Open Aiden On The Go on your phone and scan this code.” Mac name visible. Manual-code fallback available. | Phone: **Scan code**. Desktop: **Create new code** only when required. | Enable the chosen service/mode, configure the Aiden-owned private connection when permitted, verify it, then open the existing one-use pairing window. Track authenticated completion. | +| **Connected summary** | “[Device name] is connected to [Mac name].” Show “Nearby only” or “Uses Tailscale,” available content, and “Keep Aiden running on your Mac.” | Phone: **Open a Bot** or **Open a workspace**, according to available content. | Show current reachability separately from saved pairing. Keep device management and advanced connection diagnostics below. | + +There are only two setup cards. If a dependency is missing, replace the preparation area inside card 2 with a single repair instruction. Do not add an expanding wall of independent switches. Optional folder access is a follow-up in the connected summary; it does not block pairing or Bot use. + +### One acknowledgement modal + +For the Tailscale path: + +> **Connect your phone to this Mac?** +> +> Aiden will turn on phone access, set up its private connection through Tailscale, and show a one-time code for your phone. +> +> Paired devices can use the workspaces and capabilities this Mac allows. Your AI keys stay on this Mac; requests still go to the AI service you choose. Keep Aiden running to use it from your phone. +> +> You can remove a device's access here at any time. +> +> **Enable and show code** · **Cancel** + +For nearby access, replace the first paragraph with: “Aiden will turn on phone access over your local network and show a one-time code. Your phone and Mac need to be on the same network.” + +The scope sentence must be built from the actual current permissions and allowed workspaces. Put a human-readable access summary behind **Review access**, with no new grants selected automatically. Enabling the network connection is not permission to grant the home folder, Full access, unattended tools, or every Bot capability. + +The primary button is the acknowledgement. Do not add an “I understand” checkbox or a second generic confirmation. Use a separate review only if the proposed action materially changes, such as replacing a previous Aiden connection or changing an existing device's route. + +### Honest click budget + +Count desktop clicks starting on the setup page; network waits, QR scanning, text entry, OS permissions, and external sign-in are recorded separately. Current counts are inferred from controls and vary with saved settings; establish the actual baseline in the live test. + +| Starting state | Target Aiden interaction | Extra work that must remain visible | +|---|---|---| +| Fresh Aiden remote setup, Tailscale already installed/signed in/HTTPS authorized, default route suitable | **Connect a device → Enable and show code** | Phone scan and any camera permission; both devices need authorized Tailscale connectivity. | +| Same Wi-Fi chosen instead of the suggested away route | Select **On the same Wi-Fi**, then the two actions above | Phone camera/local-network permissions and scan. This is three desktop clicks when a route choice is changed. | +| Existing ready connection; add another device with unchanged scope | **Add device** opens code directly; existing access summary remains visible | Phone scan. No repeated acknowledgement of unchanged settings. | +| Tailscale missing or signed out | Same two Aiden setup actions, then a guided prerequisite | Installation, sign-in on both devices, and any required HTTPS/admin approval. Resume rather than restart. No two-click completion claim. | +| Conflict or unknown previous route result | Explain and offer the applicable review/verification | Owner review or external repair may be necessary; do not overwrite a connection to satisfy a click target. | + +### State and recovery contract + +```mermaid +flowchart TD + A[Connect a device] --> B[Review and confirm access setup] + B --> C[Check selected connection prerequisites] + C -->|Missing| D[Show one specific setup action] + D -->|Return and recheck| C + C -->|Ready| E[Prepare and verify connection] + E -->|Verified| F[Show one-time QR code] + E -->|Conflict or uncertain result| G[Explain and review or verify] + G -->|Resolved| C + F -->|Authenticated phone completion| H[Connected: choose a first task] + F -->|Expired| I[Create a new code] + I --> F +``` + +| State | Plain-language presentation | Required behavior | +|---|---|---| +| Tailscale missing | “To connect away from home, install Tailscale on your Mac and phone.” **Get Tailscale**; **Use same Wi-Fi instead**. | Use reviewed official destinations; do not install or authorize it silently. Changing transport requires the updated scope to be visible. | +| Tailscale signed out | “Open Tailscale and sign in on both devices to the same private network.” **Open Tailscale**. | Recheck on return; preserve setup progress. Mac readiness alone cannot prove phone membership. | +| HTTPS approval missing | “Your Tailscale network needs permission to create a secure connection. You may need its administrator.” **View setup instructions**. | Keep HTTPS authorization explicit; do not auto-change account/network policy. | +| Preparing | “Turning on phone access…” → “Preparing your private connection…” → “Checking the connection…” | Ordered, bounded operations; one active attempt. No QR until the selected transport is verified. | +| Different Aiden profile uses route | “Another Aiden profile is using this Mac's phone connection.” | Keep the current route. Active owner blocks; a stale owner gets a specific review. Never overwrite unrelated routes or enable Funnel. | +| Unknown route result | “We couldn't confirm whether setup finished.” **Check connection**. | Reconcile observed ownership/health before retrying; never claim nothing changed without evidence. | +| QR ready / consumed / expired | “Scan this code”; “Finishing connection”; “This code expired.” | Preserve existing five-minute one-use lifecycle and identity checks. Do not weaken/manual-shorten the setup secret. Do not interrupt a completing phone handshake to rotate the code. | +| Camera denied / unavailable | “Camera access is off. You can enter a setup code instead.” | Provide accessible manual pairing and OS-settings recovery. Keep address required where discovery cannot supply it. | +| Paired but no usable content | “Connected. Open a Bot, or choose a workspace on your Mac.” | Tailor to actual inventory and granted scope; never silently approve a folder or select a different AI recipient. | +| Unreachable later | “Can't reach [Mac name]. Keep Aiden running and check the connection.” | Preserve drafts/cache with explicit freshness. Retry only safe reads; revoked credentials go to re-pairing and cache cleanup. | + +### Orchestration requirements for implementation + +Reuse existing service, route, pairing, and revocation logic. Add a main-process-owned setup coordinator rather than a fragile sequence of renderer toggle clicks. The new operation must: + +1. Capture the exact profile, prior enabled/mode state, route ownership, and reviewed change scope. Recheck before each mutation; reject a stale review if consequences changed. +2. Perform prerequisite checks before avoidable mutations. Then enable the selected service, configure only the owned connection, verify health, and begin pairing in the order required by the existing service contract. +3. Serialize attempts and handle double clicks, navigation, app restart, cancellation, and late responses. Reuse the existing authenticated pairing-completion lifecycle. +4. Preserve pre-existing enabled access, devices, roots, endpoints, and unrelated Tailscale handlers. Do not silently select “both” to make discovery easier or change an established mobile endpoint. +5. On failure/cancel, close the exact unused pairing session. Roll back only changes proven to belong to this attempt when no completed pairing or concurrent change depends on them. If cleanup is uncertain, report what is known and offer verification; never blanket-reset Tailscale. +6. Distinguish preparation, code-ready, paired, reachable, and useful-content states. A local listener, created QR, or consumed code alone is not completion. +7. Keep secrets, codes, endpoints, raw Tailscale output, and identifiers out of diagnostics. Optional UX measurements must be coarse local counters, not new upload telemetry. + +This is more than rearranging controls. Safe orchestration and recovery are the substantial engineering work; the two-card surface is its presentation. + +### Mobile parity and accurate copy + +- iOS and Android should use the same user concepts and state meanings while retaining native interaction patterns. Both default to scanning, support accessible manual entry, and put payload import under Advanced. +- Update desktop device labels, empty states, and onboarding copy to include Android where the shipped build supports it. `SettingsDeviceRow` currently renders every non-iPad device as “iPhone”; this needs contract review before choosing a corrected type mapping. +- Verified during implementation: the manual setup code decrypts the bootstrap on the phone and is never sent to the Mac. Preserve that accurate disclosure and the existing cryptography. This does not mean prompts or all chat data stay on the phone. +- Remove transport terminology from the primary phone path because a valid QR already specifies its endpoint. Keep endpoint/pin information available for manual setup and identity problems. +- Never bypass an identity mismatch, credential revocation, system permission, or unavailable feature on an older client. Provide a named recovery action or compatible fallback. + +## A consistent pattern for every setup + +Use **Choose outcome → review meaningful consequences → prepare automatically → verify → first useful action**. Keep the interface to two or three cards where that actually simplifies decisions. Do not force ordinary repeat actions into a wizard. + +| Setup | First card | Second card | Optional third / completion | +|---|---|---|---| +| AI connection | Choose how to connect | Sign in or provide required key; verify | Selected model and **Start a chat** | +| Bot | Describe its job | Review explicit model and minimal supported access | **Create and chat**; appearance/custom scope optional | +| Telegram | Connect your Telegram bot | Review owner and allowed work | Send pairing message; confirm connection | +| Voice | Choose where audio is processed | Complete required download/permission | Return to editable composer and record on intent | +| Computer Use | Explain screenshots and actions | Complete missing OS permissions | Enable for originating chat with existing action approvals | +| Plugin | Choose service and review access | Sign in/key and verify | Show available tools and return to task | +| Schedule | Describe work and when | Review exact time, model, scope and cost implications | Show next run and how to pause | + +Use one acknowledgement when an action enables remote access, changes recipients, grants capabilities, schedules unattended work, or downloads a substantial optional model. Use direct actions with clear feedback for ordinary navigation, unchanged repeat pairing, and reversible preferences. Preserve separate destructive confirmation where warranted. + +For new users, offer defaults derived from actual supported inventory. Do not silently replace a user's model, inherit Full access into a new Bot, enable unattended web/plugin access, or change a saved privacy preference. A “recommended” label needs a transparent reason, such as “already connected,” not an invented quality ranking. + +## Language and settings organization + +| Current wording | Proposed primary wording | Keep in detail when useful | +|---|---|---| +| Remote Access | Aiden On The Go / Connect your phone | Remote Access as search alias | +| Tailscale / Local Network | Away from home / On the same Wi-Fi | Tailscale requirement and actual route | +| Tailscale Serve | Private phone connection | Exact route/command and diagnostics | +| Approved roots | Folders your phone can browse | Root restrictions and existing-workspace distinction | +| Revoke | Remove device access | Immediate credential invalidation | +| Per-device credential / Pinned HTTPS identity | Only paired devices can connect | Identity verification details | +| Pi-powered teammate | Bot | Runtime names in developer information | +| Polling / polling lease | Connected / Checking for messages | Troubleshooting diagnostics | +| MCP server | App connection, or named plugin | MCP in Advanced and search aliases | +| Compaction | Keep long chats working | Summarization engine and experimental controls | +| Reset onboarding | Reset profile setup and preferences | Exact affected/preserved data from backend | + +Keep sentences accurate before making them shorter. For example, “Stored on this Mac” does not imply information is never included in a model request. “Same Wi-Fi” needs a fallback explanation for wired Macs and networks that isolate devices. “Away from home” still requires the Mac to be reachable and the phone to have the intended private-network access. + +Proposed Settings grouping, retaining deep links and expert access: + +| Group | Destinations / actions | +|---|---| +| AI and chat | AI connections, optional Model Pad, memory | +| Apps and tools | Plugins, Skills, Web Search, Computer Use | +| Phone and automation | Aiden On The Go, Telegram, Scheduled tasks | +| Personal preferences | Appearance, Voice, Keyboard shortcuts, Assistant | +| Help and app | Updates/About, replay setup, diagnostics, carefully separated reset | + +Prototype and test this grouping before moving navigation. The first implementation should repair high-friction journeys and copy without requiring a whole-app navigation migration. + +Every disabled primary control needs a nearby reason and a relevant action. Every empty state needs a next step. Every failure should say what happened, what is preserved, and what the user can do; do not promise preservation if outcome is unknown. Keep raw diagnostic detail expandable and copyable without displaying credentials. + +## Visual and accessibility requirements + +Use Aiden's existing semantic tokens and UI primitives, informed by [the desktop reference](../chatgpt-desktop-ui-inspiration.md) and [the interactive specimen](../chatgpt-ui-element-specimen.html). This proposal adds no new application UI or assets. + +- Cards use existing backgrounds and spacing. No decorative borders/outlines around radio choice cards; selection uses the radio and background state. +- Status uses soft semantic fills, text, and icons, never color alone or decorative colored outlines. +- Non-text keyboard controls retain visible neutral focus rings. Text-entry borders stay unchanged on focus; use existing input-background/caret states. +- Dialogs announce their title, contain focus appropriately, support cancel, and return focus to the initiating control. Step changes have concise screen-reader announcements. +- Progress is readable without animation. Respect reduced motion, text scaling, light/dark/high-contrast settings, and narrow desktop/mobile layouts. +- QR pairing cannot be the sole accessible route. Do not announce a countdown every second; announce meaningful state changes and keep time remaining available. +- Onboarding must stay concise and data-driven. Reuse the existing Aiden On The Go illustration when updating that tile. Any new advertised durable feature requires its own optimized 1024 × 1024 transparent PNG and the existing asset-contract test. + +## Delivery order and acceptance gates + +| Phase | Concrete deliverable | Dependencies and acceptance | +|---|---|---| +| 0 — Establish baseline | Walk through J01–J39 and mark observed/pass/fail/not applicable on actual builds; prototype remote cards. | No user-account/permission mutations during inspection without the corresponding user action. Record actions, navigation changes, completion, and confusing words. | +| 1 — Remote setup | Main-owned setup operation, two-card desktop flow, acknowledgement, recovery, native pairing copy/navigation, optional onboarding entry, updated remote guide. | Cover already-ready LAN/Tailscale, missing prerequisites, conflicts, cancellation, expiry, multiple profiles/devices, stale clients, and revocation. Two desktop clicks on the defined ready/default path. | +| 2 — First useful chat | Provider return-to-draft setup, deliberate model default, workspace wording, attachment compatibility recovery, actionable blocked composer. | Reuse onboarding validation; preserve provider choice and privacy defaults. Verify key-invalid, cancelled sign-in, no models, all-hidden models, offline provider, and failed send. | +| 3 — Other setup journeys | Bot, Telegram, voice, Computer Use, plugins, schedules using the shared UX pattern. | Ship in independently testable slices; keep backend access distinctions and explicit external authorization. No new unsupported “safe” Bot mode label. | +| 4 — Recovery and navigation | Settings intent search, memory/help/reset clarity, persistent repair actions, optional grouping changes. | Preserve deep links, shortcuts, accessibility, expert controls, and safe migration of existing preferences. | + +**Phase 1 definition of done:** a user can start at the setup page, understand what will be enabled, get a working code on a ready connection in two desktop clicks, finish pairing on iOS or Android, identify the connected Mac, open permitted content, recover from a blocked setup, and remove access. All existing identity/ownership safeguards must still pass. + +**Engineering checks for future implementation:** + +- Extend relevant existing desktop tests; register any added test file in `package.json`. Phase 1 includes `npm run test:aiden-remote`, `npm run test:onboarding`, and focused settings/command/connection-popover coverage where changed, plus type checking and build. +- For shared remote contracts or transcript/activity changes, inspect and update **both** native consumers and focused tests. Run applicable iOS tests under its documented Xcode workflow and Android Gradle suites; do not claim mobile validation from desktop tests alone. +- Other slices use the existing provider, Bots, scheduled, voice, web-search, Computer Use, memory, and command suites as relevant. Check `package.json` for the exact current scripts when implementing. +- Test outcome and state behavior, not only copy snapshots: one attempt per gesture, no premature success, no stale-result publication, preserved drafts, accurate cleanup, no unauthorized scope change, and safe re-entry. +- Add live keyboard/screen-reader, mobile camera/manual-entry, background/foreground, and Mac unavailable checks. Keep physical-device gates open until performed. + +**Validation for this document:** source references and local links checked, journey IDs checked for uniqueness, and `git diff --check`. Application tests are not required for this documentation-only proposal; none of the proposed behaviors have been implemented or runtime-validated here. + +## Usability study and success targets + +Use five to eight participants unfamiliar with developer tools. Include both mobile platforms, a keyboard-only/screen-reader session, someone without an AI connection, and someone without Tailscale. Use consented test accounts/devices; count external setup separately. + +| Task | What to observe | Proposed acceptance target, not a measured result | +|---|---|---| +| Connect AI and ask a first question | Abandonment, terms needing explanation, lost drafts | At least 80% complete without moderator intervention after account prerequisites are met. | +| Pair phone on prepared Tailscale | Desktop actions, transport confusion, accurate readiness | Two desktop clicks to QR on the defined default path; at least 80% finish pairing unassisted. | +| Pair phone with no Tailscale | External handoff, return/resume, understanding nearby alternative | Participants can identify the next required action and resume without repeating completed setup. | +| Recover from denied camera or unreachable Mac | Recovery discoverability, draft preservation | At least 80% find manual entry or the relevant recovery action without help. | +| Explain and remove access | Understanding of running Mac, provider requests, allowed work, removal | Every participant can locate removal; any misunderstanding of access/data consequences triggers copy redesign. | +| Create Bot and schedule a task | Required decisions and scope comprehension | At least 80% complete unassisted and can explain the selected model, access, next run, and Mac availability requirement. | + +Measure completion time, decision count, navigation changes, backtracking, assistance requests, and one post-task ease rating. Establish actual baseline values before setting time-reduction claims. Store research observations with consent; any product counters remain local and categorical unless separately approved. A small study identifies friction; it does not prove accessibility or population-wide success. + +## Implementation handoff prompt + +> Implement Phase 1 of `docs/plans/nontechnical-user-journey-ux-plan.md`. Read project instructions, current memory, remote hardening/manual-pairing plans, and both UI design references first. Replace dependency hunting in Aiden On The Go setup with the two-card, explicitly acknowledged flow and a main-process-owned coordinator. Preserve route ownership, endpoint stability, one-use codes, identity validation, permissions, per-device revocation, and all existing-device state. Keep Tailscale installation/sign-in/HTTPS authorization as guided external prerequisites. Include iOS and Android pairing/copy parity, relevant onboarding updates, recovery states, focused tests, remote setup documentation, and plan/memory updates. Treat the two-click budget as applying only to the specified prepared default route. Validate the work and report any remaining physical-device gates. Leave later phases proposed until separately scoped. + +## Source register + +Paths below are the authoritative audit evidence. Component code takes precedence where older narrative documentation uses superseded labels. Sources support the current-state observations; proposed copy and flows are recommendations. + +**S1 — First run:** [onboarding flow](../../renderer/components/onboarding-flow.tsx), [onboarding tests](../../renderer/components/onboarding-flow.test.tsx), [auth/validation plan](onboarding-auth-and-provider-validation-plan.md). + +**S2 — Settings:** [destinations and search keywords](../../renderer/shared/settings-section.ts), [settings view](../../renderer/main/settings-view.tsx). + +**S3 — Providers/models:** [providers](../../renderer/components/settings/providers-settings.tsx), [custom provider editor](../../renderer/components/settings/provider-editor.tsx), [model picker](../../renderer/components/model-picker.tsx), [model visibility](../../renderer/components/settings/provider-model-visibility.tsx). + +**S4 — Compose:** [composer](../../renderer/components/composer.tsx), [workspace picker](../../renderer/components/workspace-picker.tsx), [chat pane](../../renderer/main/chat-pane.tsx). + +**S5 — Navigation and history:** [sidebar](../../renderer/components/chat-sidebar.tsx), [chat layout](../../renderer/main/chat-layout.tsx). + +**S6 — Bots:** [desktop Bots/editor](../../renderer/main/bots-view.tsx), [iOS editor](../../ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift), [Android editor](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt), [Bot-first plan](bot-first-aiden-on-the-go-plan.md). + +**S7 — Telegram:** [settings](../../renderer/components/settings/telegram-settings.tsx), [parity plan](telegram-first-class-agent-parity-plan.md). + +**S8 — Schedules/Assistant:** [task editor](../../renderer/components/scheduled-task-editor.tsx), [tasks view](../../renderer/components/scheduled-tasks-view.tsx), [Assistant capability disclosure](../../renderer/components/settings/assistant-settings.tsx), [Assistant automation approval](../../renderer/components/assistant/assistant-automation-approval.tsx). + +**S9 — Plugins/skills:** [plugin settings](../../renderer/components/settings/mcp-settings.tsx), [preset setup](../../renderer/components/settings/mcp-preset-setup.tsx), [skills](../../renderer/components/settings/skills-settings.tsx). + +**S10 — Progress/approval/recovery:** [activity feed](../../renderer/components/activity-feed.tsx), [subagent shell approval](../../renderer/components/subagent-shell-approval.tsx), [provider failure mapping](../../main/services/provider-failure.ts), [composer](../../renderer/components/composer.tsx). + +**S11 — Work surfaces/results:** [Files](../../renderer/components/files-panel.tsx), [Review](../../renderer/components/review-panel.tsx), [Environment](../../renderer/components/environment-panel.tsx), [artifact preview/export](../../renderer/components/html-artifact-frame.tsx), [commit](../../renderer/components/git-commit-dialog.tsx), [push](../../renderer/components/git-push-dialog.tsx). + +**S12 — Search and optional model information:** [Web Search](../../renderer/components/settings/web-search-settings.tsx), [Model Pad](../../renderer/components/settings/model-pad-settings.tsx), [model data](../../renderer/components/settings/model-data-settings.tsx), [manual catalog policy](../../AGENTS.md). + +**S13 — Desktop remote UX:** [settings and pairing dialog](../../renderer/components/settings/remote-access-settings.tsx), [remote settings tests](../../renderer/components/settings/remote-access-settings.test.tsx), [connection popover](../../renderer/components/remote-connection-popover.tsx), [pairing lifecycle](../../renderer/lib/remote-pairing-lifecycle.ts). + +**S14 — Remote boundaries:** [remote guide](../aiden-on-the-go-remote-access.md), [manual pairing plan](aiden-manual-pairing-plan.md), [multi-instance hardening](completed/aiden-remote-multi-instance-hardening-plan.md), [remote API](../aiden-remote-api-v1.md). + +**S15 — iOS pairing:** [onboarding and pairing view](../../ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift), [remote client](../../ios/AidenOnTheGo/Networking/AidenRemoteClient.swift). + +**S16 — Android pairing:** [pairing and removal screen](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt), [remote client](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt). + +**S17 — Native continuation:** [iOS coordinator](../../ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift), [iOS workspace shell](../../ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift), [Android product shell](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenProductShellScreen.kt), [Android workspace shell](../../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/workspaces/AidenWorkspaceShellScreen.kt). + +**S18 — Voice:** [voice settings](../../renderer/components/settings/voice-settings.tsx), [local voice setup](../../renderer/components/settings/local-voice-settings.tsx), [paired-Mac speech](../aiden-on-the-go-remote-access.md#paired-mac-voice-input). + +**S19 — Computer Use:** [settings and disclosures](../../renderer/components/settings/computer-use-settings.tsx), [hardening plan](update-microphone-computer-use-hardening-plan.md). + +**S20 — Memory:** [memory settings](../../renderer/components/settings/memory-settings.tsx), [compaction plan](compaction-plan.md). + +**S21 — Personal preferences:** [appearance](../../renderer/components/settings/appearance-settings.tsx), [shortcuts](../../renderer/components/settings/shortcut-settings.tsx), [semantic appearance definitions](../../renderer/shared/appearance.ts), [style tokens](../../renderer/styles.css). + +**S22 — Usage/sharing:** [profile](../../renderer/main/profile-view.tsx), [share card](../../renderer/components/usage/profile-share-card.tsx). + +**S23 — Maintenance:** [About/update/reset](../../renderer/components/settings/about-settings.tsx), [diagnostics](../../renderer/components/settings/diagnostics-settings.tsx), [test scripts](../../package.json). diff --git a/docs/plans/performance-stability-efficiency-plan.md b/docs/plans/performance-stability-efficiency-plan.md index f5467aaf8..1ab6358be 100644 --- a/docs/plans/performance-stability-efficiency-plan.md +++ b/docs/plans/performance-stability-efficiency-plan.md @@ -56,7 +56,7 @@ The first implementation milestone should therefore be **data safety and bounded | P1 | Scheduled catch-up has per-task overlap protection but no global budget or battery/lock policy | `main/services/schedule-service-core.ts` | Multiple missed tasks can stampede after startup/resume | | P2 | The closed model picker queries every provider and rebuilds/sorts catalog structures during parent renders | `renderer/components/model-picker.tsx`, `renderer/lib/queries.ts` | Stream-frame work scales with provider/model count | | P2 | Startup waits for provider enumeration/auth state before the first React render | `renderer/main/index.tsx`, `main/services/provider-registry.ts` | A slow keychain/provider probe delays visible app chrome | -| P2 | Routes, settings, xterm, KaTeX, and full Highlight.js are in an eager startup graph | `renderer/main/router.tsx`, `renderer/main/settings-view.tsx`, `renderer/components/code-block.tsx` | Larger parse/compile/startup and update payload | +| P2 | Routes, settings, terminal, KaTeX, and full Highlight.js are in an eager startup graph | `renderer/main/router.tsx`, `renderer/main/settings-view.tsx`, `renderer/components/code-block.tsx` | Larger parse/compile/startup and update payload | | P2 | Terminal output and LLM deltas cross IPC at source cadence; terminal buffers repeatedly copy large strings | `main/services/terminal.ts`, `main/services/llm-client.ts` | Excess wakeups, IPC allocations, and resize churn | | P2 | Several realistic lists are unwindowed | files, review, chat/model palettes, transcript | Large workspaces and histories degrade nonlinearly | @@ -239,7 +239,7 @@ Exit gate: #### 4B. Render sooner and load optional code later - Render the lightweight shell before provider enumeration. Show a truthful provider-hydration state and enable composer selection only after alias/identity migration is authoritative. -- Dynamically import Settings/Profile/Scheduled routes, individual heavy Settings sections, terminal/xterm on first open, and other secondary panels. +- Dynamically import Settings/Profile/Scheduled routes, individual heavy Settings sections, terminal/libghostty on first open, and other secondary panels. - Keep ChatLayout, transcript shell, composer, selected-model trigger, and generation bridge in the initial graph. Optionally idle-preload the next likely surface after first input readiness. - Use package-content allowlists. Bundle pure-JS main dependencies when safe; externalize/unpack only native/runtime-required modules. - Disable packaged source maps or publish hidden maps outside the app artifact. Add a test that no `.map` is shipped. diff --git a/docs/plans/pi-compaction-memory-upgrade-implementation-notes.md b/docs/plans/pi-compaction-memory-upgrade-implementation-notes.md index 045a85f9b..2f7de22e6 100644 --- a/docs/plans/pi-compaction-memory-upgrade-implementation-notes.md +++ b/docs/plans/pi-compaction-memory-upgrade-implementation-notes.md @@ -399,7 +399,12 @@ claimed or selected without its device-local receipts. then `v4_only`. The same authoritative chat eligibility controls new v4 journal creation, legacy migration, automatic and manual Pi checkpoints, and memory. In particular, a pre-existing chat without a journal is not silently - classified as new. `AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0` is read once at + classified as new. Rollout-ineligible chats therefore generate journalless + over an in-memory session (`openChatIfEligible` reports a structured reason + instead of throwing; recall is omitted, effect recovery is never marked + durable, and the durable store is never quarantined), so no stage blocks + generation — see the "Journalless generation" section of the Phase 7 + rollout runbook. `AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0` is read once at startup and disables journal creation/migration, automatic/manual checkpoint generation, and memory while retaining byte-stable read access to existing v4 journals. diff --git a/docs/plans/scheduled-provider-and-pi-rollout-recovery-plan.md b/docs/plans/scheduled-provider-and-pi-rollout-recovery-plan.md new file mode 100644 index 000000000..550ad4931 --- /dev/null +++ b/docs/plans/scheduled-provider-and-pi-rollout-recovery-plan.md @@ -0,0 +1,94 @@ +# Scheduled-Task Provider Recovery and Pi Rollout Generation Fix + +Status: Implemented (2026-09-09) — Phases A, B2, and C are coded and green in +worktree `.worktrees/prod-error-recovery` (branch `fix/scheduled-provider-and-pi-rollout`); +B1 (operator stage advance) and machine remediation remain release-owner steps. +Originated from installed production diagnostics on 2026-09-09 +(app v0.39.0, `~/Library/Application Support/Aiden Agent`). + +## Problem + +Two production failures surfaced from the installed app's logs and data: + +1. **Scheduled LLM tasks fail forever with "Choose a provider before running + this scheduled task."** + - Thrown at `main/services/schedule-execution.ts:233` when both + `task.providerId` and `settings.lastProviderId` are empty. + - UI-created tasks never store a provider (`renderer/components/scheduled-tasks-view.tsx` + `newTask()`; the editor has no provider picker), and the fallback key + `settings.lastProviderId` is only ever written by the Telegram flow while + the app's real selection lives in renderer localStorage + (`renderer/lib/use-model-selection.ts`), invisible to the main-process + scheduler. +2. **"Generation failed: Pi v4 journal creation is outside the active device + rollout stage."** + - Thrown at `main/services/pi-compaction-session-store.ts:675` when a chat + has no v4 journal and the device-local rollout policy (stage + `new_chats`, `activatedAt` = first launch of the Pi-upgrade build) marks + the chat as pre-activation. + - `main/services/llm-client.ts` opens the journal unconditionally and + hard-fails generation without one, so every chat created before + activation is blocked from generating until the operator advances the + rollout stage. +3. **Remote 4xx bursts** in rotated diagnostics + (`remote-request-failed`, route categories workspaces/chats/schedules/usage) + — cause unknown; needs route/version evidence before a fix. + +## Fix design + +### Phase A — scheduled-task provider resolution + +- **A1 (main):** attended renderer chat starts persist + `lastProviderId`/`lastModel` into app settings so the documented scheduler + fallback resolves the selection the app actually uses. Attended + renderer-owned starts only; scheduled/subagent/bot streams must not + overwrite the user default. +- **A2 (renderer + tool):** pin providers explicitly — + `newTask()`/templates prefill from the current model selection; the task + editor gains a provider/model picker (default "App default"); chat-driven + `schedule_task` creation stops displaying "Scheduler default" when nothing + resolves and rejects creation instead of saving a task that can only fail. +- **A3:** editor + parse guardrail warns when an LLM task has no provider and + no app default exists yet. +- Contract unchanged: `providerId` stays optional; remote/mobile-created + tasks already pin concrete providers. + +### Phase B — Pi rollout generation block + +- **B1 (operational, release owner):** advance the device per + `docs/testing/pi-compaction-phase7-rollout-gates.md` (evaluation receipt → + installed receipt → `npm run pi-upgrade:advance -- migrated_low_risk_chats`). + Note `AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0` does not restore generation for + journal-less chats. +- **B2 (code):** generation must never be hard-blocked by the rollout gate, + while preserving the fail-closed contract that a pre-existing chat is never + silently given a v4 journal or migrated: + - `pi-compaction-session-store.ts` gains `openChatIfEligible()` returning + either a session or a structured rollout-ineligible/legacy-deferred + reason; `openChat()` keeps its throws for background callers. + - `llm-client.ts` uses the probe and, when ineligible, runs the turn + journalless over an in-memory session (child-agent precedent): no v4 + journal is created, effect recovery is never marked durable, and store + quarantine can never fire for an in-memory failure. VCC recall is + omitted; todo replay runs against the empty in-memory journal. + - `chats:todoSnapshot` and the context-lifecycle compaction caller skip + instead of throwing for ineligible chats. + +### Phase C — remote 4xx evidence + +Add route/method/client-version fields to `remote-request-failed` journal +events, correlate with the paired mobile build, then fix the wrong side +(route alias or client refresh backoff) with a router regression test. + +## Machine remediation (after ship) + +- Salman Guardian task heals via the A1 fallback after one attended send, or + by pinning a provider in the new editor. +- Pre-activation chats heal immediately via B1; B2 protects every device. + +## Verification + +`npm run type-check`, `npm run lint`, focused suites (`test:scheduled`, +`test:assistant-automations`, `test:compaction`, `test:google-provider`, +`test:command-system`, `test:aiden-remote`), then the full `npm run test` +chain. New test files must be registered in `package.json`. diff --git a/docs/security/aiden-remote-threat-model.md b/docs/security/aiden-remote-threat-model.md index 0c4db1338..819ddcd3a 100644 --- a/docs/security/aiden-remote-threat-model.md +++ b/docs/security/aiden-remote-threat-model.md @@ -133,7 +133,7 @@ Speech setup and use deliberately map to the existing `server:read`/`chat:write` ## 7. Privacy and logging -Remote-access diagnostics are metadata-minimal. Permitted fields: closed route category, outcome or status class, bounded duration, and stable Aiden-owned error code; successful production traffic is aggregated by day. Forbidden fields: request IDs, instance/device suffixes, Authorization, pairing/idempotency secrets, opaque handles, QR contents, URLs, request/response bodies, managed or external paths, prompts/messages/reasoning, authoritative bot instructions, editable bot guidance, policy fingerprints, skill contents, attachments/avatar bytes or metadata, Image Playground prompts/rejected candidates/temporary URLs, tool details, provider/MCP failures, Git/shell/schedule output, Keychain/App Group data. +Remote-access diagnostics are metadata-minimal. Permitted fields: closed route category, bounded HTTP request method, Aiden-owned route templates (content-free server constants such as `/chats/:id/turns` that never reflect a request URL, query string, credential, identifier, or caller-supplied path), outcome or status class, bounded duration, and stable Aiden-owned error code; successful production traffic is aggregated by day. Forbidden fields: request IDs, instance/device suffixes, Authorization, pairing/idempotency secrets, opaque handles, QR contents, URLs, request/response bodies, managed or external paths, prompts/messages/reasoning, authoritative bot instructions, editable bot guidance, policy fingerprints, skill contents, attachments/avatar bytes or metadata, Image Playground prompts/rejected candidates/temporary URLs, tool details, provider/MCP failures, Git/shell/schedule output, Keychain/App Group data. Offline caches are scoped by Aiden instance ID and use platform data protection. They may retain safe Bot identity/inbox/access summaries and canonical-avatar cache entries, never managed paths, credentials, internal bindings, or Image Playground temporary results. Shared unsent composer drafts are additionally keyed by chat ID, remain in the app-private container (not App Group, widget, intents, or logs), clear after an accepted send, and purge on removal, revocation, or replacement pairing. Pending attachment references are not copied into draft persistence. Revocation makes other cached data read-only until the user explicitly removes the installation/cache. Lock Screen response excerpts are off by default. diff --git a/docs/settings-design-system.md b/docs/settings-design-system.md index ad2d11614..78af6074a 100644 --- a/docs/settings-design-system.md +++ b/docs/settings-design-system.md @@ -15,7 +15,7 @@ The `.settings-responsive` container defines `--settings-card-radius`, `--settin Rows respond to their allocated content width, not the whole window. Below 540px complex controls stack under descriptions, while switches remain on the right. Grid groups must use `minmax(0, 1fr)` / `grid-cols-1` so long provider names or endpoints cannot force horizontal overflow. Controls and text must stay reachable without horizontal page scrolling. -Model Pad measures the actual scrollport, wrapped toolbar, labels, and legend. Its square is constrained by both remaining height and column width. On very short or highly zoomed windows, it retains a usable 160px square and the Settings page scrolls; the Pad and its labels remain reachable. Ordinary window allocations show the full canvas and legend together. Opening model or benchmark panels uses the same measurement. +Model Pad measures the actual scrollport and remaining column. Axis captions and the legend use a reserved height so the square outline stays put while surrounding copy, marker labels, and catalog text change. The square is constrained by remaining height, column width, and the visible scrollport. On very short or highly zoomed windows, it keeps a usable canvas (160px when the scrollport allows) and the Settings page scrolls; the Pad and its labels remain reachable. Ordinary window allocations show the full canvas and legend together. Opening model or benchmark panels uses the same measurement. ## Workspace labels diff --git a/docs/testing/pi-compaction-phase7-rollout-gates.md b/docs/testing/pi-compaction-phase7-rollout-gates.md index 2aedfe967..b7c9921ae 100644 --- a/docs/testing/pi-compaction-phase7-rollout-gates.md +++ b/docs/testing/pi-compaction-phase7-rollout-gates.md @@ -3,6 +3,10 @@ Status: automated evaluation and signed development-package acceptance pass; installed production and credentialed-provider evidence remains **Pending** until the release owner runs the steps below against the installed candidate. +Generation is never blocked by the rollout gates at any stage: +rollout-ineligible chats generate **journalless** over an in-memory session +instead of failing (see "Journalless generation" below), so advancing the +stage is a durability decision, not an availability one. ## Device-local evaluation receipt @@ -46,7 +50,42 @@ Repeat only after observing the current stage and completing the next cohort's a ## Rollback -Set `AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0` before app startup and restart Aiden. This disables new v4 journal creation, legacy migration, automatic/manual Pi checkpoint generation, and durable-memory retrieval or writes. Existing v4 journals remain readable and are not downgraded or rewritten. Remove the override and restart to resume the persisted rollout stage. +Set `AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0` before app startup and restart Aiden. This disables new v4 journal creation, legacy migration, automatic/manual Pi checkpoint generation, and durable-memory retrieval or writes. Existing v4 journals remain readable and are not downgraded or rewritten. With the journalless safety net, chats that have no existing v4 journal continue to generate in the rollback environment — journalless over an in-memory session — rather than failing; durable compaction, memory, and history recall stay disabled for them until the override is removed and the persisted rollout stage resumes. Remove the override and restart to resume the persisted rollout stage. + +## Journalless generation (safety net) + +`PiCompactionSessionStore.openChatIfEligible()` probes eligibility and reports a +structured reason instead of throwing; `openChat()` keeps its fail-closed +contract for background callers. When the probe reports a reason, the +generation runs over an in-memory session and **no durable journal is created +and no legacy migration runs** — the fail-closed rollout contract is preserved +verbatim. + +It engages exactly when a chat cannot yet hold a durable journal: + +- Stage `new_chats` (production default): chats created before the policy's + `activatedAt` (the first launch of the Pi-upgrade build on that device). +- Any stage: chats whose legacy v3 journal is still deferred (its cohort has + not reached `migrated_low_risk_chats`, or it exceeds that stage's 500-entry + limit). The v3 bytes are never touched. +- The rollback environment (`AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0`): chats + without an existing v4 journal. + +Semantics of a journalless run: the request path is identical, visible turns +persist through the chat store exactly as with journaled runs, but VCC history +recall is omitted (nothing durable to recall), todo replay reads the empty +in-memory journal (the todo panel reports the snapshot unavailable), +automatic and manual Pi checkpoints stay cohort-disabled, effect-recovery +boundaries are written only in-process and never acknowledged as durable, and +the durable store can never be quarantined by an in-memory failure. + +Verify on a device (any pre-activation chat): send a message — generation +succeeds; no new journal appears for that chat under the app's +`pi-compaction-sessions` storage; its todo snapshot reports unavailable; +requesting compaction resolves as already compact enough. Advancing the stage +(below) restores durable journals — creation becomes unconditional at +`migrated_low_risk_chats`, legacy v3 migration unlocks for journals of up to +500 entries, and later stages follow the cohort ladder to `v4_only`. ## Provider-native re-audit diff --git a/docs/ux/implementation-review.md b/docs/ux/implementation-review.md new file mode 100644 index 000000000..660f9df9e --- /dev/null +++ b/docs/ux/implementation-review.md @@ -0,0 +1,39 @@ +# Guided setup UX — implementation review + +Approved from [Now vs proposed](now-vs-proposed.html). The [journey chart](../plans/nontechnical-user-journey-ux-plan.md) records the broader backlog and the exact scope implemented here. + +## Quick review + +| Try this | Look for | +| --- | --- | +| First-run AI setup | ChatGPT, LM Studio, Ollama, Other Custom Provider; Other ways below. Custom setup cannot complete without an available default model. | +| Settings → Aiden On The Go | Connect your phone / Scan to finish. Choose a method, acknowledge once, scan the code. | +| Cancel the phone acknowledgement | Access stays off. No route changes. | +| Interrupt or fail phone preparation | New access is rolled back when the result is known. Existing access and unrelated routes are preserved. Uncertain changes require explicit verification. | +| Create a bot | Name and instructions → model and access. Optional appearance and detailed capabilities. Fresh desktop drafts start with no custom tool grants. | +| Telegram | Token → model/access → connect and pair. Enable and connect happen together after an unattended-access acknowledgement. | +| Voice | Choose where audio goes. Errors stay beside the draft with Open voice settings. | +| Computer Use | Read the screenshot/provider explanation before enable; then handle Mac permissions. | +| Scheduled task | Review the task and its access before creation. Failed saves keep the draft. | +| Connect a plugin | Connect checks the endpoint’s tool availability. Errors stay in the dialog. | +| Search Settings | Try “connect my phone”, “use my voice”, “connect my ai”, or “see my screen”. | +| Native pairing | Updated Mac instructions, scanning first, manual entry available, raw payload import under Advanced. | + +Two desktop actions means **Connect a device → Enable and show code after choosing the method**. External Tailscale installation/sign-in/HTTPS authorization, scanning, and OS permission prompts are additional steps. + +## Evidence + +- Desktop TypeScript and E2E TypeScript checks pass. +- Focused remote, onboarding, bot, Telegram, voice, scheduling, composer, plugin, and permission checks pass. Remote tests cover successful LAN/Tailscale setup, stale reviews, owner cancellation, concurrent attempts, rollback, preservation of enabled access, saved-route protection, and pending-outcome reconciliation. +- Electron walkthroughs cover the four provider choices, custom-provider validation, LM Studio discovery and relaunch, computer-control acknowledgement cancellation, guided LAN pairing cancellation/success, listener survival after closing the window, and all Settings destinations. +- The Bot editor Electron test uses a test-owned IPC catalog and captures its submitted Custom access. It deliberately fails saving to verify draft retention. It does not prove native Bot Keychain storage; the isolated profile cannot establish that authority. The separate Bot storage/permission suites pass. +- Android `:app:testDebugUnitTest` passes, including compiling the updated pairing UI. It uses the installed Android Studio JBR and local Android SDK. +- React Doctor reports no errors; its warnings concern existing large component/state patterns and draft resets when opening dialogs. ESLint passes for changed TypeScript files. +- Vite and Electron bundles build. The unchanged desktop C helpers compile with the installed Command Line Tools and the existing build flags. The normal `npm run build` wrapper is blocked because its sanitized child environment selects an Xcode installation with an unaccepted license. + +## Before release + +- Resolve the Xcode license and run the focused iOS native integration/pairing tests on the allowed physical device. No simulator was used. +- Complete a physical phone scan, actual Tailscale route setup/recovery, and device revocation walkthrough. Tests use local fixtures, not external accounts or a live tailnet. +- Verify native Bot Keychain storage in a suitable signed/test environment. No authority fallback was added to production. +- Conduct the nontechnical-user usability checks from the plan. The action reductions are implemented interaction counts, not measured user outcomes. Broader first-task suggestions and exhaustive 39-journey redesign remain tracked in the audit. diff --git a/docs/ux/now-vs-proposed.html b/docs/ux/now-vs-proposed.html new file mode 100644 index 000000000..52a9b94ba --- /dev/null +++ b/docs/ux/now-vs-proposed.html @@ -0,0 +1,223 @@ + + + + + +Aiden · Now & proposed + + + + +
Aiden / UX review
Full audit ↗
+
+

Less setup. More doing.

The same capabilities, with an easier way in.

Visual concepts, based on the source audit. No real settings change.

+
+

+
NowSimplified reconstruction
+
ProposedTry the buttons ↗
+

+
+ +
+

+
+ + + diff --git a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift index 2ac53bc28..80feba4fc 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift @@ -9,7 +9,7 @@ struct AidenDiscoveredAgent: Identifiable, Equatable { enum AidenPairingAlertCopy { static let title = String(localized: "Aiden On The Go") - static let fallbackMessage = String(localized: "Try again from Aiden Agent Remote Access settings.") + static let fallbackMessage = String(localized: "Try again from Aiden Agent → Settings → Aiden On The Go.") } enum AidenDiscoveryIdentity { @@ -574,18 +574,18 @@ struct AidenPairingView: View { VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 8) { Text("Prepare your Mac").font(.largeTitle.bold()) - Text("Aiden Agent remains the server and keeps provider credentials on your Mac.") + Text("Your Mac does the work. Your AI account keys stay on your Mac.") .foregroundStyle(palette.secondary) } - pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Remote Access.") - pairingStep(number: 2, title: "Turn on Remote Access", detail: "Choose Local Network, Tailscale, or both. Tailscale is best when you are away from home.") - pairingStep(number: 3, title: "Create a pairing code", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.") + pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Aiden On The Go.") + pairingStep(number: 2, title: "Connect your phone", detail: "Choose where you’ll use Aiden, then select Connect a device. Review what Aiden will enable.") + pairingStep(number: 3, title: "Scan to finish", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.") VStack(alignment: .leading, spacing: 10) { - Label("Per-device credential", systemImage: "key.fill") - Label("Pinned HTTPS identity", systemImage: "lock.shield.fill") - Label("Revocable from your Mac", systemImage: "checkmark.shield") + Label("Only devices you connect can access Aiden", systemImage: "key.fill") + Label("Encrypted connection to your Mac", systemImage: "lock.shield.fill") + Label("Remove access from your Mac at any time", systemImage: "checkmark.shield") } .font(.subheadline) .foregroundStyle(palette.secondary) @@ -599,7 +599,7 @@ struct AidenPairingView: View { onIntroductionComplete?() step = 2 }) { - Text("Choose How to Connect") + Text("Scan the Code") } .padding(.bottom, AidenMobileOnboardingLayout.actionBottomPadding) } @@ -624,11 +624,12 @@ struct AidenPairingView: View { private var pairingPage: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 12) { - Text("Choose the connection shown in Aiden Agent’s Add Device window.") + Text("Scan the code in Settings → Aiden On The Go on your Mac.") .font(.subheadline) .foregroundStyle(palette.secondary) .fixedSize(horizontal: false, vertical: true) + DisclosureGroup("Other ways to connect") { Picker("Connection method", selection: $selectedPairingMethod) { ForEach(AidenPairingMethod.primary) { method in Text(method.tabTitle).tag(method) @@ -636,6 +637,7 @@ struct AidenPairingView: View { } .pickerStyle(.segmented) .accessibilityHint("Swipe the content below or choose a tab.") + } } .padding(.horizontal, 18) .padding(.top, 12) diff --git a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift index 9051f11e7..059d327a4 100644 --- a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift +++ b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift @@ -527,7 +527,7 @@ final class AidenNativeIntegrationTests: XCTestCase { XCTAssertEqual(AidenPairingAlertCopy.title, "Aiden On The Go") XCTAssertEqual( AidenPairingAlertCopy.fallbackMessage, - "Try again from Aiden Agent Remote Access settings." + "Try again from Aiden Agent → Settings → Aiden On The Go." ) } diff --git a/main-window.html b/main-window.html index e45304cf8..5ebcadef1 100644 --- a/main-window.html +++ b/main-window.html @@ -5,7 +5,7 @@ Aiden Agent diff --git a/main/handlers/aiden-remote.test.ts b/main/handlers/aiden-remote.test.ts index dea69e4a5..9460963b1 100644 --- a/main/handlers/aiden-remote.test.ts +++ b/main/handlers/aiden-remote.test.ts @@ -52,3 +52,15 @@ test("saved endpoint repair is an explicit IPC action", async () => { assert.match(source, /ipcMain\.handle\("remote:moveToAvailablePort"/u); assert.match(source, /service\.moveToAvailablePort\(\)/u); }); + + +test("guided setup IPC binds the acknowledgement to its live document and settings", async () => { + const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8"); + const handler = source.slice(source.indexOf('ipcMain.handle("remote:setupPairing"'), source.indexOf('ipcMain.handle("remote:beginPairing"')); + assert.match(handler, /rendererDocumentOwner/u); + assert.match(handler, /parseAidenRemoteTransport\(transport\)/u); + assert.match(handler, /typeof review.enabled !== "boolean"/u); + assert.match(handler, /parseAidenRemoteConnectionMode\(review.connectionMode\)/u); + assert.match(handler, /service.setupPairing/u); + assert.match(handler, /!owner.isDestroyed\(\)/u); +}); diff --git a/main/handlers/aiden-remote.ts b/main/handlers/aiden-remote.ts index 938a46f3f..4f2fe66fc 100644 --- a/main/handlers/aiden-remote.ts +++ b/main/handlers/aiden-remote.ts @@ -129,6 +129,23 @@ export function registerAidenRemoteHandlers(): void { return settingsSnapshot(); }); + ipcMain.handle("remote:setupPairing", async (event, transport: unknown, expected: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Phone setup requires the active application document.")); + const selectedTransport = parseAidenRemoteTransport(transport); + if (!expected || typeof expected !== "object" || Array.isArray(expected)) throw new Error("Invalid phone setup review."); + const review = expected as Record; + if (typeof review.instanceId !== "string" || review.instanceId.length > 128 + || typeof review.enabled !== "boolean") throw new Error("Invalid phone setup review."); + const connectionMode = parseAidenRemoteConnectionMode(review.connectionMode); + const service = (await getAidenRemoteRuntime()).service; + const pairing = await service.setupPairing(selectedTransport, { + instanceId: review.instanceId, enabled: review.enabled, connectionMode, + }, () => !owner.isDestroyed()); + return { ...pairing.bootstrap, pairingSessionId: pairing.sessionId, + qrPayload: pairing.qrPayload ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport), + manualCode: pairing.manualCode }; + }); + ipcMain.handle("remote:beginPairing", async (_event, transport: unknown) => { const selectedTransport = parseAidenRemoteTransport(transport); const service = (await getAidenRemoteRuntime()).service; diff --git a/main/handlers/chat-first-message-params.ts b/main/handlers/chat-first-message-params.ts new file mode 100644 index 000000000..4d7de5329 --- /dev/null +++ b/main/handlers/chat-first-message-params.ts @@ -0,0 +1,61 @@ +import { parseChatAppend, type ParsedChatAppend } from "./chat-append-params.js"; +import { parseChatCreate } from "./chat-create-params.js"; +import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; + +const KEYS = new Set([ + "draftId", "workspaceId", "providerId", "model", "computerUseEnabled", "title", + "turnId", "message", "skillInvocation", +]); + +export interface ParsedChatFirstMessage extends ParsedChatAppend { + title?: string; + workspaceId: string; + computerUseEnabled: boolean; +} + +/** Project the complete untrusted envelope before retaining it across awaits. */ +export function parseChatFirstMessage(input: unknown): ParsedChatFirstMessage { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Invalid first-message request."); + } + const record = input as Record; + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + if (!KEYS.has(key)) throw new Error("Invalid first-message fields."); + } + // A dedicated UUID namespace prevents a draft from impersonating bot, + // Assistant, scheduled, or imported chat identities. + if (typeof record.draftId !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(record.draftId)) { + throw new Error("Invalid draft identifier."); + } + const create = parseChatCreate({ + title: record.title, + workspaceId: record.workspaceId, + providerId: record.providerId, + model: record.model, + }); + if (create.workspaceId === ASSISTANT_WORKSPACE_ID) { + throw new Error("Assistant chats require the Assistant chat creation path."); + } + if (record.computerUseEnabled !== undefined && typeof record.computerUseEnabled !== "boolean") { + throw new Error("Invalid Computer Use setting."); + } + const append = parseChatAppend(record.draftId, record.message, { + turnId: record.turnId, + providerId: create.providerId, + model: create.model, + autoTitle: true, + skillInvocation: record.skillInvocation, + }); + if (!append.content.trim() && !append.attachments?.length) { + throw new Error("Add a message or attachment before sending."); + } + return { + ...append, + title: create.title, + workspaceId: create.workspaceId, + computerUseEnabled: record.computerUseEnabled === true, + retainedBytes: append.retainedBytes + Buffer.byteLength(create.workspaceId, "utf8") + Buffer.byteLength(create.title ?? "", "utf8") + 64, + }; +} diff --git a/main/handlers/chat.ts b/main/handlers/chat.ts index 99f8abb6c..fee7ca650 100644 --- a/main/handlers/chat.ts +++ b/main/handlers/chat.ts @@ -5,6 +5,7 @@ import { ipcMain, logger } from "../platform.js"; import { startGenerationAndMaybeTitle } from "../services/chat-generation-start.js"; import { isExplicitUserStop, parseChatCancelOrigin } from "../services/chat-cancel.js"; import { chatTitleService } from "../services/chat-title.js"; +import { configStore } from "../services/config-store.js"; import { llmClient } from "../services/llm-client.js"; import { chatGenerationOwner } from "../services/chat-generation-owner.js"; import { isSafeSubagentIdentifier } from "../../renderer/shared/subagent-runs.js"; @@ -46,6 +47,11 @@ export function registerChatGenerationHandlers(): void { }, }), startTitle: (input) => chatTitleService.startForFirstTurn(input), + rememberSelection: (providerId, model) => { + void configStore + .setSettings({ lastProviderId: providerId, lastModel: model }) + .catch(() => undefined); + }, }, id, parsed, diff --git a/main/handlers/chats.test.ts b/main/handlers/chats.test.ts new file mode 100644 index 000000000..3399195d7 --- /dev/null +++ b/main/handlers/chats.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("the todo snapshot handler probes rollout eligibility and never mints journals", () => { + const handlers = readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); + const snapshot = handlers.slice( + handlers.indexOf('ipcMain.handle("chats:todoSnapshot"'), + handlers.indexOf('ipcMain.handle("chats:waitUntilIdle"'), + ); + assert.ok(snapshot.includes('ipcMain.handle("chats:todoSnapshot"')); + assert.match(snapshot, /openChatIfEligible/u); + assert.doesNotMatch(snapshot, /openChat\(/u); + assert.match(snapshot, /unavailableTodoSnapshot\(chatId\)/u); + assert.match(snapshot, /!opened\.session/u); +}); \ No newline at end of file diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts index 197e6dcf6..a20dcd56d 100644 --- a/main/handlers/chats.ts +++ b/main/handlers/chats.ts @@ -31,6 +31,8 @@ import { workspaceOperationRegistry, } from "../services/workspace-operation-registry.js"; import { parseChatAppend } from "./chat-append-params.js"; +import { parseChatFirstMessage } from "./chat-first-message-params.js"; +import { createFirstMessageCommitter } from "../services/chat-first-message-commit.js"; import { appendChatMessageWithReconciliation, isAppendReconciliationRequiredError, @@ -82,6 +84,53 @@ function artifactRecoveryMessage(unresolved: string, recoveredMessage: string): } export function registerChatHistoryHandlers(): void { + const commitFirstMessage = createFirstMessageCommitter({ + store: chatStore, + beginTurn: (chatId, turnId, ownerId) => llmClient.beginChatTurn(chatId, turnId, ownerId), + requiresReconciliation: (ownerId) => llmClient.requiresAppendReconciliation(ownerId), + markReconciliation: (ownerId) => llmClient.markAppendReconciliationRequired(ownerId), + clearReconciliation: (ownerId) => llmClient.clearAppendReconciliationRequired(ownerId), + admitWorkspace: (workspaceId, owner) => { + const mutation = workspaceMutationGate.admit(workspaceId); + try { + const operation = admitRendererOwnedWorkspaceOperation(workspaceOperationRegistry, owner, workspaceId); + const abort = () => operation.cancel(); + mutation.signal.addEventListener("abort", abort, { once: true }); + if (mutation.signal.aborted) abort(); + return { + signal: operation.signal, + cancel: operation.cancel, + release: () => { + mutation.signal.removeEventListener("abort", abort); + operation.release(); + mutation.release(); + }, + }; + } catch (error) { + mutation.release(); + throw error; + } + }, + workspaceExists: async (workspaceId) => Boolean(await configStore.getWorkspace(workspaceId)), + requireComputerUseReady: async (signal) => { + const status = await computerUseStatus.status({ signal }); + if (!status.ready) throw new Error(status.detail); + }, + resolveSkill: (workspaceId, invocationId) => skillRegistry.resolveFresh(workspaceId, invocationId), + }); + ipcMain.handle("chats:createWithFirstMessage", (event, input: unknown) => { + const parsed = parseChatFirstMessage(input); + const owner = rendererDocumentOwner(event, () => new Error("Chats require the active application document.")); + return commitFirstMessage(parsed, owner).then((chat) => { + ipcMain.broadcast("chats:metadata-updated", { + chatId: chat.id, + title: chat.title, + workspaceId: persistedChatWorkspaceId(chat.workspaceId), + updatedAt: chat.updatedAt, + }); + return chatForRenderer(chat); + }); + }); let chatCopyActive = false; let chatExportActive = false; ipcMain.handle("chats:activitySnapshot", () => chatActivityRegistry.snapshot()); @@ -106,11 +155,14 @@ export function registerChatHistoryHandlers(): void { return null; } if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + const opened = await piCompactionSessionStore.openChatIfEligible(chatId, chat); + if (!opened.session) { + // Rollout-ineligible chats have no durable journal to replay, so todo is + // unavailable exactly like a corrupt journal. Never mint a journal here. + return unavailableTodoSnapshot(chatId); + } try { - const snapshot = todoSnapshotForRenderer( - chatId, - await replayTodoState(await piCompactionSessionStore.openChat(chatId, chat)), - ); + const snapshot = todoSnapshotForRenderer(chatId, await replayTodoState(opened.session)); if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); return snapshot; } catch (error) { diff --git a/main/handlers/index.ts b/main/handlers/index.ts index 8f5937515..cd150e862 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -25,6 +25,7 @@ import { registerShortcutHandlers } from "./shortcuts.js"; import { registerTelegramHandlers } from "./telegram.js"; import { registerSubagentHandlers } from "./subagents.js"; import { registerAidenRemoteHandlers } from "./aiden-remote.js"; +import { registerPeerHostHandlers } from "./peer-hosts.js"; import { registerBotHandlers } from "./bots.js"; import { registerDiagnosticHandlers } from "./diagnostics.js"; import { registerBtwHandlers } from "./btw.js"; @@ -64,6 +65,7 @@ export function registerHandlers(): void { registerTelegramHandlers(); registerSubagentHandlers(); registerAidenRemoteHandlers(); + registerPeerHostHandlers(); registerBotHandlers(); registerBtwHandlers(); diff --git a/main/handlers/peer-hosts.ts b/main/handlers/peer-hosts.ts new file mode 100644 index 000000000..f9ce8fa4f --- /dev/null +++ b/main/handlers/peer-hosts.ts @@ -0,0 +1,65 @@ +import { ipcMain } from "../platform.js"; +import { hostIdentifier } from "../../renderer/shared/peer-host.js"; +import { getPeerHostRegistry } from "../services/peer-host-service-main.js"; +import { parsePeerPairing, peerText } from "../services/peer-pairing.js"; +import { + peerOperationRequest, + peerOperationResult, +} from "../services/peer-operation.js"; +import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; + +export function registerPeerHostHandlers(): void { + ipcMain.handle("remote:peersList", () => getPeerHostRegistry().list()); + ipcMain.handle("remote:peersPair", async (event, payload: unknown) => { + const owner = rendererDocumentOwner( + event, + () => new Error("Pairing requires an active application document."), + ); + const pairing = parsePeerPairing(peerText(payload, 8192)); + if (owner.isDestroyed()) + throw new Error("The application document changed."); + const controller = new AbortController(); + const detach = owner.onInvalidated(() => controller.abort()); + try { + return await getPeerHostRegistry().pair(pairing, controller.signal); + } finally { + detach(); + } + }); + ipcMain.handle( + "remote:peersSetEnabled", + async (_event, id: unknown, enabled: unknown) => { + if (typeof enabled !== "boolean") + throw new Error("Invalid connection state."); + await getPeerHostRegistry().setEnabled(hostIdentifier(id), enabled); + }, + ); + ipcMain.handle("remote:peersRemove", async (_event, id: unknown) => { + await getPeerHostRegistry().remove(hostIdentifier(id)); + }); + ipcMain.handle( + "remote:peerOperation", + async (event, id: unknown, operation: unknown) => { + const owner = rendererDocumentOwner( + event, + () => + new Error("Device actions require an active application document."), + ); + const controller = new AbortController(); + const detach = owner.onInvalidated(() => controller.abort()); + try { + if (owner.isDestroyed()) + throw new Error("The application document changed."); + const result = await getPeerHostRegistry().request(hostIdentifier(id), { + ...peerOperationRequest(operation), + signal: controller.signal, + }); + if (owner.isDestroyed()) + throw new Error("The application document changed."); + return peerOperationResult(operation, result); + } finally { + detach(); + } + }, + ); +} diff --git a/main/handlers/providers.ts b/main/handlers/providers.ts index 94e1e1bc6..9dd9ef94d 100644 --- a/main/handlers/providers.ts +++ b/main/handlers/providers.ts @@ -58,8 +58,10 @@ import { normalizeAppearanceConfig, parseAppearanceConfig, } from "../../renderer/shared/appearance.js"; -import { normalizeProviderArtwork } from "../../renderer/shared/provider-artwork.js"; -import { normalizeProviderArtworkInput } from "../services/provider-artwork.js"; +import { + normalizeProviderArtworkInput, + persistableProviderArtwork, +} from "../services/provider-artwork.js"; import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js"; import { isGeminiUsageScope } from "../../renderer/shared/gemini-usage-scope.js"; import { isGeminiTranscriptionModel } from "../../renderer/shared/voice-models.js"; @@ -158,7 +160,7 @@ function parseProvider(value: unknown): StoredProvider { id: asProviderId(p.id), kind, label: asString(p.label, "label"), - artwork: normalizeProviderArtwork(p.artwork), + artwork: persistableProviderArtwork(p.artwork), baseUrl, models, modelMetadata, diff --git a/main/index.ts b/main/index.ts index 239ac1363..69f148d6f 100644 --- a/main/index.ts +++ b/main/index.ts @@ -105,6 +105,8 @@ import { } from "./services/git.js"; import { reconcilePendingManagedWorktreeDeletions } from "./services/managed-worktree-deletion-recovery.js"; import { reconcilePendingChatDeletions } from "./services/chat-deletion-reconciliation.js"; +import { EmptyChatMigrationSnapshotError } from "./services/empty-chat-migration.js"; +import { migrateLegacyEmptyWorkspaceChats } from "./services/empty-chat-migration-main.js"; import { ensureUserDataDir } from "./services/data-store.js"; import { piCompactionSessionStore } from "./services/pi-compaction-session-store.js"; import { @@ -1874,6 +1876,16 @@ if (!ownsSingleInstanceLock) { error, ); } + // One-time legacy cleanup runs after recoverable artifacts and Bot identity + // restoration, but before renderers, schedules, or remote clients can write. + try { + await migrateLegacyEmptyWorkspaceChats(); + } catch (error) { + // Do not admit new writers after an uncertain initial snapshot write: + // otherwise a restart could mistake their new chats for legacy data. + if (error instanceof EmptyChatMigrationSnapshotError) throw error; + logger.warn("chat", "Empty-chat migration is incomplete; it will resume on the next launch.", error); + } const visibleChatIds = new Set( (await chatStore.list()).map((chat) => chat.id), ); diff --git a/main/services/aiden-remote-pairing.test.ts b/main/services/aiden-remote-pairing.test.ts index 464bb5f9e..0d9c86ec6 100644 --- a/main/services/aiden-remote-pairing.test.ts +++ b/main/services/aiden-remote-pairing.test.ts @@ -77,6 +77,15 @@ function exchange( }; } +test("Mac and Linux pairing keep the existing grants without implicit host-wide authority", async () => { + for (const deviceType of ["mac", "linux"] as const) { + const pairing = fixture(); + const opened = pairing.service.begin(endpoint, fingerprint); + const result = await pairing.service.exchange({ ...exchange(opened.bootstrap.secret), deviceType }, "desktop"); + assert.deepEqual(result.capabilities, AIDEN_REMOTE_LEGACY_CAPABILITIES); + } +}); + test("pairing opens for exactly five minutes and consumes its 256-bit secret once", async () => { const pairing = fixture(); const opened = pairing.service.begin(endpoint, fingerprint); diff --git a/main/services/aiden-remote-pairing.ts b/main/services/aiden-remote-pairing.ts index 3d67fcf50..cbe258cf5 100644 --- a/main/services/aiden-remote-pairing.ts +++ b/main/services/aiden-remote-pairing.ts @@ -208,7 +208,7 @@ export function parseAidenRemotePairingExchangeInput( typeof record.secret !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(record.secret) || !bounded(record.deviceName, 80) || - (record.deviceType !== "iphone" && record.deviceType !== "ipad") || + (record.deviceType !== "iphone" && record.deviceType !== "ipad" && record.deviceType !== "mac" && record.deviceType !== "linux") || !bounded(record.clientVersion, 40) || (record.acceptsDisplayName !== undefined && typeof record.acceptsDisplayName !== "boolean") || (record.acceptsBotCapabilities !== undefined && typeof record.acceptsBotCapabilities !== "boolean") diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 858316655..afe8037dd 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -237,6 +237,7 @@ test("OpenAPI freezes every planned route under authenticated Aiden v1 semantics record(schemas.PairingExchangeRequest, "PairingExchangeRequest").properties, "PairingExchangeRequest properties", ); + assert.deepEqual(record(pairingRequestProperties.deviceType, "deviceType").enum, ["iphone", "ipad", "mac", "linux"]); assert.deepEqual(record(pairingRequestProperties.acceptsBotCapabilities, "acceptsBotCapabilities"), { type: "boolean", description: "Explicitly accepts the Bot capability vocabulary and the additive serverCapabilities projection. Bot grants are never issued when this field is absent or false.", diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index 76b1ac183..7ba66bd40 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { createServer, request as httpRequest } from "node:http"; import test from "node:test"; -import { createAidenRemoteRequestHandler } from "./aiden-remote-router.js"; +import { + AIDEN_REMOTE_ROUTE_TEMPLATES, + createAidenRemoteRequestHandler, + remoteRouteTemplate, +} from "./aiden-remote-router.js"; +import type { AidenRemoteRouteLabel } from "./aiden-remote-router.js"; import type { AidenRemoteRetainedBotChatAuthorizationRequest } from "./aiden-remote-chats.js"; import { AIDEN_REMOTE_MAX_JSON_RESPONSE_BYTES, @@ -2351,3 +2357,191 @@ test("unknown routes and query aliases fail without reflecting untrusted input", await app.close(); } }); + +test("request logs carry the HTTP method and a query-free canonical route template", async () => { + const app = await fixture({ capabilities: ["chat:read"] }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + try { + // A matched route failing after resolution still reports the concrete + // method and canonical route template (never the literal chat id). + const denied = await fetch(`${app.base}/chats/chat-1`, { + headers: { "aiden-protocol-version": "1" }, + }); + assert.equal(denied.status, 401); + let entry = app.logs[app.logs.length - 1] as Record; + assert.equal(entry.method, "GET"); + assert.equal(entry.route, "chat"); + assert.equal(entry.routePath, "/chats/:id"); + assert.equal(JSON.stringify(app.logs).includes("chat-1"), false); + + // Query strings are never reflected into the recorded route. + const listed = await fetch(`${app.base}/chats?workspaceId=workspace-1`, { headers }); + assert.equal(listed.status, 200); + entry = app.logs[app.logs.length - 1] as Record; + assert.equal(entry.method, "GET"); + assert.equal(entry.routePath, "/chats"); + assert.equal(JSON.stringify(app.logs).includes("workspaceId"), false); + assert.equal(JSON.stringify(app.logs).includes("?"), false); + + // Unknown routes omit the canonical route and never echo the raw path. + const missing = await fetch(`${app.base}/missing`); + assert.equal(missing.status, 404); + entry = app.logs[app.logs.length - 1] as Record; + assert.equal(entry.method, "GET"); + assert.equal(entry.route, "unknown"); + assert.equal("routePath" in entry, false); + assert.equal(JSON.stringify(app.logs).includes("/missing"), false); + } finally { + await app.close(); + } +}); + +const ROUTE_METHOD_CANDIDATES = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; + +/** + * Instantiate a declared route template with one concrete request path. `:param` + * segments receive tokens that satisfy the matching capture grammars in the + * router (plain ids accept `x1`; `:fileId`, `:attachmentId`, and + * `:avatarRevision` carry required prefixes; the git/scheduled `:action` + * families require one of their fixed action literals). + */ +function concreteRequestPath(template: string): string { + let parameterIndex = 0; + return template + .split("/") + .map((segment) => { + if (!segment.startsWith(":")) return segment; + parameterIndex += 1; + switch (segment) { + case ":fileId": + return `file_${"f".repeat(43)}`; + case ":attachmentId": + return `att_${"a".repeat(43)}`; + case ":avatarRevision": + return `avatar_revision_${"a".repeat(32)}`; + case ":attachmentName": + return `x${parameterIndex}.png`; + case ":action": + return template.includes("/git/") ? "review" : "run"; + default: + return `x${parameterIndex}`; + } + }) + .join("/"); +} + +test("every declared remote route template resolves to its own label and exact routePath", async () => { + const app = await fixture({ + capabilities: ["chat:read", "chat:write", "schedule:read", "schedule:write"], + }); + const failures: string[] = []; + try { + for (const [label, templates] of Object.entries(AIDEN_REMOTE_ROUTE_TEMPLATES) as Array< + [AidenRemoteRouteLabel, readonly string[]] + >) { + if (label === "unknown") continue; + for (const template of templates) { + const path = concreteRequestPath(template); + let resolved = false; + for (const method of ROUTE_METHOD_CANDIDATES) { + const response = await fetch(`${app.base}${path}`, { method }); + await response.arrayBuffer(); + const entry = app.logs[app.logs.length - 1] as + | Record + | undefined; + if (entry?.route === label && entry?.routePath === template) { + assert.equal( + remoteRouteTemplate(label, path), + template, + `${label} ${template} must derive its own canonical route`, + ); + resolved = true; + break; + } + } + if (!resolved) { + failures.push(`${label}: ${template} (instantiated as ${path})`); + } + } + } + assert.deepEqual( + failures, + [], + "a declared template with no matching router route is a mistranscription", + ); + } finally { + await app.close(); + } +}); + +/** Split a template or matcher literal into per-segment route shapes (`:x` for params). */ +function routeShapeSegments(literal: string): string[] { + let body = literal; + if (body.startsWith("^")) body = body.slice(1); + if (body.endsWith("$")) body = body.slice(0, -1); + body = body.replace(/\\/gu, ""); + const segments = body.split("/"); + if (segments[0] === "") segments.shift(); + return segments.map((segment) => (segment.includes("(") || segment.startsWith(":") ? ":x" : segment)); +} + +/** True when every literal segment of the matcher shape matches the template. */ +function matcherCoveredByTemplate(matcher: string[], template: string[]): boolean { + if (matcher.length !== template.length) return false; + return matcher.every((segment, index) => { + const declared = template[index]; + return declared === ":x" || declared === segment; + }); +} + +test("every matcher path pattern in the router source is declared as a route template", () => { + const source = readFileSync( + new URL("./aiden-remote-router.ts", import.meta.url), + "utf8", + ); + // The template table above the handler duplicates these matcher shapes by + // hand; this scan starts at the handler so table literals never satisfy it. + const handler = source.slice( + source.indexOf("export function createAidenRemoteRequestHandler("), + ); + const matcherShapes = new Set(); + const recordShape = (literal: string) => { + const segments = routeShapeSegments(literal); + if (segments.length > 0) matcherShapes.add(segments.join("/")); + }; + // String-literal matchers (`path === "/scheduled-tasks/scripts"` and friends). + // The handler body's only leading-slash string literals are route equality + // checks, so single-segment routes (`/health`, `/chats`) are scanned too. + for (const match of handler.matchAll(/["']((?:[^"'\\]|\\.)*)["']/gu)) { + const literal = match[1] ?? ""; + if (literal.startsWith("/")) { + recordShape(literal); + } + } + // Regex matcher literals bound to `.exec(path)` (the `const XxxMatch` family). + for (const match of handler.matchAll( + /\bconst\s+[A-Za-z0-9_]+Match\s*=\s*\/([^\n]*?)\/u\.exec\(path\);/gu, + )) { + recordShape(match[1] ?? ""); + } + const declaredShapes = Object.values(AIDEN_REMOTE_ROUTE_TEMPLATES) + .flat() + .map((template) => routeShapeSegments(template)); + // No allowlist is needed: the scan is confined to the handler body, where + // every leading-slash literal is a route matcher rather than a header name, + // internal prefix, or `path.includes("//")` guard. + const uncovered = [...matcherShapes].filter( + (shape) => + !declaredShapes.some((declared) => + matcherCoveredByTemplate(shape.split("/"), declared), + ), + ); + assert.deepEqual( + uncovered, + [], + "every router matcher path must have a declared route template for routePath evidence", + ); +}); diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index 03d9815ce..410d6e7fd 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -150,47 +150,15 @@ export interface AidenRemoteRouterDependencies { acceptStrippedBasePath?: boolean; log(entry: { requestId: string; - route: - | "health" - | "pairingManualBootstrap" - | "pairingExchange" - | "server" - | "deviceIdentity" - | "botAccessNotice" - | "bots" - | "bot" - | "botCapabilities" - | "botChatCapabilities" - | "botFavorites" - | "botConversations" - | "botAvatar" - | "botFiles" - | "botFile" - | "workspaces" - | "workspace" - | "workspaceBrowserRoots" - | "workspaceBrowserChildren" - | "workspaceBrowserSelection" - | "workspaceFiles" - | "workspaceFile" - | "workspaceGit" - | "scheduledTasks" - | "memorySettings" - | "usage" - | "speech" - | "chats" - | "chatSummaries" - | "chat" - | "chatMove" - | "chatAttachment" - | "turns" - | "models" - | "stream" - | "streamApproval" - | "streamEvents" - | "streamCancel" - | "approvalRespond" - | "unknown"; + route: AidenRemoteRouteLabel; + /** The request HTTP method (GET/POST/...). */ + method?: string; + /** + * The matched route template (for example `/chats/:id/turns`) without any + * query string. Present only when the request resolved to a known route; + * caller-controlled literal paths are never reflected here. + */ + routePath?: string; status: number; latencyMs: number; deviceIdSuffix?: string; @@ -198,6 +166,144 @@ export interface AidenRemoteRouterDependencies { }): void; } +export type AidenRemoteRouteLabel = + | "health" + | "pairingManualBootstrap" + | "pairingExchange" + | "server" + | "deviceIdentity" + | "botAccessNotice" + | "bots" + | "bot" + | "botCapabilities" + | "botChatCapabilities" + | "botFavorites" + | "botConversations" + | "botAvatar" + | "botFiles" + | "botFile" + | "workspaces" + | "workspace" + | "workspaceBrowserRoots" + | "workspaceBrowserChildren" + | "workspaceBrowserSelection" + | "workspaceFiles" + | "workspaceFile" + | "workspaceGit" + | "scheduledTasks" + | "memorySettings" + | "usage" + | "speech" + | "chats" + | "chatSummaries" + | "chat" + | "chatMove" + | "chatAttachment" + | "turns" + | "models" + | "stream" + | "streamApproval" + | "streamEvents" + | "streamCancel" + | "approvalRespond" + | "unknown"; + +/** Canonical template(s) for every router route label. */ +export const AIDEN_REMOTE_ROUTE_TEMPLATES: Readonly> = { + health: ["/health"], + pairingManualBootstrap: ["/pairing/manual-bootstrap"], + pairingExchange: ["/pairing/exchange"], + server: ["/server"], + deviceIdentity: ["/device/identity"], + botAccessNotice: ["/bot-access-notice", "/bot-access-notice/acknowledgement"], + bots: ["/bots", "/bots/:botId/chats"], + bot: ["/bots/:botId", "/bots/:botId/restore"], + botCapabilities: ["/bot-capabilities", "/bots/:botId/capabilities"], + botChatCapabilities: ["/chats/:chatId/capabilities"], + botFavorites: ["/bot-favorites"], + botConversations: ["/bot-conversations"], + botFiles: ["/bot-conversations/:chatId/files"], + botFile: ["/bot-conversations/:chatId/files/:fileId"], + botAvatar: ["/bots/:botId/avatar", "/bots/:botId/avatar/:avatarRevision"], + workspaces: ["/workspaces"], + workspace: ["/workspaces/:id"], + workspaceBrowserRoots: ["/workspace-browser/roots"], + workspaceBrowserChildren: ["/workspace-browser/children"], + workspaceBrowserSelection: ["/workspace-browser/selections"], + workspaceFiles: ["/workspaces/:id/files"], + workspaceFile: ["/workspaces/:id/files/:fileId"], + workspaceGit: ["/workspaces/:id/git/managed-worktree", "/workspaces/:id/git/:action"], + scheduledTasks: [ + "/scheduled-tasks", + "/scheduled-tasks/preview", + "/scheduled-tasks/scripts", + "/scheduled-tasks/mcp-servers", + "/scheduled-tasks/settings", + "/scheduled-tasks/:id/runs", + "/scheduled-tasks/:id/:action", + "/scheduled-tasks/:id", + ], + memorySettings: ["/memory/settings"], + usage: ["/usage"], + speech: ["/speech", "/speech/transcriptions", "/speech/models/:modelId/download", "/speech/models/:modelId"], + chats: ["/chats"], + chatSummaries: ["/chat-summaries"], + chat: ["/chats/:id"], + chatMove: ["/chats/:id/move"], + chatAttachment: [ + "/chats/:id/attachments", + "/chats/:id/attachments/:attachmentId", + "/chats/:id/attachments/:attachmentName/content", + ], + turns: ["/chats/:id/turns"], + models: ["/models"], + stream: ["/streams/:streamId"], + streamApproval: ["/streams/:streamId/approval"], + streamEvents: ["/streams/:streamId/events"], + streamCancel: ["/streams/:streamId/cancel"], + approvalRespond: ["/approvals/:approvalId/respond"], + unknown: [], +}; + +/** Resolve the canonical route template for a classified route and concrete request path. */ +export function remoteRouteTemplate( + route: AidenRemoteRouteLabel, + requestPath: string, +): string | undefined { + const candidates = AIDEN_REMOTE_ROUTE_TEMPLATES[route]; + if (candidates.length === 0) return undefined; + const requestSegments = requestPath.split("/"); + let best: { template: string; parameterSegments: number } | undefined; + for (const template of candidates) { + const templateSegments = template.split("/"); + if (templateSegments.length !== requestSegments.length) continue; + let parameterSegments = 0; + let matched = true; + for (let index = 0; index < requestSegments.length; index += 1) { + const expected = templateSegments[index]; + if (expected === undefined) { + matched = false; + break; + } + if (expected.startsWith(":")) { + parameterSegments += 1; + continue; + } + if (expected !== requestSegments[index]) { + matched = false; + break; + } + } + if (!matched) continue; + // Prefer the most specific template (fewest parameter segments), so static + // endpoints such as `/scheduled-tasks/scripts` never resolve to a `:id`. + if (!best || parameterSegments < best.parameterSegments) { + best = { template, parameterSegments }; + } + } + return best?.template; +} + function requestId(): string { return `req_${randomBytes(18).toString("base64url")}`; } @@ -873,6 +979,25 @@ export function createAidenRemoteRequestHandler( let route: Parameters[0]["route"] = "unknown"; let deviceIdSuffix: string | undefined; let releaseDeviceAuthorization: (() => void) | undefined; + let requestPath: string | undefined; + const logRequest = ( + status: number, + options: { errorCode?: string } = {}, + ) => { + const routePath = requestPath === undefined + ? undefined + : remoteRouteTemplate(route, requestPath); + dependencies.log({ + requestId: id, + route, + method: request.method, + ...(routePath !== undefined ? { routePath } : {}), + status, + latencyMs: Math.max(0, dependencies.now() - startedAt), + ...(deviceIdSuffix ? { deviceIdSuffix } : {}), + ...(options.errorCode ? { errorCode: options.errorCode } : {}), + }); + }; void (async () => { if (request.headers.origin !== undefined) { throw new AidenRemoteServiceError( @@ -886,6 +1011,7 @@ export function createAidenRemoteRequestHandler( dependencies.acceptStrippedBasePath === true, ); const { path, query } = target; + requestPath = path; const authenticate = async ( _request: IncomingMessage, _devices: Pick, @@ -2169,26 +2295,13 @@ export function createAidenRemoteRequestHandler( })() .finally(() => releaseDeviceAuthorization?.()) .then(() => { - dependencies.log({ - requestId: id, - route, - status: response.statusCode, - latencyMs: Math.max(0, dependencies.now() - startedAt), - ...(deviceIdSuffix ? { deviceIdSuffix } : {}), - }); + logRequest(response.statusCode); }) .catch((error: unknown) => { const safe = asAidenRemoteServiceError(error); if (!response.headersSent) writeError(response, id, safe); else response.destroy(); - dependencies.log({ - requestId: id, - route, - status: safe.status, - latencyMs: Math.max(0, dependencies.now() - startedAt), - ...(deviceIdSuffix ? { deviceIdSuffix } : {}), - errorCode: safe.code, - }); + logRequest(safe.status, { errorCode: safe.code }); }); }; } diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index bbeb14d32..2616e7747 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -193,6 +193,8 @@ function writeRemoteLog(entry: AidenRemoteServiceLogEntry): void { ...(status >= 500 ? { code: "internal-error" as const } : {}), fields: { routeCategory: remoteRouteCategory(details.route), + ...(typeof details.method === "string" ? { method: details.method } : {}), + ...(typeof details.routePath === "string" ? { route: details.routePath } : {}), statusClass: status >= 500 ? "5xx" : status >= 400 ? "4xx" : "2xx", latencyBucket: latencyMs >= 10_000 ? "10s-plus" : latencyMs >= 5_000 ? "5s-plus" : "2s-plus", remoteCode: typeof details.errorCode === "string" ? details.errorCode : null, diff --git a/main/services/aiden-remote-service.test.ts b/main/services/aiden-remote-service.test.ts index 35db37fe2..0c9af1a6a 100644 --- a/main/services/aiden-remote-service.test.ts +++ b/main/services/aiden-remote-service.test.ts @@ -110,6 +110,7 @@ interface FixtureOptions { portCandidates?: readonly number[]; failSaveWhen?: (document: AidenRemoteStateDocument) => boolean; failBonjourStart?: boolean; + failTailscaleDisconnect?: boolean; tailscaleServeStatus?: AidenTailscaleStatus; tailscaleStatusFailureAtCall?: number; enableTailscaleTakeover?: boolean; @@ -196,6 +197,7 @@ async function fixture( } return { installed: true, + httpsAvailable: true, dnsName: "aiden.tailnet.ts.net", ...(options.tailscaleServeStatus ? { serveStatus: options.tailscaleServeStatus } @@ -220,6 +222,7 @@ async function fixture( ) => { tailscale.disconnects += 1; tailscale.disconnectTargets.push(target); + if (options.failTailscaleDisconnect) throw new Error("tailscale route unavailable"); await clearOwnership?.(); }, reconcilePendingOutcome: async () => { @@ -1606,3 +1609,194 @@ test("two paired devices authenticate independently and revoking one leaves the await app.cleanup(); } }); + + +test("guided LAN setup enables access and issues one expiring pairing in one operation", async () => { + const f = await fixture(); + try { + const before = await f.state.snapshot(); + const pairing = await f.service.setupPairing("lan", before); + assert.ok(pairing.qrPayload); + assert.ok(pairing.manualCode); + assert.equal((await f.state.snapshot()).enabled, true); + assert.equal((await f.service.status()).running, true); + assert.equal(f.tailscale.connects, 0); + assert.equal(f.service.pairingStatus()?.state, "awaiting_scan"); + await assert.rejects(f.service.setupPairing("lan", await f.state.snapshot()), /already open/); + assert.equal(f.service.pairingStatus()?.sessionId, pairing.sessionId); + } finally { await f.cleanup(); } +}); + +test("guided setup rejects a stale review before enabling listeners", async () => { + const f = await fixture(); + try { + const before = await f.state.snapshot(); + await f.service.setConnectionMode("both"); + await assert.rejects(f.service.setupPairing("lan", before), /changed/); + assert.equal((await f.state.snapshot()).enabled, false); + assert.equal(f.bonjour.starts, 0); + } finally { await f.cleanup(); } +}); + +test("guided setup checks Tailscale prerequisites without changing access", async () => { + const f = await fixture({ tailscaleInspection: { + connectionStatus: { installed: false }, assessment: { state: "unavailable" }, + } }); + try { + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("tailscale", before), /tailscale_not_installed/); + assert.deepEqual(await f.state.snapshot(), before); + assert.equal(f.tailscale.connects, 0); + assert.equal(f.bonjour.starts, 0); + } finally { await f.cleanup(); } +}); + +test("guided Tailscale setup enables the owned route and returns a sealed code", async () => { + const f = await fixture({ tailscaleAssessment: { state: "owned" } }); + try { + const pairing = await f.service.setupPairing("tailscale", await f.state.snapshot()); + assert.ok(pairing.qrPayload); + assert.equal(f.tailscale.connects, 1); + assert.equal((await f.state.snapshot()).connectionMode, "tailscale"); + assert.ok((await f.state.snapshot()).tailscaleOwnership); + } finally { await f.cleanup(); } +}); + +test("guided setup rolls back newly enabled listeners if its owning window closes", async () => { + let current = true; + const f = await fixture({ afterListenerBound: async () => { current = false; } }); + try { + await assert.rejects(f.service.setupPairing("lan", await f.state.snapshot(), () => current), /cancelled/); + assert.equal((await f.state.snapshot()).enabled, false); + assert.equal((await f.service.status()).running, false); + assert.equal(f.service.pairingStatus(), undefined); + } finally { await f.cleanup(); } +}); + +test("a failed fresh Tailscale pairing restores the original connection mode", async () => { + const f = await fixture({ tailscaleAssessment: { state: "unrelated_conflict" } }); + try { + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("tailscale", before)); + const after = await f.state.snapshot(); + assert.equal(after.enabled, false); + assert.equal(after.connectionMode, before.connectionMode); + assert.equal(after.tailscaleOwnership, undefined); + assert.equal((await f.service.status()).running, false); + } finally { await f.cleanup(); } +}); + +test("a route-cleanup failure still disables fresh local access and restores its mode", async () => { + const f = await fixture({ + tailscaleAssessment: { state: "unrelated_conflict" }, + failTailscaleDisconnect: true, + }); + try { + const before = await f.state.snapshot(); + await assert.rejects( + f.service.setupPairing("tailscale", before), + /Local access was turned off, but the new Tailscale route could not be removed/u, + ); + const after = await f.state.snapshot(); + assert.equal(after.enabled, false); + assert.equal(after.connectionMode, before.connectionMode); + assert.ok(after.tailscaleOwnership); + assert.equal((await f.service.status()).running, false); + assert.equal(f.tailscale.disconnects, 1); + } finally { await f.cleanup(); } +}); + +test("a route-cleanup failure preserves existing local access and reports that outcome", async () => { + const f = await fixture({ + mode: "both", + tailscaleAssessment: { state: "unrelated_conflict" }, + failTailscaleDisconnect: true, + }); + try { + await f.service.setEnabled(true); + const before = await f.state.snapshot(); + await assert.rejects( + f.service.setupPairing("tailscale", before), + /Existing local access stayed on/u, + ); + const after = await f.state.snapshot(); + assert.equal(after.enabled, true); + assert.equal(after.connectionMode, "both"); + assert.ok(after.tailscaleOwnership); + assert.equal((await f.service.status()).running, true); + assert.equal(f.tailscale.disconnects, 1); + } finally { await f.cleanup(); } +}); + +test("simultaneous guided setup cannot issue competing pairing sessions", async () => { + let release!: () => void; + let bound!: () => void; + const reached = new Promise((resolve) => { bound = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + const f = await fixture({ afterListenerBound: async () => { bound(); await gate; } }); + try { + const before = await f.state.snapshot(); + const first = f.service.setupPairing("lan", before); + await within(reached); + await assert.rejects(f.service.setupPairing("lan", before), /already in progress/); + release(); + assert.ok((await first).qrPayload); + } finally { release(); await f.cleanup(); } +}); + + +test("failed guided setup preserves an already enabled local connection", async () => { + const f = await fixture({ tailscaleAssessment: { state: "unrelated_conflict" } }); + try { + await f.service.setEnabled(true); + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("tailscale", before)); + assert.equal((await f.state.snapshot()).enabled, true); + assert.equal((await f.state.snapshot()).connectionMode, "lan"); + assert.equal((await f.service.status()).running, true); + assert.equal((await f.state.snapshot()).tailscaleOwnership, undefined); + } finally { await f.cleanup(); } +}); + + +test("guided setup preserves an uncertain external route for explicit reconciliation", async () => { + const f = await fixture({ initial: (state) => { + state.tailscalePendingOutcome = { + operation: "connect", target: `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`, + beforeFingerprint: "a".repeat(64), preservedFingerprint: "b".repeat(64), + normalizeListenerScaffolding: false, createdAt: 1_000, + }; + } }); + try { + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("lan", before), /reconciliation_required/); + assert.deepEqual(await f.state.snapshot(), before); + assert.equal(f.tailscale.disconnects, 0); + assert.equal(f.tailscale.reconciles, 0); + } finally { await f.cleanup(); } +}); + + +test("failed pairing removes only the new route when existing access stays enabled", async () => { + const f = await fixture({ mode: "both", tailscaleAssessment: { state: "unrelated_conflict" } }); + try { + await f.service.setEnabled(true); + await assert.rejects(f.service.setupPairing("tailscale", await f.state.snapshot())); + assert.equal((await f.state.snapshot()).enabled, true); + assert.equal((await f.state.snapshot()).connectionMode, "both"); + assert.equal((await f.state.snapshot()).tailscaleOwnership, undefined); + assert.equal(f.tailscale.disconnects, 1); + } finally { await f.cleanup(); } +}); + +test("guided setup never changes the mode of a saved private connection", async () => { + const f = await fixture({ mode: "tailscale", tailscaleAssessment: { state: "owned" } }); + try { + await f.service.setEnabled(true); + await f.service.connectTailscale(); + const before = await f.state.snapshot(); + await assert.rejects(f.service.setupPairing("lan", before), /saved connection/); + assert.deepEqual(await f.state.snapshot(), before); + assert.equal(f.tailscale.disconnects, 0); + } finally { await f.cleanup(); } +}); diff --git a/main/services/aiden-remote-service.ts b/main/services/aiden-remote-service.ts index 671126c54..2468e67ab 100644 --- a/main/services/aiden-remote-service.ts +++ b/main/services/aiden-remote-service.ts @@ -386,6 +386,7 @@ export class AidenRemoteService { private activeState: AidenRemoteStateDocument | null = null; private lastError: string | undefined; private lastErrorCode: "remote_port_in_use" | undefined; + private setupInFlight = false; private operationTail: Promise = Promise.resolve(); private settleRemoteApi: (() => Promise) | undefined; private readonly now: () => number; @@ -443,6 +444,8 @@ export class AidenRemoteService { details: { requestId: entry.requestId, route: entry.route, + method: entry.method, + routePath: entry.routePath, status: entry.status, latencyMs: entry.latencyMs, deviceIdSuffix: entry.deviceIdSuffix, @@ -692,34 +695,36 @@ export class AidenRemoteService { } async setEnabled(enabled: boolean): Promise { - await this.serialized(async () => { - const current = await this.options.state.snapshot(); - if (enabled) { - if (!current.enabled || !this.activeState) { - await this.startConfigured({ ...current, enabled: true }); - try { - await this.options.state.setEnabled(true); - } catch (error) { - await this.stopListeners(); - throw error; - } - } - return; - } - let disconnectError: unknown; - if (current.tailscaleOwnership) { + return this.serialized(() => this.setEnabledInternal(enabled)); + } + + private async setEnabledInternal(enabled: boolean): Promise { + const current = await this.options.state.snapshot(); + if (enabled) { + if (!current.enabled || !this.activeState) { + await this.startConfigured({ ...current, enabled: true }); try { - await this.disconnectTailscaleInternal(current); + await this.options.state.setEnabled(true); } catch (error) { - disconnectError = error; + await this.stopListeners(); + throw error; } } - await this.stopListeners(); - await this.options.state.setEnabled(false); - this.lastError = undefined; - this.lastErrorCode = undefined; - if (disconnectError) throw disconnectError; - }); + return; + } + let disconnectError: unknown; + if (current.tailscaleOwnership) { + try { + await this.disconnectTailscaleInternal(current); + } catch (error) { + disconnectError = error; + } + } + await this.stopListeners(); + await this.options.state.setEnabled(false); + this.lastError = undefined; + this.lastErrorCode = undefined; + if (disconnectError) throw disconnectError; } /** @@ -755,39 +760,41 @@ export class AidenRemoteService { } async setConnectionMode(connectionMode: AidenRemoteConnectionMode): Promise { - await this.serialized(async () => { - const current = await this.options.state.snapshot(); - if (current.tailscaleOwnership && connectionMode === "lan") { - await this.disconnectTailscaleInternal(current); + return this.serialized(() => this.setConnectionModeInternal(connectionMode)); + } + + private async setConnectionModeInternal(connectionMode: AidenRemoteConnectionMode): Promise { + const current = await this.options.state.snapshot(); + if (current.tailscaleOwnership && connectionMode === "lan") { + await this.disconnectTailscaleInternal(current); + } + await this.options.state.setConnectionMode(connectionMode); + if (current.enabled) { + if (!this.activeState || !this.lanServer || !this.tailscaleServer) { + await this.startConfigured({ ...current, connectionMode }); + return; } - await this.options.state.setConnectionMode(connectionMode); - if (current.enabled) { - if (!this.activeState || !this.lanServer || !this.tailscaleServer) { - await this.startConfigured({ ...current, connectionMode }); - return; - } - const previouslyAdvertised = current.connectionMode === "lan" - || current.connectionMode === "both"; - const shouldAdvertise = connectionMode === "lan" || connectionMode === "both"; - this.activeState.connectionMode = connectionMode; - if (connectionMode === "tailscale") this.destroyConnections(this.lanConnections); - if (connectionMode === "lan") this.destroyConnections(this.tailscaleConnections); - if (previouslyAdvertised && !shouldAdvertise) { - this.options.bonjour.stop(); - } else if (!previouslyAdvertised && shouldAdvertise) { - try { - await this.publishBonjour({ - instanceId: this.activeState.instanceId, - displayName: this.activeState.displayName, - port: this.activeState.lanPort, - }); - } catch (error) { - await this.stopListeners(); - throw error; - } + const previouslyAdvertised = current.connectionMode === "lan" + || current.connectionMode === "both"; + const shouldAdvertise = connectionMode === "lan" || connectionMode === "both"; + this.activeState.connectionMode = connectionMode; + if (connectionMode === "tailscale") this.destroyConnections(this.lanConnections); + if (connectionMode === "lan") this.destroyConnections(this.tailscaleConnections); + if (previouslyAdvertised && !shouldAdvertise) { + this.options.bonjour.stop(); + } else if (!previouslyAdvertised && shouldAdvertise) { + try { + await this.publishBonjour({ + instanceId: this.activeState.instanceId, + displayName: this.activeState.displayName, + port: this.activeState.lanPort, + }); + } catch (error) { + await this.stopListeners(); + throw error; } } - }); + } } private destroyConnections(connections: Set): void { @@ -823,32 +830,34 @@ export class AidenRemoteService { } async connectTailscale(): Promise { - await this.serialized(async () => { - const state = await this.options.state.snapshot(); - if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); - if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { - throw new Error("Enable Aiden Remote with Tailscale access before connecting Serve."); - } - if (!this.tailscaleServer) throw new Error("Aiden Remote loopback service is not running."); - const target = this.loopbackTarget(state); - let ownership = state.tailscaleOwnership; - if (ownership && ownership.target !== target) { - // Pre-acceptance builds persisted an origin-only target that cannot - // route the canonical API after Tailscale strips --set-path. Remove - // only that exact owned route before creating the corrected one. - await this.options.tailscale.disconnect( - ownership.target, - ownership, - () => this.options.state.commitTailscaleOutcome(undefined), - ); - ownership = undefined; - } - await this.options.tailscale.connect( - target, + return this.serialized(() => this.connectTailscaleInternal()); + } + + private async connectTailscaleInternal(): Promise { + const state = await this.options.state.snapshot(); + if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { + throw new Error("Enable Aiden Remote with Tailscale access before connecting Serve."); + } + if (!this.tailscaleServer) throw new Error("Aiden Remote loopback service is not running."); + const target = this.loopbackTarget(state); + let ownership = state.tailscaleOwnership; + if (ownership && ownership.target !== target) { + // Pre-acceptance builds persisted an origin-only target that cannot + // route the canonical API after Tailscale strips --set-path. Remove + // only that exact owned route before creating the corrected one. + await this.options.tailscale.disconnect( + ownership.target, ownership, - (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), + () => this.options.state.commitTailscaleOutcome(undefined), ); - }); + ownership = undefined; + } + await this.options.tailscale.connect( + target, + ownership, + (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), + ); } async reviewTailscaleTakeover(): Promise { @@ -916,79 +925,192 @@ export class AidenRemoteService { } async beginPairing(transport: "lan" | "tailscale"): Promise { - return this.serialized(async () => { - const state = await this.options.state.snapshot(); - if (!state.enabled || !this.pairing || !this.tlsIdentity) { - throw new Error("Enable Aiden Remote before pairing a device."); + return this.serialized(() => this.beginPairingInternal(transport)); + } + + private async beginPairingInternal(transport: "lan" | "tailscale"): Promise { + const state = await this.options.state.snapshot(); + if (!state.enabled || !this.pairing || !this.tlsIdentity) { + throw new Error("Enable Aiden Remote before pairing a device."); + } + let endpoint: string; + let serverSpkiSha256: string; + if (transport === "lan") { + if ( + !this.lanServer + || (state.connectionMode !== "lan" && state.connectionMode !== "both") + ) throw new Error("Local-network access is not enabled."); + endpoint = `https://${localDnsName(this.hostname)}:${state.lanPort}${AIDEN_REMOTE_BASE_PATH}`; + serverSpkiSha256 = this.tlsIdentity.serverSpkiSha256; + } else { + if (state.tailscalePendingOutcome) { + throw new Error("Verify the previous Tailscale route update before pairing."); } - let endpoint: string; - let serverSpkiSha256: string; - if (transport === "lan") { - if ( - !this.lanServer - || (state.connectionMode !== "lan" && state.connectionMode !== "both") - ) throw new Error("Local-network access is not enabled."); - endpoint = `https://${localDnsName(this.hostname)}:${state.lanPort}${AIDEN_REMOTE_BASE_PATH}`; - serverSpkiSha256 = this.tlsIdentity.serverSpkiSha256; + if ( + !state.tailscaleOwnership + || !this.tailscaleServer + || (state.connectionMode !== "tailscale" && state.connectionMode !== "both") + ) { + throw new Error("Connect the Aiden Tailscale Serve route before pairing."); + } + const inspection = this.options.tailscale.inspectRoute + ? await this.options.tailscale.inspectRoute( + this.loopbackTarget(state), + state.tailscaleOwnership, + ) + : undefined; + const status = inspection?.connectionStatus ?? await this.options.tailscale.status(); + if (inspection || this.options.tailscale.assessRoute) { + const assessment = inspection?.assessment ?? await this.options.tailscale.assessRoute!( + this.loopbackTarget(state), + state.tailscaleOwnership, + ); + if (assessment.state !== "owned" || assessment.errorCode) { + throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } } else { - if (state.tailscalePendingOutcome) { - throw new Error("Verify the previous Tailscale route update before pairing."); + let connected = false; + try { + connected = status.serveStatus !== undefined + && planAidenTailscaleConnect( + status.serveStatus, + this.loopbackTarget(state), + state.tailscaleOwnership, + status.httpsAvailable, + ).action === "noop"; + } catch { + connected = false; } - if ( - !state.tailscaleOwnership - || !this.tailscaleServer - || (state.connectionMode !== "tailscale" && state.connectionMode !== "both") - ) { - throw new Error("Connect the Aiden Tailscale Serve route before pairing."); + if (!connected) { + throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } + } + if (!status.dnsName) throw new Error("Tailscale does not report a stable DNS name."); + endpoint = `https://${status.dnsName}${AIDEN_REMOTE_BASE_PATH}`; + serverSpkiSha256 = await ( + this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256 + )(status.dnsName, 443); + } + const pairing = this.pairing.begin(endpoint, serverSpkiSha256); + try { + const qrPayload = this.pairingQrPayload(pairing.bootstrap, transport); + this.pairing.sealManualPayload(pairing.sessionId, qrPayload); + return { ...pairing, qrPayload }; + } catch (error) { + this.pairing.close(pairing.sessionId); + throw error; + } + } + + /** One acknowledged desktop action; shares the service mutation lane with advanced controls. */ + async setupPairing( + transport: "lan" | "tailscale", + expected: { instanceId: string; enabled: boolean; connectionMode: AidenRemoteConnectionMode }, + isCurrent: () => boolean = () => true, + ): Promise { + if (this.setupInFlight) throw new Error("Phone setup is already in progress."); + this.setupInFlight = true; + try { + return await this.serialized(async () => { + const current = await this.options.state.snapshot(); + const checkOwner = () => { + if (!isCurrent()) throw new Error("Phone setup was cancelled. Return to Settings to try again."); + }; + checkOwner(); + if (current.instanceId !== expected.instanceId || current.enabled !== expected.enabled + || current.connectionMode !== expected.connectionMode) { + throw new Error("Phone access changed. Review the setup again before continuing."); } - const inspection = this.options.tailscale.inspectRoute - ? await this.options.tailscale.inspectRoute( - this.loopbackTarget(state), - state.tailscaleOwnership, - ) - : undefined; - const status = inspection?.connectionStatus ?? await this.options.tailscale.status(); - if (inspection || this.options.tailscale.assessRoute) { - const assessment = inspection?.assessment ?? await this.options.tailscale.assessRoute!( - this.loopbackTarget(state), - state.tailscaleOwnership, + if (current.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + const mode = current.connectionMode === "both" ? "both" : transport; + // Changing a saved transport can strand existing devices. Keep that an + // explicit advanced operation, rather than silently choosing both. + if (mode !== current.connectionMode && (current.devices.length || current.tailscaleOwnership)) { + throw new Error("This Mac already has a saved connection. Use its current method, or review Connection settings before changing it."); + } + if (["finishing", "awaiting_scan"].includes(this.pairingStatus()?.state ?? "")) { + throw new Error("A phone connection is already open. Finish or close it before adding another device."); + } + if (transport === "tailscale") { + const inspection = await this.options.tailscale.inspectRoute?.( + this.loopbackTarget(current), current.tailscaleOwnership, ); - if (assessment.state !== "owned" || assessment.errorCode) { - throw new Error("The Tailscale route is not privately connected to this Aiden profile."); - } - } else { - let connected = false; - try { - connected = status.serveStatus !== undefined - && planAidenTailscaleConnect( - status.serveStatus, - this.loopbackTarget(state), - state.tailscaleOwnership, - status.httpsAvailable, - ).action === "noop"; - } catch { - connected = false; + const connection = inspection?.connectionStatus ?? await this.options.tailscale.status(); + checkOwner(); + if (!connection.installed) throw new Error("tailscale_not_installed"); + if (connection.errorCode) throw new Error(`tailscale_${connection.errorCode}`); + if (!connection.dnsName) throw new Error("tailscale_not_connected"); + if (connection.httpsAvailable !== true) throw new Error("tailscale_https_unavailable"); + if (inspection && !["available", "owned"].includes(inspection.assessment.state)) { + throw new Error("This phone connection is already in use or needs review. Open Connection settings to resolve it; nothing was replaced."); } - if (!connected) { - throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } + let pairing: AidenRemoteDesktopPairing | undefined; + try { + if (mode !== current.connectionMode) await this.setConnectionModeInternal(mode); + checkOwner(); + await this.setEnabledInternal(true); + checkOwner(); + if (transport === "tailscale") await this.connectTailscaleInternal(); + checkOwner(); + pairing = await this.beginPairingInternal(transport); + checkOwner(); + return pairing; + } catch (error) { + if (pairing) this.pairing?.close(pairing.sessionId); + const after = await this.options.state.snapshot(); + // Keep uncertain external results available for explicit reconciliation. + // Roll back only the access introduced by this acknowledged attempt. + if (!after.tailscalePendingOutcome) { + let routeCleanupFailed = false; + let localCleanupFailed = false; + if (!current.tailscaleOwnership && after.tailscaleOwnership) { + try { + await this.disconnectTailscaleInternal(after); + } catch { + routeCleanupFailed = true; + } + } + if (!current.enabled) { + try { + await this.stopListeners(); + } catch { + localCleanupFailed = true; + } + try { + await this.options.state.setEnabled(false); + } catch { + localCleanupFailed = true; + } + if (mode !== current.connectionMode) { + try { + await this.options.state.setConnectionMode(current.connectionMode); + } catch { + localCleanupFailed = true; + } + } + } else if (mode !== current.connectionMode) { + try { + await this.setConnectionModeInternal(current.connectionMode); + } catch { + localCleanupFailed = true; + } + } + if (localCleanupFailed) { + throw new Error("Phone setup did not finish and cleanup could not be confirmed. Check Connection settings before trying again."); + } + if (routeCleanupFailed) { + throw new Error(current.enabled + ? "Phone setup did not finish, and the new Tailscale route could not be removed. Existing local access stayed on; check Connection settings before trying again." + : "Phone setup did not finish. Local access was turned off, but the new Tailscale route could not be removed. Check Connection settings before trying again."); + } } + throw error; } - if (!status.dnsName) throw new Error("Tailscale does not report a stable DNS name."); - endpoint = `https://${status.dnsName}${AIDEN_REMOTE_BASE_PATH}`; - serverSpkiSha256 = await ( - this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256 - )(status.dnsName, 443); - } - const pairing = this.pairing.begin(endpoint, serverSpkiSha256); - try { - const qrPayload = this.pairingQrPayload(pairing.bootstrap, transport); - this.pairing.sealManualPayload(pairing.sessionId, qrPayload); - return { ...pairing, qrPayload }; - } catch (error) { - this.pairing.close(pairing.sessionId); - throw error; - } - }); + }); + } finally { + this.setupInFlight = false; + } } async closePairing(sessionId: string): Promise { diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts index b9cc24b2c..ec3ebe04d 100644 --- a/main/services/aiden-remote-state.test.ts +++ b/main/services/aiden-remote-state.test.ts @@ -55,6 +55,18 @@ function fixture(initial?: unknown) { }; } +test("desktop device records round-trip without changing existing mobile grants", async () => { + const state = fixture(); + await state.registry.initialize(); + for (const type of ["mac", "linux"] as const) { + await state.registry.issueDevice({ name: type, type, clientVersion: "1" }); + } + const restored = fixture(state.stored()); + await restored.registry.initialize(); + assert.deepEqual((await restored.registry.listDevices()).map((device) => device.type), ["mac", "linux"]); + assert.ok(state.stored().devices.every((device) => !device.acceptsBotCapabilities)); +}); + test("remote device credentials persist only digests and authenticate with capability state", async () => { const state = fixture(); await state.registry.initialize(); diff --git a/main/services/aiden-remote-state.ts b/main/services/aiden-remote-state.ts index 8cdcadd42..a107b8a6e 100644 --- a/main/services/aiden-remote-state.ts +++ b/main/services/aiden-remote-state.ts @@ -27,7 +27,7 @@ const LAST_SEEN_WRITE_INTERVAL_MS = 5 * 60_000; export const MAX_AIDEN_REMOTE_DISPLAY_NAME_CHARACTERS = 80; const FALLBACK_AIDEN_REMOTE_DISPLAY_NAME = "Aiden Agent"; -export type AidenRemoteDeviceType = "iphone" | "ipad"; +export type AidenRemoteDeviceType = "iphone" | "ipad" | "mac" | "linux"; export type AidenRemoteConnectionMode = "lan" | "tailscale" | "both"; interface StoredAidenRemoteDevice { @@ -227,7 +227,7 @@ function parseDevice(value: unknown): StoredAidenRemoteDevice | null { if ( !boundedString(record.id, 128) || !boundedString(record.name, 80) || - (record.type !== "iphone" && record.type !== "ipad") || + (record.type !== "iphone" && record.type !== "ipad" && record.type !== "mac" && record.type !== "linux") || !boundedString(record.clientVersion, 40) || !digestString(record.lookupDigest) || !digestString(record.credentialSalt) || @@ -659,7 +659,7 @@ export class AidenRemoteStateRegistry { }): Promise { if ( !boundedString(input.name, 80) || - (input.type !== "iphone" && input.type !== "ipad") || + (input.type !== "iphone" && input.type !== "ipad" && input.type !== "mac" && input.type !== "linux") || !boundedString(input.clientVersion, 40) || (input.acceptsBotCapabilities !== undefined && typeof input.acceptsBotCapabilities !== "boolean") diff --git a/main/services/bot-skill-content-watcher.test.ts b/main/services/bot-skill-content-watcher.test.ts index 4bf8503f8..6262b6983 100644 --- a/main/services/bot-skill-content-watcher.test.ts +++ b/main/services/bot-skill-content-watcher.test.ts @@ -24,7 +24,7 @@ test("editing an admitted discovered skill aborts the live Bot inventory lease", const aborted = new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("Skill watcher did not invalidate live Bot authority.")), - 1_000, + 5_000, ); lease.signal.addEventListener("abort", () => { clearTimeout(timeout); diff --git a/main/services/chat-first-message-commit.test.ts b/main/services/chat-first-message-commit.test.ts new file mode 100644 index 000000000..026c3af86 --- /dev/null +++ b/main/services/chat-first-message-commit.test.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { parseChatFirstMessage } from "../handlers/chat-first-message-params.js"; +import { createChatStore, type ChatStoreDurability } from "./chat-store-core.js"; +import { createFirstMessageCommitter } from "./chat-first-message-commit.js"; +import { ChatTurnAdmission, type ChatTurnAdmissionOptions } from "./chat-turn-admission.js"; +import { WorkspaceOperationRegistry, admitOwnedWorkspaceOperation } from "./workspace-operation-registry.js"; +import { isAppendReconciliationRequiredError } from "./chat-append-commit.js"; +import { chatForRenderer } from "./visible-chat-projection.js"; +import type { RegisteredSkill } from "./skill-registry.js"; + +let nextTurn = 0; +function request(overrides: Record = {}) { + return { + draftId: randomUUID(), workspaceId: "workspace", providerId: "openai", model: "test-model", + turnId: `turn-${++nextTurn}`, message: { role: "user", content: "Investigate the failing build" }, + ...overrides, + }; +} + +function documentOwner() { + let destroyed = false; + const listeners = new Set<() => void>(); + return { + documentId: randomUUID(), + isDestroyed: () => destroyed, + onInvalidated: (listener: () => void) => { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + invalidate: () => { + destroyed = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +async function fixture(t: test.TestContext, durability: ChatStoreDurability = {}, admission: ChatTurnAdmissionOptions = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-first-message-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const store = createChatStore(async () => directory, undefined, durability); + const owner = documentOwner(); + t.after(() => owner.invalidate()); + const turns = new ChatTurnAdmission(admission); + const workspaces = new WorkspaceOperationRegistry(); + const deps: Parameters[0] = { + store, + beginTurn: (chatId, turnId, ownerId) => turns.tryBegin(chatId, turnId, ownerId, false), + requiresReconciliation: (ownerId) => turns.requiresAppendReconciliation(ownerId), + markReconciliation: (ownerId) => turns.markAppendReconciliationRequired(ownerId), + clearReconciliation: (ownerId) => turns.clearAppendReconciliationRequired(ownerId), + admitWorkspace: (workspaceId, document) => admitOwnedWorkspaceOperation(workspaces, document, workspaceId), + workspaceExists: async () => true, + requireComputerUseReady: async () => {}, + resolveSkill: async () => { throw new Error("Skill is unavailable"); }, + }; + return { directory, store, owner, turns, workspaces, deps }; +} + +test("first-message parser rejects empty, forged, and oversized requests", () => { + for (const value of [ + request({ draftId: "../chat" }), + request({ message: { role: "assistant", content: "forged" } }), + request({ message: { role: "user", content: " " } }), + request({ message: { role: "user", content: "x".repeat(1_048_577) } }), + request({ computerUseEnabled: "true" }), + request({ botId: "bot" }), + request({ workspaceId: "" }), + ]) assert.throws(() => parseChatFirstMessage(value)); + const original = request(); + const parsed = parseChatFirstMessage(original); + original.message.content = "changed after admission"; + assert.equal(parsed.content, "Investigate the failing build"); +}); + +test("first message publishes a nonempty chat, title, settings, and private durable receipt", async (t) => { + const f = await fixture(t); + const parsed = parseChatFirstMessage(request({ computerUseEnabled: true })); + assert.deepEqual(await f.store.list(), []); + const chat = await createFirstMessageCommitter(f.deps)(parsed, f.owner); + assert.equal(chat.id, parsed.chatId); + assert.equal(chat.title, parsed.content); + assert.equal(chat.messages.length, 1); + assert.equal(chat.messages[0]?.role, "user"); + assert.equal(chat.computerUseEnabled, true); + assert.equal((await f.store.list())[0]?.id, chat.id); + assert.equal(f.turns.owns(chat.id, parsed.turnId, f.owner.documentId), true); + assert.equal(chatForRenderer(chat)?.firstMessageCommit, undefined); + const restart = createChatStore(async () => f.directory); + assert.deepEqual((await restart.get(chat.id))?.firstMessageCommit, chat.firstMessageCommit); +}); + +test("attachment-only first send retains attachments and an explicit draft title", async (t) => { + const f = await fixture(t); + const attachment = { id: "text-1", name: "notes.txt", kind: "text", mimeType: "text/plain", size: 5, text: "notes" }; + const parsed = parseChatFirstMessage(request({ + title: "My investigation", + message: { role: "user", content: "", attachments: [attachment] }, + })); + const chat = await createFirstMessageCommitter(f.deps)(parsed, f.owner); + assert.equal(chat.title, "My investigation"); + assert.deepEqual(chat.messages[0]?.attachments, [attachment]); +}); + +test("first send prepares skill instructions for the exact durable user message", async (t) => { + const f = await fixture(t); + const skill: RegisteredSkill = { + stableId: "configured:review", invocationId: `sk1_${"a".repeat(43)}`, + toolKey: "skill_review", name: "Review", description: "Review changes", + instructions: "Inspect the diff carefully.", source: "configured", enabled: true, available: true, + }; + const parsed = parseChatFirstMessage(request({ + skillInvocation: { version: 1, invocationId: skill.invocationId, displayName: "Review", source: "configured" }, + })); + const chat = await createFirstMessageCommitter({ ...f.deps, resolveSkill: async () => skill })(parsed, f.owner); + assert.deepEqual(chat.messages[0]?.skill, { version: 1, name: "Review", source: "configured" }); + let preparedId: string | undefined; + assert.equal(f.turns.handoff(chat.id, parsed.turnId, f.owner.documentId, (prepared) => { preparedId = prepared?.userMessageId; }), true); + assert.equal(preparedId, chat.messages[0]?.id); +}); + +test("duplicate first sends share one operation and mismatched identities cannot overwrite", async (t) => { + const f = await fixture(t); + const commit = createFirstMessageCommitter(f.deps); + const input = request(); + const parsed = parseChatFirstMessage(input); + const first = commit(parsed, f.owner); + assert.equal(commit(parseChatFirstMessage(input), f.owner), first); + assert.throws(() => commit(parseChatFirstMessage({ ...input, message: { role: "user", content: "different" } }), f.owner)); + const chat = await first; + f.turns.releaseMatching(chat.id, parsed.turnId, f.owner.documentId); + const replay = await commit(parsed, f.owner); + assert.equal(replay.messages.length, 1); + assert.equal(replay.messages[0]?.id, chat.messages[0]?.id); + assert.equal(f.turns.owns(chat.id, parsed.turnId, f.owner.documentId), false, "replay must not authorize another generation"); + await assert.rejects(commit(parseChatFirstMessage({ ...input, turnId: "turn-reused" }), f.owner), /already been used/u); + assert.equal((await f.store.get(chat.id))?.messages.length, 1); +}); + +test("a regular or Bot chat identity cannot be claimed by a first-message retry", async (t) => { + const f = await fixture(t); + const input = request(); + await f.store.create({ id: input.draftId, botId: "bot", workspaceId: "managed" }); + await assert.rejects(createFirstMessageCommitter(f.deps)(parseChatFirstMessage(input), f.owner), /already been used/u); + assert.equal((await f.store.get(input.draftId))?.botId, "bot"); + assert.equal((await f.store.get(input.draftId))?.messages.length, 0); +}); + +test("completed first-send receipts retain quota until handoff or abandonment", async (t) => { + const f = await fixture(t, {}, { maxAppendTurns: 1 }); + const commit = createFirstMessageCommitter(f.deps); + const first = parseChatFirstMessage(request()); + const next = parseChatFirstMessage(request()); + const receipt = await commit(first, f.owner); + assert.equal(await commit(first, f.owner), receipt, "same-lease retry reuses the bounded receipt"); + assert.throws(() => commit(next, f.owner), /Too many messages/u); + assert.equal(f.turns.owns(next.chatId, next.turnId, f.owner.documentId), false); + assert.equal(f.turns.handoff(first.chatId, first.turnId, f.owner.documentId, () => {}), true); + const secondReceipt = await commit(next, f.owner); + assert.equal(secondReceipt.messages.length, 1); + const third = parseChatFirstMessage(request()); + assert.throws(() => commit(third, f.owner), /Too many messages/u); + f.turns.releaseMatching(next.chatId, next.turnId, f.owner.documentId); + assert.equal((await commit(third, f.owner)).messages.length, 1); +}); + +test("corrupt and mismatched UUID payload collisions are preserved byte-for-byte", async (t) => { + const f = await fixture(t); + const commit = createFirstMessageCommitter(f.deps); + for (const contents of ["{interrupted payload", JSON.stringify({ id: "another-id", title: "Keep me", messages: [], createdAt: 1, updatedAt: 1 })]) { + const input = parseChatFirstMessage(request()); + const file = path.join(f.directory, `${input.chatId}.json`); + await fs.writeFile(file, contents, "utf8"); + await assert.rejects(commit(input, f.owner), /unreadable existing chat/u); + assert.equal(await fs.readFile(file, "utf8"), contents); + assert.equal(f.turns.owns(input.chatId, input.turnId, f.owner.documentId), false); + } + assert.deepEqual(await f.store.list(), []); +}); + +test("missing workspace, unavailable Computer Use, and skill rejection leave no chat or lease", async (t) => { + const f = await fixture(t); + for (const kind of ["workspace", "computer", "skill"] as const) { + const parsed = parseChatFirstMessage(request({ computerUseEnabled: kind === "computer" })); + if (kind === "skill") parsed.skillReference = { version: 1, invocationId: "configured:test", displayName: "test", source: "configured" }; + const commit = createFirstMessageCommitter({ + ...f.deps, + workspaceExists: async () => kind !== "workspace", + requireComputerUseReady: async () => { throw new Error("Computer Use is unavailable"); }, + }); + await assert.rejects(commit(parsed, f.owner)); + assert.equal(f.turns.owns(parsed.chatId, parsed.turnId, f.owner.documentId), false); + } + assert.deepEqual(await f.store.list(), []); +}); + +test("owner invalidation before installation aborts without an empty chat", async (t) => { + const f = await fixture(t); + const commit = createFirstMessageCommitter({ ...f.deps, workspaceExists: async () => { f.owner.invalidate(); return true; } }); + await assert.rejects(commit(parseChatFirstMessage(request()), f.owner), /changed/u); + assert.deepEqual(await f.store.list(), []); +}); + +test("workspace cancellation drains first-message preparation before releasing its operation", async (t) => { + const f = await fixture(t); + let completeValidation = () => {}; + let validationStarted = () => {}; + const started = new Promise((resolve) => { validationStarted = resolve; }); + const waiting = new Promise((resolve) => { completeValidation = resolve; }); + const commit = createFirstMessageCommitter({ ...f.deps, workspaceExists: async () => { validationStarted(); await waiting; return true; } }); + const writing = commit(parseChatFirstMessage(request()), f.owner); + await started; + let drained = false; + const drain = f.workspaces.cancelAndSettle("workspace").then(() => { drained = true; }); + await Promise.resolve(); + assert.equal(drained, false); + completeValidation(); + await assert.rejects(writing, /changed/u); + await drain; + assert.equal(drained, true); + assert.deepEqual(await f.store.list(), []); +}); + +test("payload failure keeps draft retryable; index uncertainty blocks retry and recovers a nonempty chat", async (t) => { + for (const phase of ["chat-write", "index-write"] as const) { + const f = await fixture(t, { + syncFile: async (target) => { + if (target.endsWith(`.${phase}.tmp`)) throw new Error(`Injected ${phase} failure`); + const handle = await fs.open(target, "r"); + try { await handle.sync(); } finally { await handle.close(); } + }, + }); + const input = parseChatFirstMessage(request()); + let failure: unknown; + try { await createFirstMessageCommitter(f.deps)(input, f.owner); } catch (error) { failure = error; } + assert.ok(failure); + assert.equal(isAppendReconciliationRequiredError(failure), phase === "index-write"); + assert.equal(f.turns.requiresAppendReconciliation(f.owner.documentId), phase === "index-write"); + const restart = createChatStore(async () => f.directory); + const chat = await restart.get(input.chatId); + assert.equal(chat?.messages.length ?? 0, phase === "index-write" ? 1 : 0); + assert.equal((await restart.list()).length, phase === "index-write" ? 1 : 0); + } +}); diff --git a/main/services/chat-first-message-commit.ts b/main/services/chat-first-message-commit.ts new file mode 100644 index 000000000..0d0ad5957 --- /dev/null +++ b/main/services/chat-first-message-commit.ts @@ -0,0 +1,161 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { ParsedChatFirstMessage } from "../handlers/chat-first-message-params.js"; +import type { Chat } from "./types.js"; +import type { ChatTurnLease } from "./chat-turn-admission.js"; +import type { RendererDocumentOwner } from "./renderer-document-owner.js"; +import type { WorkspaceOperationAdmission } from "./workspace-operation-registry.js"; +import type { createChatStore } from "./chat-store-core.js"; +import { isChatCreateReconciliationRequiredError } from "./chat-store-core.js"; +import { AppendReconciliationRequiredError } from "./chat-append-commit.js"; +import { appendReconciliationFailureMessage } from "../../renderer/shared/chat-message-contract.js"; +import { commitSkillInvocationForAppend } from "./skill-invocation-turn.js"; +import type { RegisteredSkill } from "./skill-registry.js"; +import type { SkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; + +type Owner = Pick; + +interface Dependencies { + store: Pick, "get" | "createWithFirstMessage">; + beginTurn(chatId: string, turnId: string, ownerId: string): ChatTurnLease | null; + requiresReconciliation(ownerId: string): boolean; + markReconciliation(ownerId: string): void; + clearReconciliation(ownerId: string): void; + admitWorkspace(workspaceId: string, owner: Owner): WorkspaceOperationAdmission; + workspaceExists(workspaceId: string): Promise; + requireComputerUseReady(signal: AbortSignal): Promise; + resolveSkill(workspaceId: string, invocationId: string): Promise; +} + +/** Own the draft-to-durable handoff independently of renderer navigation. */ +export function createFirstMessageCommitter(deps: Dependencies) { + // Entries live only as long as the bounded turn lease. Their append payload + // reservation stays charged while the completed receipt is cached, so sends + // that never hand off to generation cannot bypass the memory budget. + const pending = new Map }>(); + + return (input: ParsedChatFirstMessage, owner: Owner): Promise => { + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + if (deps.requiresReconciliation(owner.documentId)) { + throw new Error(appendReconciliationFailureMessage("blocked")); + } + const fingerprint = createHash("sha256").update(JSON.stringify(input)).digest("hex"); + const inFlight = pending.get(input.chatId); + if (inFlight) { + if (inFlight.ownerId !== owner.documentId || inFlight.fingerprint !== fingerprint) { + throw new Error("This draft is already sending a different message."); + } + return inFlight.promise; + } + const turn = deps.beginTurn(input.chatId, input.turnId, owner.documentId); + if (!turn) throw new Error("Wait for the previous response to finish saving before sending again."); + turn.onReleased(owner.onInvalidated(turn.release)); + try { + turn.reserveAppendPayload(input.retainedBytes); + if (input.skillReference) turn.reserveSkillPreparation(); + } catch (error) { + turn.release(); + turn.settleAsyncWork(); + throw error; + } + const entry = { ownerId: owner.documentId, fingerprint, promise: undefined as unknown as Promise }; + pending.set(input.chatId, entry); + turn.onReleased(() => { + if (pending.get(input.chatId) === entry) pending.delete(input.chatId); + }); + entry.promise = (async () => { + let committed = false; + let workSettled = false; + let workspace: WorkspaceOperationAdmission | undefined; + try { + workspace = deps.admitWorkspace(input.workspaceId, owner); + const admittedWorkspace = workspace; + const abortTurn = () => turn.release(); + workspace.signal.addEventListener("abort", abortTurn, { once: true }); + turn.onReleased(() => { + admittedWorkspace.signal.removeEventListener("abort", abortTurn); + // Revocation must drain an in-flight store write before the owning + // workspace may be deleted or repurposed. + if (workSettled) { + admittedWorkspace.release(); + turn.settleAsyncWork(); + } + }); + if (workspace.signal.aborted) abortTurn(); + const isCurrent = () => turn.isActive() && !owner.isDestroyed() && !workspace!.signal.aborted; + const assertCurrent = () => { + if (!isCurrent()) throw new Error("The workspace or message turn changed before the chat could be saved."); + if (deps.requiresReconciliation(owner.documentId)) { + throw new Error(appendReconciliationFailureMessage("blocked")); + } + }; + const existing = await deps.store.get(input.chatId); + assertCurrent(); + if (existing) { + if (existing.firstMessageCommit?.turnId !== input.turnId || + existing.firstMessageCommit.fingerprint !== fingerprint) { + throw new Error("This draft identifier has already been used for a different message."); + } + // A replay after the original lease settled confirms delivery but + // must not authorize a second generation for the same user message. + return existing; + } + if (!(await deps.workspaceExists(input.workspaceId))) { + throw new Error("The selected workspace is no longer available."); + } + if (input.computerUseEnabled) await deps.requireComputerUseReady(workspace.signal); + assertCurrent(); + const userMessageId = randomUUID(); + const append = (prepared?: { provenance: SkillProvenanceV1 }) => deps.store.createWithFirstMessage({ + id: input.chatId, + title: input.title, + workspaceId: input.workspaceId, + providerId: input.providerId, + model: input.metaModel, + computerUseEnabled: input.computerUseEnabled, + turnId: input.turnId, + fingerprint, + message: { + id: userMessageId, + content: input.content, + model: input.messageModel, + attachments: input.attachments, + skill: prepared?.provenance, + }, + assertCurrent, + }); + const chat = input.skillReference + ? await commitSkillInvocationForAppend({ + invocationId: input.skillReference.invocationId, + role: "user", + content: input.content, + attachments: input.attachments, + workspaceId: input.workspaceId, + userMessageId, + }, { + resolveFresh: deps.resolveSkill, + isCurrent, + prepareLease: (prepared) => turn.prepareSkillInvocation(prepared), + append, + }) + : await append(); + committed = true; + return chat; + } catch (error) { + if (isChatCreateReconciliationRequiredError(error)) { + deps.markReconciliation(owner.documentId); + owner.onInvalidated(() => deps.clearReconciliation(owner.documentId)); + throw new AppendReconciliationRequiredError(); + } + throw error; + } finally { + workSettled = true; + if (!committed) turn.release(); + if (!turn.isActive()) workspace?.release(); + // A successful receipt remains in pending until handoff/abandon/expiry. + // Keep its payload capacity reserved for exactly that same lifetime. + turn.settleAsyncWork({ retainAppendPayloadUntilRelease: committed && turn.isActive() }); + } + })(); + return entry.promise; + }; +} diff --git a/main/services/chat-generation-start.test.ts b/main/services/chat-generation-start.test.ts index 32a4559ac..6f1667e73 100644 --- a/main/services/chat-generation-start.test.ts +++ b/main/services/chat-generation-start.test.ts @@ -47,6 +47,85 @@ test("starts one title request only after chat initialization succeeds", async ( ]); }); +test("remembers the attended provider/model selection when both are present", async () => { + const remembered: Array<{ providerId: string; model: string }> = []; + const started = await startGenerationAndMaybeTitle( + { + start: async () => true, + startTitle: () => undefined, + rememberSelection: (providerId, model) => remembered.push({ providerId, model }), + }, + "stream-1", + params, + ); + + assert.equal(started, true); + assert.deepEqual(remembered, [{ providerId: "openai-codex", model: "gpt-5.4" }]); +}); + +test("does not remember a selection when providerId is empty", async () => { + let rememberCalls = 0; + await startGenerationAndMaybeTitle( + { + start: async () => true, + startTitle: () => undefined, + rememberSelection: () => { + rememberCalls += 1; + }, + }, + "stream-1", + { ...params, providerId: "" }, + ); + + assert.equal(rememberCalls, 0); +}); + +test("does not remember a selection when model is empty", async () => { + let rememberCalls = 0; + await startGenerationAndMaybeTitle( + { + start: async () => true, + startTitle: () => undefined, + rememberSelection: () => { + rememberCalls += 1; + }, + }, + "stream-1", + { ...params, model: "" }, + ); + + assert.equal(rememberCalls, 0); +}); + +test("remembers the selection even when chat initialization is declined", async () => { + const remembered: Array<{ providerId: string; model: string }> = []; + const started = await startGenerationAndMaybeTitle( + { + start: async () => false, + startTitle: () => undefined, + rememberSelection: (providerId, model) => remembered.push({ providerId, model }), + }, + "stream-1", + params, + ); + + assert.equal(started, false); + assert.deepEqual(remembered, [{ providerId: "openai-codex", model: "gpt-5.4" }]); +}); + +test("an absent rememberSelection callback changes nothing", async () => { + const started = await startGenerationAndMaybeTitle( + { + start: async () => true, + startTitle: () => undefined, + }, + "stream-1", + params, + ); + + assert.equal(started, true); +}); + test("only an explicit visible user Stop origin is acceptance evidence", () => { assert.equal(isExplicitUserStop("user_stop"), true); for (const origin of ["lifecycle", "navigation", "unmount", "stop", "", null, undefined]) { diff --git a/main/services/chat-generation-start.ts b/main/services/chat-generation-start.ts index 89e3c8c12..a63828193 100644 --- a/main/services/chat-generation-start.ts +++ b/main/services/chat-generation-start.ts @@ -3,6 +3,7 @@ import type { ChatStartParams } from "./types.js"; interface ChatGenerationStartDependencies { start(streamId: string, params: ChatStartParams): Promise; startTitle(input: { chatId: string; providerId: string; model: string }): void; + rememberSelection?(providerId: string, model: string): void; } /** Keep a stopped initialization from starting a second, background model request. */ @@ -11,6 +12,11 @@ export async function startGenerationAndMaybeTitle( streamId: string, params: ChatStartParams, ): Promise { + // The selection reflects the user's explicit choice at send time, so persist + // it before learning whether generation itself succeeded. + if (params.providerId && params.model) { + dependencies.rememberSelection?.(params.providerId, params.model); + } const started = await dependencies.start(streamId, params); if (started) { dependencies.startTitle({ diff --git a/main/services/chat-store-core.ts b/main/services/chat-store-core.ts index 282ee64ee..7221dd35b 100644 --- a/main/services/chat-store-core.ts +++ b/main/services/chat-store-core.ts @@ -732,6 +732,67 @@ export function createChatStore( return serialized(() => readChat(id)); }, + /** Install the first user message and sidebar metadata as one recoverable transaction. */ + async createWithFirstMessage(input: { + id: string; + title?: string; + workspaceId: string; + providerId?: string; + model?: string; + computerUseEnabled: boolean; + turnId: string; + fingerprint: string; + message: Pick; + assertCurrent: () => void; + }): Promise { + return serialized(async () => { + input.assertCurrent(); + const existing = await readChat(input.id); + if (existing) { + if (existing.firstMessageCommit?.turnId !== input.turnId || + existing.firstMessageCommit.fingerprint !== input.fingerprint) { + throw new Error("This draft identifier has already been used for a different message."); + } + return existing; + } + // readChat returns null for malformed payloads as well as missing + // files. Never replace an unreadable existing conversation on an ID + // collision; only a genuinely absent path may receive a new draft. + try { + await fs.lstat(await chatPath(input.id)); + throw new Error("This draft identifier belongs to an unreadable existing chat."); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (!input.message.content.trim() && !input.message.attachments?.length) { + throw new Error("Add a message or attachment before sending."); + } + const now = Date.now(); + const message: ChatMessage = { + id: input.message.id, + role: "user", + content: input.message.content, + model: input.message.model, + attachments: safeStoredAttachments(input.message.attachments), + skill: parseSkillProvenanceV1(input.message.skill), + createdAt: now, + }; + const chat: Chat = { + id: input.id, + workspaceId: input.workspaceId, + providerId: await resolveProviderId(input.providerId), + model: input.model, + computerUseEnabled: input.computerUseEnabled, + title: input.title?.trim() || deriveChatTitleSeed(message), + createdAt: now, + updatedAt: now, + messages: [message], + firstMessageCommit: { turnId: input.turnId, fingerprint: input.fingerprint }, + }; + return installNewChat(chat, input.assertCurrent); + }); + }, + async create(input: { id?: string; title?: string; diff --git a/main/services/chat-turn-admission.test.ts b/main/services/chat-turn-admission.test.ts index 3fc63e83f..1b1306fa1 100644 --- a/main/services/chat-turn-admission.test.ts +++ b/main/services/chat-turn-admission.test.ts @@ -90,6 +90,26 @@ test("generation handoff remains closed until append async work has settled", () assert.equal(admission.isAdmitted("chat-1"), false); }); +test("settled cached receipts remain byte-budgeted while allowing generation handoff", () => { + const admission = new ChatTurnAdmission({ maxAppendTurns: 2, maxAppendBytes: 100 }); + const first = admission.tryBegin("chat-1", "turn-1", "owner", false); + const second = admission.tryBegin("chat-2", "turn-2", "owner", false); + assert.ok(first && second); + first.reserveAppendPayload(100); + first.settleAsyncWork({ retainAppendPayloadUntilRelease: true }); + assert.equal(admission.owns("chat-1", "turn-1", "owner"), true); + assert.throws(() => second.reserveAppendPayload(1), /Too many messages/u); + assert.equal(admission.handoff("chat-1", "turn-1", "owner", () => {}), true); + second.reserveAppendPayload(100); + second.settleAsyncWork({ retainAppendPayloadUntilRelease: true }); + second.release(); + const third = admission.tryBegin("chat-3", "turn-3", "owner", false); + assert.ok(third); + third.reserveAppendPayload(100); + third.release(); + third.settleAsyncWork(); +}); + test("handed-off skill prompts remain globally charged until generation cleanup", () => { const admission = new ChatTurnAdmission({ maxPreparedTurns: 1, diff --git a/main/services/chat-turn-admission.ts b/main/services/chat-turn-admission.ts index f50e3ccfc..fbdfe47b8 100644 --- a/main/services/chat-turn-admission.ts +++ b/main/services/chat-turn-admission.ts @@ -26,8 +26,8 @@ export interface ChatTurnLease { reserveAppendPayload(bytes: number): void; reserveSkillPreparation(): void; prepareSkillInvocation(invocation: PreparedSkillInvocation): void; - /** Release payload accounting only after the append async frame settles. */ - settleAsyncWork(): void; + /** Mark work ready for handoff; cached first-send receipts stay charged until release. */ + settleAsyncWork(options?: { retainAppendPayloadUntilRelease?: boolean }): void; onReleased(cleanup: () => void): void; release(): void; } @@ -187,11 +187,11 @@ export class ChatTurnAdmission { this.skillBytes -= record.skillBytes - invocationBytes; record.skillBytes = invocationBytes; }, - settleAsyncWork: () => { + settleAsyncWork: (options) => { const record = this.turns.get(chatId); if (!record || record.lease !== lease || record.asyncSettled) return; record.asyncSettled = true; - if (record.appendSlotReserved) { + if (record.appendSlotReserved && !options?.retainAppendPayloadUntilRelease) { this.appendTurns -= 1; this.appendBytes -= record.appendBytes; record.appendSlotReserved = false; diff --git a/main/services/context-lifecycle-service-main.ts b/main/services/context-lifecycle-service-main.ts index 46e71cad0..65c97a3fb 100644 --- a/main/services/context-lifecycle-service-main.ts +++ b/main/services/context-lifecycle-service-main.ts @@ -32,7 +32,10 @@ export const contextLifecycleService = new ContextLifecycleService({ openSession: async (chatId) => { const chat = await chatStore.get(chatId); if (!chat) throw new Error("Chat is unavailable."); - return piCompactionSessionStore.openChat(chatId, chat); + const opened = await piCompactionSessionStore.openChatIfEligible(chatId, chat); + // Rollout-ineligible chats (pre-activation or deferred v3 migration) resolve + // compaction benignly instead of throwing a fail-closed journal error. + return opened.session ?? null; }, resolveRuntime: resolveModelRuntime, resolveLocalModel: resolveCompactionModelMetadata, diff --git a/main/services/context-lifecycle-service.test.ts b/main/services/context-lifecycle-service.test.ts index dfcaf42e7..e912ace41 100644 --- a/main/services/context-lifecycle-service.test.ts +++ b/main/services/context-lifecycle-service.test.ts @@ -106,6 +106,25 @@ test("startup rollback gate disables manual compaction before admission or journ assert.equal(admitted, false); }); +test("rollout-ineligible openSession closes compaction benignly without touching the journal", async () => { + let opened = false; + const { value, events } = deps({ + openSession: async () => { + opened = true; + // The probe reports a rollout reason: no v4 journal, no v3 migration. + return null; + }, + }); + const result = await new ContextLifecycleService(value).compactChat( + baseChat.id, + { kind: "desktop", ownerId: "renderer:1" }, + "operator", + ); + assert.deepEqual(result, { compacted: false, reason: "already_compact" }); + assert.equal(opened, true); + assert.deepEqual(events, ["settle", "release"]); +}); + test("manual compaction resolves the exact provider and model saved on the chat", async () => { const resolved: string[] = []; const { value, events } = deps({ diff --git a/main/services/context-lifecycle-service.ts b/main/services/context-lifecycle-service.ts index 597e174bd..5436c8f5f 100644 --- a/main/services/context-lifecycle-service.ts +++ b/main/services/context-lifecycle-service.ts @@ -51,7 +51,11 @@ export interface ContextLifecycleServiceDeps { listChatsByBot(botId: string): Promise; isBotArchived(botId: string): Promise; beginChatTurn(chatId: string, turnId: string, ownerId: string): ChatTurnLease | null; - openSession(chatId: string): Promise; + /** + * Opens the durable Pi journal for the chat. Rollout-ineligible chats resolve + * `null` instead of throwing, so compaction closes benignly. + */ + openSession(chatId: string): Promise; resolveRuntime( providerId: string, model: string, @@ -170,6 +174,9 @@ export class ContextLifecycleService { } let session = await this.deps.openSession(chat.id); + if (!session) { + return { compacted: false, reason: "already_compact" }; + } await syncChatMessagesToPiSession( session, chat.messages, diff --git a/main/services/diagnostics-contract.test.ts b/main/services/diagnostics-contract.test.ts index 522dc19d7..5426307c2 100644 --- a/main/services/diagnostics-contract.test.ts +++ b/main/services/diagnostics-contract.test.ts @@ -100,6 +100,62 @@ test("categorical fields reject grammar-valid but unregistered strings", () => { }), { platform: "darwin", arch: "arm64" }); }); +test("remote request diagnostics admit bounded methods and route templates only", () => { + const event = createDiagnosticEvent( + { + level: "warn", + area: "remote", + event: "remote-request-failed", + outcome: "degraded", + fields: { + routeCategory: "chats", + method: "POST", + route: "/chats/:id/turns", + statusClass: "4xx", + latencyBucket: "2s-plus", + remoteCode: "not_found", + }, + }, + sessionId, + ); + assert.equal(event.fields?.method, "POST"); + assert.equal(event.fields?.route, "/chats/:id/turns"); + assert.equal(event.fields?.routeCategory, "chats"); + assert.equal(event.fields?.remoteCode, "not_found"); + assert.deepEqual(normalizeDiagnosticFields({ + method: "GET", + route: "/scheduled-tasks/:id/runs", + }), { method: "GET", route: "/scheduled-tasks/:id/runs" }); + assert.deepEqual(normalizeDiagnosticFields({ + method: "DELETE", + route: "/workspaces/:id/git/managed-worktree", + }), { method: "DELETE", route: "/workspaces/:id/git/managed-worktree" }); +}); + +test("remote request diagnostics reject unregistered methods and untrusted route strings", () => { + assert.deepEqual(normalizeDiagnosticFields({ + method: "STEAL", + route: "/chats/:id/turns", + }), { route: "/chats/:id/turns" }); + assert.deepEqual(normalizeDiagnosticFields({ + method: "POST", + route: "/chats/:id/turns?token=hidden", + }), { method: "POST" }); + assert.deepEqual(normalizeDiagnosticFields({ + method: "GET", + route: "/Users/alice/private.ts", + }), { method: "GET" }); + assert.equal(normalizeDiagnosticFields({ + route: "chats/:id", + }), undefined); + assert.equal(normalizeDiagnosticFields({ + route: "/chats//:id", + }), undefined); + assert.equal(normalizeDiagnosticFields({ + route: `/${"x".repeat(500)}`, + }), undefined); +}); + test("Tailscale status diagnostics retain only closed failure categories", () => { const event = createDiagnosticEvent( { diff --git a/main/services/diagnostics-contract.ts b/main/services/diagnostics-contract.ts index 1dcbd7aa3..1994ecee9 100644 --- a/main/services/diagnostics-contract.ts +++ b/main/services/diagnostics-contract.ts @@ -226,6 +226,7 @@ const ENUM_STRING_FIELDS: Readonly>> = { "unknown", ]), latencyBucket: new Set(["2s-plus", "5s-plus", "10s-plus"]), + method: new Set(["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"]), profile: new Set(["development", "production"]), rendererContext: new Set(["root", "router", "subtree", "window"]), routeCategory: new Set([ @@ -249,6 +250,13 @@ const ENUM_STRING_FIELDS: Readonly>> = { }; function normalizedStringField(key: string, value: string): string | undefined { + // Route templates are structural constants emitted by the Aiden Remote + // router itself (for example `/chats/:id/turns`). They are content-free by + // construction — never a query string, header, credential, body, or a + // caller-controlled literal path — so the generic content scrubber is not + // applied; instead a strict grammar admits only bounded leading-slash paths + // of lowercase static segments and `:param` placeholders. + if (key === "route") return normalizedDiagnosticRoute(value); const sanitized = sanitizeDiagnosticText(value); if (!sanitized) return undefined; const enumerated = ENUM_STRING_FIELDS[key]; @@ -264,6 +272,28 @@ function normalizedStringField(key: string, value: string): string | undefined { return undefined; } +function normalizedDiagnosticRoute(value: string): string | undefined { + if (value.length === 0 || [...value].length > MAX_DIAGNOSTIC_FIELD_LENGTH) { + return undefined; + } + if ( + value[0] !== "/" || + value.includes("//") || + /[?%#@\\\s]/u.test(value) + ) { + return undefined; + } + for (const segment of value.split("/")) { + if (segment.length === 0) continue; + if (segment.startsWith(":")) { + if (!/^:[a-z][A-Za-z0-9]{0,63}$/u.test(segment)) return undefined; + } else if (!/^[a-z0-9][a-z0-9._~-]{0,127}$/u.test(segment)) { + return undefined; + } + } + return value; +} + function boundedNumber(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(-Number.MAX_SAFE_INTEGER, Math.min(Number.MAX_SAFE_INTEGER, value)); diff --git a/main/services/empty-chat-migration-main.ts b/main/services/empty-chat-migration-main.ts new file mode 100644 index 000000000..7cc786a18 --- /dev/null +++ b/main/services/empty-chat-migration-main.ts @@ -0,0 +1,92 @@ +import * as path from "node:path"; +import { app, logger } from "../platform.js"; +import { DataStore } from "./data-store.js"; +import { readRegularFile, decodeUtf8 } from "./regular-file-read.js"; +import { chatStore } from "./chat-store.js"; +import { chatApplicationService } from "./chat-application-service-main.js"; +import { configStore } from "./config-store.js"; +import { subagentRunStore } from "./subagents/subagent-run-store.js"; +import { piCompactionSessionStore } from "./pi-compaction-session-store.js"; +import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; +import { displayImageArtifactStore } from "./display-image-artifact-store.js"; +import { generativeUiArtifactStore } from "./generative-ui-artifact-store.js"; +import { + isEmptyChatMigrationState, + isLegacyEmptyWorkspaceChat, + migrateEmptyWorkspaceChats, + type EmptyChatMigrationState, +} from "./empty-chat-migration.js"; + +const migration = new DataStore( + "empty-workspace-chats-migration-v1.json", + { version: 1, pending: null, complete: false }, + undefined, + { fileMode: 0o600, maxBytes: 4 * 1024 * 1024, isSafe: isEmptyChatMigrationState, + rejectCorruptWrite: true, rejectUnsafeWrite: true }, +); + +/** Protect even disabled tasks and retained historical runs, without normalizing away invalid records. */ +async function scheduledChatIds(): Promise> { + const ids = new Set(); + for (const file of ["schedules.json", "schedule-runs.json"]) { + let bytes: Buffer; + try { + bytes = await readRegularFile(path.join(app.getPath("userData"), file), 16 * 1024 * 1024); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + const records: unknown = JSON.parse(decodeUtf8(bytes)); + if (!Array.isArray(records)) throw new Error("Scheduled chat references could not be read safely."); + for (const record of records) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error("Scheduled chat references could not be read safely."); + } + if (typeof record.chatId === "string") ids.add(record.chatId); + } + } + return ids; +} + +export async function migrateLegacyEmptyWorkspaceChats(): Promise { + const state = await migration.load(); + if (await migration.loadedFromCorruptFile() || await migration.loadedFromUnsafeFile()) { + throw new Error("Empty-chat migration receipt could not be read safely."); + } + if (state.complete) return 0; + // Defer unrelated stores until after the immutable candidate boundary is + // saved. If they are unreadable, preserve affected candidates and finish. + let context: Promise<{ workspaceIds: Set; reservedChatIds: Set }> | undefined; + const eligibilityContext = () => context ??= (async () => { + if (!displayImageArtifactStore.availability().available || !generativeUiArtifactStore.availability().available) { + throw new Error("Empty-chat cleanup requires readable artifact recovery stores."); + } + return { + workspaceIds: new Set((await configStore.listWorkspaces()).map((workspace) => workspace.id)), + reservedChatIds: await scheduledChatIds(), + }; + })(); + let reportedPreservation = false; + return migrateEmptyWorkspaceChats({ + load: async () => state, + save: async (next) => { await migration.update((current) => Object.assign(current, next)); }, + list: () => chatStore.list(), + get: (id) => chatStore.get(id), + onPreserved: (error) => { + if (reportedPreservation) return; + reportedPreservation = true; + logger.warn("chat", "Empty-chat cleanup preserved records whose private history or ownership could not be read safely.", error); + }, + eligible: async (chat) => { + const { workspaceIds, reservedChatIds } = await eligibilityContext(); + return isLegacyEmptyWorkspaceChat(chat, workspaceIds, reservedChatIds) && + !(await displayImageArtifactStore.hasPending(chat.id)) && + !(await generativeUiArtifactStore.hasPending(chat.id)) && + (await subagentRunStore.listByChat(chat.id)).length === 0 && + (await piRuntimeEffectStore.listOperationsByChat(chat.id)).length === 0 && + (await piRuntimeEffectStore.listEffectsByChat(chat.id)).length === 0 && + !(await piCompactionSessionStore.hasChatHistory(chat.id)); + }, + remove: (id, assertCurrent) => chatApplicationService.remove(id, { assertCurrent }), + }); +} diff --git a/main/services/empty-chat-migration.test.ts b/main/services/empty-chat-migration.test.ts new file mode 100644 index 000000000..e547c6e5f --- /dev/null +++ b/main/services/empty-chat-migration.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createChatStore } from "./chat-store-core.js"; +import { isEmptyChatMigrationState, isLegacyEmptyWorkspaceChat, migrateEmptyWorkspaceChats, type EmptyChatMigrationState } from "./empty-chat-migration.js"; +import type { Chat } from "./types.js"; + +const empty = (id: string, overrides: Partial = {}): Chat => ({ + id, title: "New chat", workspaceId: "default", createdAt: 1, updatedAt: 1, messages: [], ...overrides, +}); +const eligible = async (chat: Chat) => isLegacyEmptyWorkspaceChat(chat, new Set(["default", "worktree"]), new Set(["scheduled"])); +function harness(chats: Chat[]) { + const records = new Map(chats.map((chat) => [chat.id, chat])); + let state: EmptyChatMigrationState = { version: 1, pending: null, complete: false }; + const removed: string[] = []; + const deps = { + load: async () => structuredClone(state), + save: async (next: EmptyChatMigrationState) => { state = structuredClone(next); }, + list: async () => [...records.values()], + get: async (id: string) => records.get(id) ?? null, + eligible, + remove: async (id: string, assertCurrent: (chat: Chat) => Promise) => { + await assertCurrent(records.get(id)!); + records.delete(id); + removed.push(id); + }, + }; + return { deps, records, removed, state: () => state }; +} + +test("only zero-message ordinary workspace chats qualify, regardless of title", async () => { + const h = harness([ + empty("blank"), empty("renamed", { title: "User title" }), empty("legacy", { workspaceId: undefined }), + empty("tree", { workspaceId: "worktree" }), + empty("bot", { botId: "bot-1" }), empty("assistant", { workspaceId: "assistant" }), + empty("telegram-123-default"), empty("assistant-live:123", { workspaceId: "assistant" }), + empty("unknown", { workspaceId: "missing" }), empty("scheduled"), + empty("message", { messages: [{ id: "m", role: "user", content: "", createdAt: 1 }] }), + empty("assistant-message", { messages: [{ id: "m", role: "assistant", content: "hello", createdAt: 1 }] }), + ]); + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 4); + assert.deepEqual(h.removed, ["blank", "renamed", "legacy", "tree"]); + assert.deepEqual(h.state(), { version: 1, pending: [], complete: true }); + h.records.set("newer-empty", empty("newer-empty")); + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.ok(h.records.has("newer-empty")); +}); + +test("partial deletion resumes only original snapshot, rechecking nonempty chats", async () => { + const h = harness([empty("one"), empty("two"), empty("three")]); + const remove = h.deps.remove; + let failed = false; + h.deps.remove = async (id, check) => { + if (id === "two" && !failed) { failed = true; throw new Error("disk unavailable"); } + await remove(id, check); + }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /disk unavailable/u); + assert.deepEqual(h.state().pending?.map((candidate) => candidate.id), ["two", "three"]); + h.records.set("newer", empty("newer")); + h.records.get("three")!.messages.push({ id: "m", role: "user", content: "sent", createdAt: 2 }); + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 1); + assert.deepEqual(h.removed, ["one", "two"]); + assert.ok(h.records.has("newer")); + assert.ok(h.records.has("three")); +}); + +test("failure saving initial snapshot deletes nothing; failure recording deletion is retryable", async () => { + const h = harness([empty("one")]); + const save = h.deps.save; + h.deps.save = async () => { throw new Error("receipt unavailable"); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /snapshot could not be saved/u); + assert.deepEqual(h.removed, []); + let writes = 0; + h.deps.save = async (next) => { if (++writes === 2) throw new Error("receipt unavailable"); await save(next); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /receipt unavailable/u); + assert.deepEqual(h.removed, ["one"]); + h.deps.save = save; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.equal(h.state().complete, true); +}); + +test("corrupt and incomplete migration receipts fail closed", () => { + for (const state of [null, {}, { version: 2, pending: [], complete: true }, + { version: 1, pending: ["../chat"], complete: false }, + { version: 1, pending: ["a", "a"], complete: false }, + { version: 1, pending: ["a"], complete: true }, + { version: 1, pending: null, complete: true }]) { + assert.equal(isEmptyChatMigrationState(state), false); + } +}); + +test("real chat files and summary rows are deleted while corrupt and populated payloads survive restart", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-empty-migration-")); + try { + const store = createChatStore(async () => root); + await store.create({ id: "empty", workspaceId: "default" }); + await store.create({ id: "corrupt", workspaceId: "default" }); + await store.create({ id: "populated", workspaceId: "default" }); + await store.appendMessage("populated", { role: "user", content: "hello" }); + await fs.writeFile(path.join(root, "corrupt.json"), "{broken"); + const h = harness([]); + assert.equal(await migrateEmptyWorkspaceChats({ ...h.deps, + list: () => store.list(), get: (id) => store.get(id), + remove: (id, check) => store.remove(id, async (chat) => { if (chat) await check(chat); }), + }), 1); + await assert.rejects(fs.stat(path.join(root, "empty.json")), { code: "ENOENT" }); + assert.equal(await fs.readFile(path.join(root, "corrupt.json"), "utf8"), "{broken"); + const reopened = createChatStore(async () => root); + assert.equal((await reopened.get("populated"))?.messages[0]?.content, "hello"); + assert.ok(!(await reopened.listSummaryMetadata()).some((chat) => chat.id === "empty")); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); + + +test("resume preserves a replacement chat reusing a candidate ID", async () => { + const h = harness([empty("reused")]); + const remove = h.deps.remove; + h.deps.remove = async () => { throw new Error("interrupted"); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /interrupted/u); + h.records.set("reused", empty("reused", { createdAt: 2, updatedAt: 2 })); + h.deps.remove = remove; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.ok(h.records.has("reused")); + assert.equal(h.state().complete, true); +}); + +test("eligibility errors occur after snapshot and preserve candidates without a later resweep", async () => { + const h = harness([empty("uncertain"), empty("healthy")]); + h.deps.eligible = async (chat) => { + assert.notEqual(h.state().pending, null, "freeze identities before reading eligibility stores"); + if (chat.id === "uncertain") throw new Error("unreadable private journal"); + return true; + }; + const warnings: unknown[] = []; + assert.equal(await migrateEmptyWorkspaceChats({ ...h.deps, onPreserved: (error) => warnings.push(error) }), 1); + assert.equal(warnings.length, 1); + assert.ok(h.records.has("uncertain")); + assert.equal(h.state().complete, true); + h.records.set("later", empty("later")); + h.deps.eligible = async () => true; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.ok(h.records.has("later")); +}); + +test("initial enumeration errors use the startup-blocking snapshot error", async () => { + const h = harness([empty("one")]); + h.deps.list = async () => { throw new Error("unreadable index"); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), { name: "EmptyChatMigrationSnapshotError" }); + assert.equal(h.state().pending, null); + assert.deepEqual(h.removed, []); +}); + +test("unreadable payloads are preserved without preventing healthy empty cleanup", async () => { + const h = harness([empty("unreadable"), empty("healthy")]); + const get = h.deps.get; + h.deps.get = async (id) => { if (id === "unreadable") throw new Error("unreadable payload"); return get(id); }; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 1); + assert.ok(h.records.has("unreadable")); + assert.equal(h.state().complete, true); +}); + +test("cross-store final assertion does not reopen already-deleted private stores", async () => { + const h = harness([empty("one")]); + let eligibilityReads = 0; + h.deps.eligible = async () => { assert.equal(++eligibilityReads, 1); return true; }; + const remove = h.deps.remove; + h.deps.remove = async (id, check) => { + await check(h.records.get(id)!); // pre-delete fence + await remove(id, check); // application service rechecks after side-store cleanup + }; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 1); + assert.equal(eligibilityReads, 1); +}); diff --git a/main/services/empty-chat-migration.ts b/main/services/empty-chat-migration.ts new file mode 100644 index 000000000..9b75f0047 --- /dev/null +++ b/main/services/empty-chat-migration.ts @@ -0,0 +1,112 @@ +import { createHash } from "node:crypto"; +import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; +import { persistedChatWorkspaceId } from "../../renderer/shared/chat-workspace.js"; +import type { Chat, ChatMeta } from "./types.js"; + +export class EmptyChatMigrationSnapshotError extends Error { + readonly cause: unknown; + constructor(cause: unknown) { + super("The empty-chat migration snapshot could not be saved safely."); + this.cause = cause; + this.name = "EmptyChatMigrationSnapshotError"; + } +} + +export interface EmptyChatCandidate { id: string; fingerprint: string } +const fingerprint = (chat: Chat) => createHash("sha256").update(JSON.stringify(chat)).digest("hex"); + +export interface EmptyChatMigrationState { + version: 1; + /** null means the legacy snapshot has not been taken yet. */ + pending: EmptyChatCandidate[] | null; + complete: boolean; +} + +export function isEmptyChatMigrationState(value: unknown): value is EmptyChatMigrationState { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const state = value as Partial; + return state.version === 1 && typeof state.complete === "boolean" && + (state.pending === null || (Array.isArray(state.pending) && + state.pending.every((candidate) => candidate && typeof candidate === "object" && + typeof candidate.id === "string" && /^[A-Za-z0-9._:-]{1,160}$/u.test(candidate.id) && + typeof candidate.fingerprint === "string" && /^[a-f0-9]{64}$/u.test(candidate.fingerprint)) && + new Set(state.pending.map((candidate) => candidate.id)).size === state.pending.length)) && + (!state.complete || (Array.isArray(state.pending) && state.pending.length === 0)); +} + +export function isLegacyEmptyWorkspaceChat( + chat: Chat, + workspaceIds: ReadonlySet, + reservedChatIds: ReadonlySet, +): boolean { + const workspaceId = persistedChatWorkspaceId(chat.workspaceId); + return chat.messages.length === 0 && chat.botId === undefined && + workspaceId !== ASSISTANT_WORKSPACE_ID && workspaceIds.has(workspaceId) && + !chat.id.startsWith("telegram-") && !chat.id.startsWith("assistant-") && + !reservedChatIds.has(chat.id); +} + +/** + * Startup-only, before any renderer or remote writer starts. Persist the exact + * legacy candidates before deleting anything: retries must never sweep chats + * created by newer clients after the first migration attempt. + */ +export async function migrateEmptyWorkspaceChats(deps: { + load(): Promise; + save(state: EmptyChatMigrationState): Promise; + list(): Promise; + get(id: string): Promise; + eligible(chat: Chat): Promise; + remove(id: string, assertCurrent: (chat: Chat) => Promise): Promise; + onPreserved?(error: unknown): void; +}): Promise { + let state = await deps.load(); + if (!isEmptyChatMigrationState(state)) throw new Error("Invalid empty-chat migration state."); + if (state.complete) return 0; + const readCandidate = async (id: string) => { + try { return await deps.get(id); } + catch (error) { deps.onPreserved?.(error); return null; } + }; + if (state.pending === null) { + try { + const candidates: EmptyChatCandidate[] = []; + const seen = new Set(); + for (const meta of await deps.list()) { + if (seen.has(meta.id)) continue; + seen.add(meta.id); + const chat = await readCandidate(meta.id); + // Snapshot before consulting any external eligibility store. Unknown + // payloads survive; any stored message is already outside cleanup scope. + if (chat?.messages.length === 0) candidates.push({ id: chat.id, fingerprint: fingerprint(chat) }); + } + state = { version: 1, pending: candidates, complete: false }; + await deps.save(state); + } catch (error) { + throw new EmptyChatMigrationSnapshotError(error); + } + } + let removed = 0; + for (const candidate of [...state.pending!]) { + const { id } = candidate; + const chat = await readCandidate(id); + let eligible = false; + if (chat && fingerprint(chat) === candidate.fingerprint) { + try { eligible = await deps.eligible(chat); } + catch (error) { deps.onPreserved?.(error); } + } + if (eligible) { + await deps.remove(id, async (current) => { + // Cross-store removal calls this again after deleting private stores; + // do not reopen those stores or re-evaluate eligibility at that point. + if (current.messages.length !== 0 || fingerprint(current) !== candidate.fingerprint) throw new Error("The empty chat changed during migration."); + }); + removed++; + } + // Eligibility uncertainty preserves this candidate permanently. It cannot + // keep a pre-snapshot retry window open or sweep newer chats on restart. + state = { version: 1, pending: state.pending!.filter((entry) => entry.id !== id), complete: false }; + await deps.save(state); + } + await deps.save({ version: 1, pending: [], complete: true }); + return removed; +} diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index 0a176df7b..93a89eb35 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -155,6 +155,8 @@ import { type PiVisibleTurnLease, } from "./pi-compaction-session-store.js"; import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; +import { createInMemoryPiSession } from "./pi-session-repository-port.js"; +import type { PiSessionPort } from "./pi-session-port.js"; import { createComputerUseController } from "./computer-use/runtime.js"; import { computerUseStatus } from "./computer-use/status.js"; import { GenerationTimelineProjector, safeToolIssueDetails } from "./generation-timeline.js"; @@ -1808,11 +1810,12 @@ export const llmClient = { let currentAssistantTurnStart = { full: 0, reasoning: 0 }; const requestUsage = new AssistantRequestUsageTracker(); let activeCompactionStepId: string | undefined; - let piSession: Awaited> | undefined; + let piSession: PiSessionPort | undefined; let candidate: PiAgentRuntimeHarness | null = null; let currentPromptMessage: AgentMessage | undefined; let journalContentOverrides: ReadonlyMap = new Map(); let piJournalHealthy = true; + let piJournalless = false; let piUpgradeCompactionEnabled = false; let memoryApprovalContext: { scope: MemoryScope; provenance: MemoryProvenance } | undefined; try { @@ -1821,7 +1824,19 @@ export const llmClient = { development: !isPackagedRuntime(), behaviorEnabled: piUpgradeBehaviorEnabledAtStartup, }); - piSession = await piCompactionSessionStore.openChat(params.chatId, generationChat); + const piOpen = await piCompactionSessionStore.openChatIfEligible( + params.chatId, + generationChat, + ); + if (piOpen.session) { + piSession = piOpen.session; + } else { + // The device rollout classifies this chat as pre-activation or holding + // a deferred v3 migration. Run the turn journalless over an in-memory + // session: no v4 journal is created and no recovery record is claimed. + piJournalless = true; + piSession = await createInMemoryPiSession(params.chatId); + } const recallExtension: PiAgentRuntimeExtension = { id: "aiden.chat-history-recall", tools: [createVccRecallTool(async () => piSession!)], @@ -1900,7 +1915,9 @@ export const llmClient = { const baseRuntimeExtensions: readonly PiAgentRuntimeExtension[] = preparedBotContext ? [ ...(memoryExtension ? [memoryExtension] : []), - recallExtension, + // Journalless runs must not offer VCC recall over an empty + // in-memory journal; history recall would be dishonest. + ...(piJournalless ? [] : [recallExtension]), { id: "aiden.bot-runtime-authority", beforeProviderRequest: async ({ model: requestModel }) => { @@ -1920,7 +1937,9 @@ export const llmClient = { ...runtimeExtensionSnapshot.extensions, ...generationExtensions, ...(memoryExtension ? [memoryExtension] : []), - recallExtension, + // Journalless runs must not offer VCC recall over an empty + // in-memory journal; history recall would be dishonest. + ...(piJournalless ? [] : [recallExtension]), ]; const toolsBeforeAdvisor = resolvePiAgentRuntimeStaticContributions( "", @@ -2162,13 +2181,15 @@ export const llmClient = { ); if (recoveryEffects.length > 0) { await recordPiEffectRecoveryBoundary(promptJournal, recoveryEffects); - for (const effect of recoveryEffects) { - await piRuntimeEffectStore.markRecoveryRecorded({ - effectId: effect.effectId, - operationId: effect.operationId, - runId: effect.runId, - chatId: effect.chatId, - }); + if (!piJournalless) { + for (const effect of recoveryEffects) { + await piRuntimeEffectStore.markRecoveryRecorded({ + effectId: effect.effectId, + operationId: effect.operationId, + runId: effect.runId, + chatId: effect.chatId, + }); + } } } currentPromptMessage = currentUser @@ -2817,10 +2838,12 @@ export const llmClient = { const quarantineFailedPiRecovery = (message: string, error: unknown) => { piJournalHealthy = false; logger.error("pi", message, error); - piCompactionSessionStore.quarantineChatUntilRecovered( - params.chatId, - Promise.reject(new Error("Pi journal recovery requires application restart.")), - ); + if (!piJournalless) { + piCompactionSessionStore.quarantineChatUntilRecovered( + params.chatId, + Promise.reject(new Error("Pi journal recovery requires application restart.")), + ); + } }; const finalizePiTurnPersistence = async (persisted: { chat: Chat | undefined; @@ -2855,7 +2878,9 @@ export const llmClient = { await syncChatMessagesToPiSession(piJournal, [visibleAssistant], model, supportsImages); } }); - piCompactionSessionStore.quarantineChatUntilRecovered(params.chatId, recovery); + if (!piJournalless) { + piCompactionSessionStore.quarantineChatUntilRecovered(params.chatId, recovery); + } return; } const turnLease = piTurnLease; @@ -2932,7 +2957,9 @@ export const llmClient = { markerAlreadyPersisted: reconcileAbandonedVisibleAssistant, }); try { - await piRuntimeEffectStore.acknowledgeChatEffectsDurable(params.chatId); + if (!piJournalless) { + await piRuntimeEffectStore.acknowledgeChatEffectsDurable(params.chatId); + } } catch (error) { // The Pi turn is already durable. Leaving the effect unacknowledged // makes startup install a conservative no-repeat boundary. diff --git a/main/services/peer-host-registry.test.ts b/main/services/peer-host-registry.test.ts new file mode 100644 index 000000000..6fb492689 --- /dev/null +++ b/main/services/peer-host-registry.test.ts @@ -0,0 +1,380 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PeerHostRegistry, + type StoredPeerHost, + type PeerClient, +} from "./peer-host-registry.js"; +import { + EncryptedPeerHostStorage, + type PeerEncryptedDocument, +} from "./peer-host-storage.js"; +import { peerRequestUrl } from "./peer-transport.js"; +import { hostResourceKey } from "../../renderer/shared/peer-host.js"; + +const trust = { + endpoint: "https://server.example/api/aiden/v1", + serverSpkiSha256: `sha256/${Buffer.alloc(32).toString("base64")}`, +}; +function saved(id = "host_a"): StoredPeerHost { + return { + ...trust, + id, + name: "Same name", + deviceId: "device_a", + credential: "a".repeat(43), + enabled: true, + capabilities: ["chat:read"], + features: [], + }; +} +function registry( + hosts: StoredPeerHost[], + client: PeerClient, + save = async (_next: StoredPeerHost[]) => {}, +) { + return new PeerHostRegistry({ + storage: { load: async () => hosts, save }, + localInstanceId: async () => "self", + deviceName: "Desktop", + clientVersion: "1", + platform: "mac", + client: (trust) => ({ + json: (input) => + input.path === "/server" + ? Promise.resolve({ + protocolVersion: 1, + instanceId: (trust as StoredPeerHost).id, + capabilities: ["chat:read"], + features: [], + }) + : client.json(input), + events: (input, onFrame) => client.events(input, onFrame), + }), + }); +} + +test("host identities do not collide and routes cannot escape the fixed API", () => { + assert.notEqual( + hostResourceKey({ hostId: "a", resourceId: "same" }), + hostResourceKey({ hostId: "b", resourceId: "same" }), + ); + for (const path of [ + "//evil.example", + "/../../settings", + "/%2e%2e/secret", + "/\\evil", + "/chats#fragment", + ]) { + assert.throws(() => peerRequestUrl(trust.endpoint, path)); + } + assert.equal( + peerRequestUrl(trust.endpoint, "/chats?cursor=abc").origin, + "https://server.example", + ); +}); + +test("disabled peers make no requests; views contain no trust or credential data", async () => { + let calls = 0; + const store = registry([{ ...saved(), enabled: false }], { + json: async () => { + calls++; + }, + events: async () => {}, + }); + await assert.rejects(store.request("host_a", { path: "/chats" })); + assert.equal(calls, 0); + const [view] = await store.list(); + assert.deepEqual( + Object.keys(view!).sort(), + ["id", "name", "enabled", "state", "features", "capabilities"].sort(), + ); +}); + +test("restored peers verify installation identity before any operation", async () => { + const paths: string[] = []; + const store = new PeerHostRegistry({ + storage: { load: async () => [saved()], save: async () => {} }, + localInstanceId: async () => "self", + deviceName: "Desktop", + clientVersion: "1", + platform: "mac", + client: () => ({ + json: async (input) => { + paths.push(input.path); + return { + protocolVersion: 1, + instanceId: "replacement", + capabilities: [], + }; + }, + events: async () => { + throw new Error("Unexpected event request"); + }, + }), + }); + await assert.rejects( + store.request("host_a", { method: "POST", path: "/chats", body: {} }), + /identity changed/, + ); + assert.deepEqual(paths, ["/server"]); + assert.equal((await store.list())[0]?.state, "unavailable"); +}); + +test("shutdown during initial storage load prevents network admission", async () => { + let release!: () => void; + let started!: () => void; + const loading = new Promise((resolve) => { + started = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const store = new PeerHostRegistry({ + storage: { + load: async () => { + started(); + await pending; + return [saved()]; + }, + save: async () => {}, + }, + localInstanceId: async () => "self", + deviceName: "Desktop", + clientVersion: "1", + platform: "mac", + client: () => ({ + json: async () => { + calls++; + return {}; + }, + events: async () => { + calls++; + }, + }), + }); + const request = store.request("host_a", { + method: "POST", + path: "/chats", + body: {}, + }); + const rejected = assert.rejects(request, /closed/); + await loading; + store.close(); + release(); + await rejected; + assert.equal(calls, 0); +}); + +test("concurrent identity checks retain independent cancellation", async () => { + const checks: Array<() => void> = []; + const store = new PeerHostRegistry({ + storage: { load: async () => [saved()], save: async () => {} }, + localInstanceId: async () => "self", + deviceName: "Desktop", + clientVersion: "1", + platform: "mac", + client: () => ({ + json: (input) => + input.path === "/server" + ? new Promise((resolve, reject) => { + input.signal?.addEventListener( + "abort", + () => reject(new Error("aborted")), + { once: true }, + ); + checks.push(() => + resolve({ + protocolVersion: 1, + instanceId: "host_a", + capabilities: [], + }), + ); + }) + : Promise.resolve({ ok: true }), + events: async () => {}, + }), + }); + const controller = new AbortController(); + const first = store.request("host_a", { + path: "/chats", + signal: controller.signal, + }); + const rejected = assert.rejects(first, /aborted/); + const second = store.request("host_a", { path: "/chats" }); + for (let i = 0; checks.length < 2 && i < 50; i++) + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(checks.length, 2); + controller.abort(); + checks[1]!(); + await rejected; + assert.deepEqual(await second, { ok: true }); +}); + +test("disabling one peer cancels and fences only that peer's late responses", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const store = registry([saved(), saved("host_b")], { + json: async () => { + await pending; + return { ok: true }; + }, + events: async () => {}, + }); + const a = store.request("host_a", { path: "/chats" }); + const rejection = assert.rejects(a, /superseded/); + const b = store.request("host_b", { path: "/chats" }); + await store.setEnabled("host_a", false); + release(); + await rejection; + assert.deepEqual(await b, { ok: true }); +}); + +test("failed persistence leaves peer enabled and credentials are never published before storage", async () => { + const store = registry( + [saved()], + { json: async () => ({}), events: async () => {} }, + async () => { + throw new Error("disk full"); + }, + ); + await assert.rejects(store.setEnabled("host_a", false), /disk full/); + assert.equal((await store.list())[0]!.enabled, true); +}); + +test("paired-host encrypted store rejects unavailable encryption and corrupt data", async () => { + let document: PeerEncryptedDocument = { version: 1, ciphertext: null }; + let available = true; + const store = new EncryptedPeerHostStorage( + { + load: async () => document, + save: async (next) => { + document = next; + }, + }, + { + isEncryptionAvailable: () => available, + encryptString: (text) => + Buffer.from(Buffer.from(text).map((byte) => byte ^ 0x55)), + decryptString: (bytes) => + Buffer.from(Buffer.from(bytes).map((byte) => byte ^ 0x55)).toString(), + }, + ); + await store.save([saved()]); + assert.ok(!JSON.stringify(document).includes(saved().credential)); + assert.deepEqual(await store.load(), [saved()]); + available = false; + await assert.rejects(store.save([]), /secure storage/); + await assert.rejects(store.load(), /secure storage/); + available = true; + document = { version: 1, ciphertext: "AAAA" }; + await assert.rejects(store.load()); +}); + +test("pairing rejects self before network and authenticates identity before publishing", async () => { + let calls = 0; + const store = registry([], { + json: async () => { + calls++; + return {}; + }, + events: async () => {}, + }); + await assert.rejects( + store.pair({ + ...trust, + instanceId: "self", + secret: "b".repeat(43), + expiresAt: new Date(Date.now() + 10000).toISOString(), + }), + /current Aiden/, + ); + assert.equal(calls, 0); + await assert.rejects( + store.pair({ + ...trust, + instanceId: "other", + secret: "b".repeat(43), + expiresAt: new Date(Date.now() + 10000).toISOString(), + }), + /identity/, + ); + assert.deepEqual(await store.list(), []); +}); + +test("a stalled pairing does not delay disconnecting another host, and cancellation fences persistence", async () => { + let release!: (value: unknown) => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const network = new Promise((resolve) => { + release = resolve; + }); + let writes = 0; + const store = registry( + [saved()], + { + json: async () => { + entered(); + return network; + }, + events: async () => {}, + }, + async () => { + writes++; + }, + ); + const controller = new AbortController(); + const pairing = store.pair( + { + ...trust, + instanceId: "host_b", + secret: "b".repeat(43), + expiresAt: new Date(Date.now() + 10000).toISOString(), + }, + controller.signal, + ); + const rejected = assert.rejects(pairing, /cancelled/); + await started; + await store.setEnabled("host_a", false); + assert.equal((await store.list())[0]!.enabled, false); + controller.abort(); + release({}); + await rejected; + assert.equal(writes, 1); + assert.equal((await store.list()).length, 1); +}); + +test("shutdown rejects future operations and cancels pending pairing", async () => { + let release!: (value: unknown) => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const network = new Promise((resolve) => { + release = resolve; + }); + const store = registry([], { + json: async () => { + entered(); + return network; + }, + events: async () => {}, + }); + const pairing = store.pair({ + ...trust, + instanceId: "other", + secret: "b".repeat(43), + expiresAt: new Date(Date.now() + 10000).toISOString(), + }); + const rejected = assert.rejects(pairing, /cancelled/); + await started; + store.close(); + release({}); + await rejected; + await assert.rejects(store.list(), /closed/); +}); diff --git a/main/services/peer-host-registry.ts b/main/services/peer-host-registry.ts new file mode 100644 index 000000000..9b58a9a5c --- /dev/null +++ b/main/services/peer-host-registry.ts @@ -0,0 +1,390 @@ +import { + hostIdentifier, + LOCAL_HOST_ID, + MAX_PEER_HOSTS, + type PeerHostView, +} from "../../renderer/shared/peer-host.js"; +import { + assertPeerPairingExpiry, + peerRecord, + peerStrings, + peerText, + type PeerPairing, +} from "./peer-pairing.js"; +import { + PeerTransport, + validatePeerTrust, + type PeerRequest, + type PeerTrust, +} from "./peer-transport.js"; + +export interface StoredPeerHost extends PeerTrust { + id: string; + name: string; + deviceId: string; + credential: string; + enabled: boolean; + capabilities: string[]; + features: string[]; +} + +export interface PeerHostStorage { + load(): Promise; + save(hosts: StoredPeerHost[], isCurrent?: () => boolean): Promise; +} + +export interface PeerClient { + json(input: PeerRequest): Promise; + events(input: PeerRequest, onFrame: (frame: string) => void): Promise; +} + +export function parseStoredPeerHosts(value: unknown): StoredPeerHost[] { + if (!Array.isArray(value) || value.length > MAX_PEER_HOSTS) + throw new Error("Invalid paired-host registry."); + const ids = new Set(); + return value.map((item) => { + const record = peerRecord(item); + const id = hostIdentifier(record.id); + if ( + ids.has(id) || + id === LOCAL_HOST_ID || + typeof record.enabled !== "boolean" + ) + throw new Error("Invalid paired-host identity."); + ids.add(id); + const credential = peerText(record.credential, 43); + if (!/^[A-Za-z0-9_-]{43}$/u.test(credential)) + throw new Error("Invalid paired-host credential."); + return { + ...validatePeerTrust({ + endpoint: peerText(record.endpoint, 2048), + serverSpkiSha256: peerText(record.serverSpkiSha256, 51), + ...(record.caCertificateDerBase64 === undefined + ? {} + : { + caCertificateDerBase64: peerText( + record.caCertificateDerBase64, + 8192, + ), + }), + }), + id, + credential, + enabled: record.enabled, + name: peerText(record.name, 80), + deviceId: hostIdentifier(record.deviceId), + capabilities: peerStrings(record.capabilities), + features: peerStrings(record.features, 32), + }; + }); +} + +/** No mutable global target: each request captures one authenticated installation. */ +export class PeerHostRegistry { + private hosts: StoredPeerHost[] | undefined; + private tail: Promise = Promise.resolve(); + private active = new Map>(); + private states = new Map(); + private epochs = new Map(); + private verified = new Set(); + private pairings = new Map(); + private closed = false; + private queued = 0; + constructor( + private readonly options: { + storage: PeerHostStorage; + localInstanceId(): Promise; + clientVersion: string; + deviceName: string; + platform: "mac" | "linux"; + client?(trust: PeerTrust): PeerClient; + changed?(): void; + }, + ) {} + + private locked(work: () => Promise): Promise { + if (this.closed) + return Promise.reject(new Error("Device connections are closed.")); + if (this.queued >= 64) + return Promise.reject(new Error("Too many pending device operations.")); + this.queued++; + const run = this.tail + .then(() => { + if (this.closed) throw new Error("Device connections are closed."); + return work(); + }) + .finally(() => { + this.queued--; + }); + this.tail = run.catch(() => undefined); + return run; + } + private async load(): Promise { + if (!this.hosts) + this.hosts = parseStoredPeerHosts(await this.options.storage.load()); + return this.hosts; + } + private client(trust: PeerTrust): PeerClient { + return this.options.client?.(trust) ?? new PeerTransport(trust); + } + private invalidate(id: string): void { + this.verified.delete(id); + this.epochs.set(id, (this.epochs.get(id) ?? 0) + 1); + for (const controller of this.active.get(id) ?? []) controller.abort(); + this.active.delete(id); + } + + private async verify( + host: StoredPeerHost, + client: PeerClient, + signal: AbortSignal, + current: () => boolean, + ): Promise { + if (this.verified.has(host.id)) return; + // Only completed verification is shared. Concurrent callers retain independent cancellation. + const server = peerRecord( + await client.json({ + path: "/server", + credential: host.credential, + signal, + }), + ); + if (!current()) throw new Error("Device operation was superseded."); + if (server.protocolVersion !== 1 || server.instanceId !== host.id) + throw new Error( + "The paired server identity changed. Pair this device again.", + ); + peerStrings(server.capabilities); + peerStrings(server.features ?? [], 32); + this.verified.add(host.id); + } + private view(host: StoredPeerHost): PeerHostView { + return { + id: host.id, + name: host.name, + enabled: host.enabled, + state: host.enabled + ? (this.states.get(host.id) ?? "disconnected") + : "disabled", + features: [...host.features], + capabilities: [...host.capabilities], + }; + } + list(): Promise { + return this.locked(async () => + (await this.load()).map((host) => this.view(host)), + ); + } + + async pair( + pairing: PeerPairing, + signal?: AbortSignal, + ): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (signal?.aborted) abort(); + signal?.addEventListener("abort", abort, { once: true }); + const current = () => !this.closed && !controller.signal.aborted; + const assertCurrent = () => { + if (!current()) throw new Error("Pairing was cancelled."); + }; + let reserved = false; + try { + await this.locked(async () => { + assertCurrent(); + const hosts = await this.load(); + if ( + pairing.instanceId === (await this.options.localInstanceId()) || + pairing.instanceId === LOCAL_HOST_ID + ) + throw new Error("This is the current Aiden installation."); + assertCurrent(); + if ( + hosts.some((host) => host.id === pairing.instanceId) || + this.pairings.has(pairing.instanceId) + ) + throw new Error("This device is already paired or pairing."); + if ( + hosts.length + this.pairings.size >= MAX_PEER_HOSTS || + this.pairings.size >= 2 + ) + throw new Error( + "The saved or pairing device limit has been reached.", + ); + assertPeerPairingExpiry(pairing.expiresAt); + this.pairings.set(pairing.instanceId, controller); + reserved = true; + }); + const client = this.client(pairing); + const exchange = peerRecord( + await client.json({ + method: "POST", + path: "/pairing/exchange", + signal: controller.signal, + body: { + secret: pairing.secret, + deviceName: this.options.deviceName, + deviceType: this.options.platform, + clientVersion: this.options.clientVersion, + acceptsDisplayName: true, + }, + }), + ); + assertCurrent(); + if ( + exchange.protocolVersion !== 1 || + exchange.instanceId !== pairing.instanceId || + exchange.endpoint !== pairing.endpoint || + exchange.serverSpkiSha256 !== pairing.serverSpkiSha256 + ) + throw new Error("The pairing identity did not match."); + const credential = peerText(exchange.credential, 43); + const server = peerRecord( + await client.json({ + path: "/server", + credential, + signal: controller.signal, + }), + ); + assertCurrent(); + if ( + server.instanceId !== pairing.instanceId || + server.protocolVersion !== 1 + ) + throw new Error("The paired server identity changed."); + const grants = peerStrings(exchange.capabilities); + const serverGrants = peerStrings(server.capabilities); + const host = parseStoredPeerHosts([ + { + id: pairing.instanceId, + name: peerText(server.name, 80), + deviceId: exchange.deviceId, + credential, + enabled: true, + endpoint: pairing.endpoint, + serverSpkiSha256: pairing.serverSpkiSha256, + ...(pairing.caCertificateDerBase64 + ? { caCertificateDerBase64: pairing.caCertificateDerBase64 } + : {}), + capabilities: grants.filter((grant) => serverGrants.includes(grant)), + features: peerStrings(server.features ?? [], 32), + }, + ])[0]!; + return await this.locked(async () => { + assertCurrent(); + const hosts = await this.load(); + if ( + hosts.some((entry) => entry.id === host.id) || + hosts.length >= MAX_PEER_HOSTS + ) + throw new Error("Paired devices changed during pairing."); + await this.options.storage.save([...hosts, host], current); + this.hosts = [...hosts, host]; + this.states.set(host.id, "connected"); + this.options.changed?.(); + return this.view(host); + }); + } finally { + signal?.removeEventListener("abort", abort); + if (reserved) this.pairings.delete(pairing.instanceId); + } + } + + setEnabled(id: string, enabled: boolean): Promise { + return this.locked(async () => { + const hosts = await this.load(); + if (!hosts.some((host) => host.id === id)) + throw new Error("Unknown paired device."); + const next = hosts.map((host) => + host.id === id ? { ...host, enabled } : host, + ); + await this.options.storage.save(next); + this.hosts = next; + this.invalidate(id); + this.states.set(id, "disconnected"); + this.options.changed?.(); + }); + } + remove(id: string): Promise { + return this.locked(async () => { + const next = (await this.load()).filter((host) => host.id !== id); + await this.options.storage.save(next); + this.hosts = next; + this.invalidate(id); + this.states.delete(id); + this.epochs.delete(id); + this.options.changed?.(); + }); + } + + async request( + id: string, + input: Omit, + onFrame?: (frame: string) => void, + ): Promise { + const { host, epoch, controller } = await this.locked(async () => { + const found = (await this.load()).find( + (candidate) => candidate.id === hostIdentifier(id), + ); + if (this.closed) throw new Error("Device connections are closed."); + if (!found?.enabled) + throw new Error("This device is disabled or unavailable."); + const pending = this.active.get(id) ?? new Set(); + if ( + pending.size >= 8 || + [...this.active.values()].reduce((sum, set) => sum + set.size, 0) >= 32 + ) + throw new Error("Too many pending device operations."); + const controller = new AbortController(); + pending.add(controller); + this.active.set(id, pending); + return { + host: { ...found }, + epoch: this.epochs.get(id) ?? 0, + controller, + }; + }); + const abort = () => controller.abort(); + if (input.signal?.aborted) abort(); + input.signal?.addEventListener("abort", abort, { once: true }); + const current = () => + !this.closed && + !controller.signal.aborted && + (this.epochs.get(id) ?? 0) === epoch; + try { + if (!current()) throw new Error("Device operation was superseded."); + const client = this.client(host); + await this.verify(host, client, controller.signal, current); + if (!current()) throw new Error("Device operation was superseded."); + const request = { + ...input, + credential: host.credential, + signal: controller.signal, + }; + const result = onFrame + ? await client.events(request, (frame) => { + if (current()) onFrame(frame); + }) + : await client.json(request); + if (!current()) throw new Error("Device operation was superseded."); + this.states.set(id, "connected"); + return result; + } catch (error) { + if (current()) { + this.states.set(id, "unavailable"); + this.verified.delete(id); + } + throw error; + } finally { + input.signal?.removeEventListener("abort", abort); + this.active.get(id)?.delete(controller); + if (this.active.get(id)?.size === 0) this.active.delete(id); + } + } + + close(): void { + this.closed = true; + for (const controller of this.pairings.values()) controller.abort(); + for (const id of this.active.keys()) this.invalidate(id); + } +} diff --git a/main/services/peer-host-service-main.ts b/main/services/peer-host-service-main.ts new file mode 100644 index 000000000..00747e2f6 --- /dev/null +++ b/main/services/peer-host-service-main.ts @@ -0,0 +1,66 @@ +import os from "node:os"; +import { app, ipcMain, safeStorage } from "../platform.js"; +import { DataStore } from "./data-store.js"; +import { + EncryptedPeerHostStorage, + type PeerEncryptedDocument, +} from "./peer-host-storage.js"; +import { PeerHostRegistry } from "./peer-host-registry.js"; +import { getAidenRemoteRuntime } from "./aiden-remote-service-main.js"; + +let registry: PeerHostRegistry | undefined; + +export function getPeerHostRegistry(): PeerHostRegistry { + if (registry) return registry; + const store = new DataStore( + "paired-hosts.json", + { version: 1, ciphertext: null }, + () => app.getPath("userData"), + { + maxBytes: 600000, + fileMode: 0o600, + rejectCorruptWrite: true, + rejectUnsafeWrite: true, + isSafe: (value) => + !!value && + typeof value === "object" && + (value as PeerEncryptedDocument).version === 1 && + ((value as PeerEncryptedDocument).ciphertext === null || + typeof (value as PeerEncryptedDocument).ciphertext === "string"), + }, + ); + registry = new PeerHostRegistry({ + storage: new EncryptedPeerHostStorage( + { + load: async () => { + const value = await store.load(); + if ( + (await store.loadedFromCorruptFile()) || + (await store.loadedFromUnsafeFile()) + ) + throw new Error("Paired-device storage needs recovery."); + return value; + }, + save: (value, isCurrent) => store.save(value, isCurrent), + }, + { + isEncryptionAvailable: () => + safeStorage.isEncryptionAvailable() && + (process.platform !== "linux" || + !["basic_text", "unknown"].includes( + safeStorage.getSelectedStorageBackend(), + )), + encryptString: (value) => safeStorage.encryptString(value), + decryptString: (value) => safeStorage.decryptString(value), + }, + ), + localInstanceId: async () => + (await (await getAidenRemoteRuntime()).state.snapshot()).instanceId, + deviceName: os.hostname().slice(0, 80) || "Aiden desktop", + clientVersion: app.getVersion(), + platform: process.platform === "linux" ? "linux" : "mac", + changed: () => ipcMain.broadcast("remote:peers-changed", {}), + }); + app.once("before-quit", () => registry?.close()); + return registry; +} diff --git a/main/services/peer-host-storage.ts b/main/services/peer-host-storage.ts new file mode 100644 index 000000000..f7c52592e --- /dev/null +++ b/main/services/peer-host-storage.ts @@ -0,0 +1,61 @@ +import type { CredentialCipher } from "./pi-credential-store-core.js"; +import { + parseStoredPeerHosts, + type PeerHostStorage, + type StoredPeerHost, +} from "./peer-host-registry.js"; + +export interface PeerEncryptedDocument { + version: 1; + ciphertext: string | null; +} + +/** One atomic encrypted document avoids a registry/credential two-file commit gap. */ +export class EncryptedPeerHostStorage implements PeerHostStorage { + constructor( + private readonly storage: { + load(): Promise; + save( + value: PeerEncryptedDocument, + isCurrent?: () => boolean, + ): Promise; + }, + private readonly cipher: CredentialCipher, + ) {} + + async load(): Promise { + const document = await this.storage.load(); + if (document.version !== 1) + throw new Error("Unsupported paired-device store."); + if (document.ciphertext === null) return []; + if ( + typeof document.ciphertext !== "string" || + document.ciphertext.length > 524288 + ) + throw new Error("Invalid paired-device store."); + if (!(await this.cipher.isEncryptionAvailable())) + throw new Error("Unlock secure storage to access paired devices."); + const serialized = await this.cipher.decryptString( + Buffer.from(document.ciphertext, "base64"), + ); + if (Buffer.byteLength(serialized) > 262144) + throw new Error("Paired-device store exceeds its limit."); + return parseStoredPeerHosts(JSON.parse(serialized)); + } + + async save( + hosts: StoredPeerHost[], + isCurrent: () => boolean = () => true, + ): Promise { + const value = JSON.stringify(parseStoredPeerHosts(hosts)); + if (Buffer.byteLength(value) > 262144) + throw new Error("Paired-device store exceeds its limit."); + if (!(await this.cipher.isEncryptionAvailable())) + throw new Error("Unlock secure storage before pairing devices."); + const ciphertext = (await this.cipher.encryptString(value)).toString( + "base64", + ); + if (!isCurrent()) throw new Error("Pairing was cancelled."); + await this.storage.save({ version: 1, ciphertext }, isCurrent); + } +} diff --git a/main/services/peer-operation.ts b/main/services/peer-operation.ts new file mode 100644 index 000000000..7fdf9bc7e --- /dev/null +++ b/main/services/peer-operation.ts @@ -0,0 +1,150 @@ +import { validatePeerResponse } from "./peer-response.js"; +import { hostIdentifier } from "../../renderer/shared/peer-host.js"; +import type { PeerOperation } from "../../renderer/shared/peer-operation.js"; +import { peerRecord, peerText } from "./peer-pairing.js"; +import type { PeerRequest } from "./peer-transport.js"; +import { + parseAidenRemoteChatProjection, + parseAidenRemoteChatSummaryPage, +} from "./aiden-remote-protocol.js"; + +/** Pairing credentials never cross back into renderer operation results. */ +export function peerOperationResult( + operation: unknown, + value: unknown, +): unknown { + const input = peerRecord(operation); + validatePeerResponse(peerText(input.operation, 40), value); + if (input.operation === "summaries") + return parseAidenRemoteChatSummaryPage(value, "Peer chat summaries"); + if ( + input.operation === "chat" || + input.operation === "createChat" || + input.operation === "renameChat" + ) + return parseAidenRemoteChatProjection(value, "Peer chat"); + return value; +} + +/** Renderer selects a closed operation, never an arbitrary URL, header or credential. */ +export function peerOperationRequest( + value: unknown, +): Omit { + const input = peerRecord(value); + if ( + Object.keys(input).some( + (key) => + ![ + "operation", + "resourceId", + "workspaceId", + "cursor", + "body", + "idempotencyKey", + "revision", + ].includes(key), + ) + ) + throw new Error("Unknown peer operation field."); + const operation = peerText(input.operation, 40) as PeerOperation["operation"]; + const id = () => encodeURIComponent(hostIdentifier(input.resourceId)); + const cursor = () => encodeURIComponent(peerText(input.cursor, 512)); + let path: string; + let method: PeerRequest["method"] = "GET"; + let needsKey = false; + let needsRevision = false; + switch (operation) { + case "server": + path = "/server"; + break; + case "summaries": + path = `/chat-summaries${input.cursor ? `?cursor=${cursor()}` : ""}`; + break; + case "workspaces": + path = "/workspaces"; + break; + case "models": + path = "/models"; + break; + case "chat": + path = `/chats/${id()}`; + break; + case "roots": + path = "/workspace-browser/roots"; + break; + case "children": + path = `/workspace-browser/children?location=${id()}${input.cursor ? `&cursor=${cursor()}` : ""}`; + break; + case "stream": + path = `/streams/${id()}`; + break; + case "approval": + path = `/streams/${id()}/approval`; + break; + case "files": + path = `/workspaces/${id()}/files`; + break; + case "file": + path = `/workspaces/${encodeURIComponent(hostIdentifier(input.workspaceId))}/files/${id()}`; + break; + case "git": + path = `/workspaces/${id()}/git/review`; + break; + case "createChat": + path = "/chats"; + method = "POST"; + needsKey = true; + break; + case "send": + path = `/chats/${id()}/turns`; + method = "POST"; + needsKey = true; + break; + case "renameChat": + path = `/chats/${id()}`; + method = "PATCH"; + needsRevision = true; + break; + case "deleteChat": + path = `/chats/${id()}`; + method = "DELETE"; + needsRevision = true; + break; + case "cancel": + path = `/streams/${id()}/cancel`; + method = "POST"; + needsKey = true; + break; + case "respondApproval": + path = `/approvals/${id()}/respond`; + method = "POST"; + needsKey = true; + break; + case "selectFolder": + path = "/workspace-browser/selections"; + method = "POST"; + break; + case "createWorkspace": + path = "/workspaces"; + method = "POST"; + needsKey = true; + break; + default: + throw new Error("Unsupported peer operation."); + } + const token = (value: unknown, min: number) => { + const text = peerText(value, 128); + if (text.length < min || !/^[\x21-\x7e]+$/u.test(text)) + throw new Error("Invalid peer operation token."); + return text; + }; + return { + path, + method, + ...(method !== "GET" && method !== "DELETE" + ? { body: input.body ?? {} } + : {}), + ...(needsKey ? { idempotencyKey: token(input.idempotencyKey, 16) } : {}), + ...(needsRevision ? { revision: token(input.revision, 1) } : {}), + }; +} diff --git a/main/services/peer-pairing.ts b/main/services/peer-pairing.ts new file mode 100644 index 000000000..a151e3811 --- /dev/null +++ b/main/services/peer-pairing.ts @@ -0,0 +1,142 @@ +import { createDecipheriv, hkdfSync } from "node:crypto"; +import { hostIdentifier } from "../../renderer/shared/peer-host.js"; +import { normalizeAidenManualPairingCode } from "./aiden-remote-pairing.js"; +import { parseAidenRemoteJson } from "./aiden-remote-protocol.js"; +import { validatePeerTrust, type PeerTrust } from "./peer-transport.js"; + +export function peerRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Invalid peer response."); + return value as Record; +} + +export function peerText(value: unknown, max: number): string { + if (typeof value !== "string" || !value.length || value.length > max) + throw new Error("Invalid peer text."); + return value; +} + +export function peerStrings(value: unknown, max = 64): string[] { + if (!Array.isArray(value) || value.length > max) + throw new Error("Invalid peer capabilities."); + return [...new Set(value.map((item) => peerText(item, 80)))]; +} + +export interface PeerPairing extends PeerTrust { + instanceId: string; + secret: string; + expiresAt: string; +} + +/** Server exchange remains authoritative; tolerate two minutes of client clock skew. */ +export function assertPeerPairingExpiry( + expiresAt: string, + now = Date.now(), +): void { + const expiry = Date.parse(expiresAt); + if ( + !Number.isFinite(expiry) || + expiry < now - 120_000 || + expiry > now + 420_000 + ) + throw new Error("Pairing code expired or invalid."); +} + +export function parsePeerPairing( + payload: string, + now = Date.now(), +): PeerPairing { + if (Buffer.byteLength(payload) > 4096) + throw new Error("Pairing payload is too large."); + const record = peerRecord(parseAidenRemoteJson(payload, "pairing payload")); + if (record.kind !== "aiden-pairing-v1") + throw new Error("Invalid pairing payload."); + const bootstrap = peerRecord(record.bootstrap); + const trust = peerRecord(record.trust); + if ( + bootstrap.protocolVersion !== 1 || + (trust.mode !== "private-ca" && trust.mode !== "system") + ) + throw new Error("Unsupported pairing protocol."); + const expiresAt = peerText(bootstrap.expiresAt, 40); + assertPeerPairingExpiry(expiresAt, now); + const secret = peerText(bootstrap.secret, 43); + if (!/^[A-Za-z0-9_-]{43}$/u.test(secret)) + throw new Error("Invalid pairing secret."); + return { + ...validatePeerTrust({ + endpoint: peerText(bootstrap.endpoint, 2048), + serverSpkiSha256: peerText(bootstrap.serverSpkiSha256, 51), + ...(trust.mode === "private-ca" + ? { + caCertificateDerBase64: peerText( + trust.caCertificateDerBase64, + 8192, + ), + } + : {}), + }), + instanceId: hostIdentifier(bootstrap.instanceId), + secret, + expiresAt, + }; +} + +function base64(value: unknown, length: number): Buffer { + const text = peerText(value, 8192); + const bytes = Buffer.from(text, "base64url"); + if (bytes.length !== length || bytes.toString("base64url") !== text) + throw new Error("Invalid sealed pairing envelope."); + return bytes; +} + +/** The setup code authenticates the entire trust payload before any credential exchange. */ +export function decryptPeerPairing( + value: unknown, + code: string, + endpoint: string, + now = Date.now(), +): PeerPairing { + const envelope = peerRecord(value); + if ( + envelope.kind !== "aiden-manual-pairing-v1" || + envelope.protocolVersion !== 1 + ) + throw new Error("Invalid sealed pairing envelope."); + const sessionId = hostIdentifier(envelope.sessionId); + const expiresAt = peerText(envelope.expiresAt, 40); + const kind = "aiden-manual-pairing-v1"; + const key = Buffer.from( + hkdfSync( + "sha256", + Buffer.from(normalizeAidenManualPairingCode(code), "ascii"), + base64(envelope.salt, 16), + Buffer.from(`${kind}\n${sessionId}`), + 32, + ), + ); + try { + const decipher = createDecipheriv( + "aes-256-gcm", + key, + base64(envelope.nonce, 12), + ); + decipher.setAAD(Buffer.from(`${kind}\n${sessionId}\n${expiresAt}`)); + decipher.setAuthTag(base64(envelope.tag, 16)); + const ciphertext = Buffer.from( + peerText(envelope.ciphertext, 6000), + "base64url", + ); + if (ciphertext.length > 4096) throw new Error("Pairing payload too large."); + const payload = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + const pairing = parsePeerPairing(payload.toString("utf8"), now); + if (pairing.endpoint !== endpoint || pairing.expiresAt !== expiresAt) + throw new Error("Pairing endpoint mismatch."); + return pairing; + } finally { + key.fill(0); + } +} diff --git a/main/services/peer-response.ts b/main/services/peer-response.ts new file mode 100644 index 000000000..c9673089e --- /dev/null +++ b/main/services/peer-response.ts @@ -0,0 +1,107 @@ +import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import protocol from "../../protocol/aiden-remote/v1/openapi.json"; +import type { PeerOperation } from "../../renderer/shared/peer-operation.js"; + +const operationIds: Record = { + server: "getServer", + summaries: "listChatSummaries", + workspaces: "listWorkspaces", + models: "listModels", + chat: "getChat", + roots: "listWorkspaceBrowserRoots", + children: "listWorkspaceBrowserChildren", + stream: "getStream", + approval: "getStreamApproval", + files: "listWorkspaceFiles", + file: "readWorkspaceFile", + git: "reviewGitWorkspace", + createChat: "createChat", + send: "startTurn", + renameChat: "updateChat", + deleteChat: "deleteChat", + cancel: "cancelStream", + respondApproval: "respondApproval", + selectFolder: "createWorkspaceSelection", + createWorkspace: "createWorkspace", +}; +let ajv: Ajv2020 | undefined; +const validators = new Map(); +function validator(operation: string): ValidateFunction | null { + if (!Object.prototype.hasOwnProperty.call(operationIds, operation)) + throw new Error("Unsupported peer operation."); + if (validators.has(operation)) return validators.get(operation)!; + if (!ajv) { + ajv = new Ajv2020({ + strict: false, + allErrors: false, + validateFormats: true, + }); + addFormats(ajv); + ajv.addSchema({ + $id: "urn:aiden:peer", + components: protocol.components, + paths: protocol.paths, + }); + } + const id = operationIds[operation as PeerOperation["operation"]]; + for (const [path, methods] of Object.entries(protocol.paths)) { + for (const [method, endpoint] of Object.entries(methods)) { + if ( + !endpoint || + typeof endpoint !== "object" || + !("operationId" in endpoint) || + endpoint.operationId !== id + ) + continue; + const responses = (endpoint as { responses: Record }) + .responses; + const status = Object.keys(responses).find((code) => + /^2\d\d$/u.test(code), + ); + if (!status) break; + if (status === "204") { + validators.set(operation, null); + return null; + } + // Compile from the checked-in protocol, including its local component references. + const pointer = path.replace(/~/gu, "~0").replace(/\//gu, "~1"); + const result = ajv.compile({ + $ref: `urn:aiden:peer#/paths/${pointer}/${method}/responses/${status}/content/application~1json/schema`, + }); + validators.set(operation, result); + return result; + } + } + throw new Error("Missing peer response contract."); +} + +/** Validate the entire protocol envelope; content fields are not scanned for secret-like words. */ +export function validatePeerResponse(operation: string, value: unknown): void { + let nodes = 0; + let bytes = 0; + const visit = (node: unknown, depth: number): void => { + if (++nodes > 50_000 || depth > 64) + throw new Error("Peer response exceeds structural limits."); + if (typeof node === "string") bytes += Buffer.byteLength(node); + else if (Array.isArray(node)) { + if (node.length > 4000) + throw new Error("Peer response array exceeds its limit."); + for (const child of node) visit(child, depth + 1); + } else if (node && typeof node === "object") { + const entries = Object.entries(node); + if (entries.length > 256) + throw new Error("Peer response object exceeds its limit."); + for (const [key, child] of entries) { + bytes += Buffer.byteLength(key); + visit(child, depth + 1); + } + } + if (bytes > 1_048_576) + throw new Error("Peer response exceeds its byte limit."); + }; + visit(value, 0); + const check = validator(operation); + if (check === null ? value !== undefined : !check(value)) + throw new Error("Invalid peer response contract."); +} diff --git a/main/services/peer-transport.test.ts b/main/services/peer-transport.test.ts new file mode 100644 index 000000000..32f5b7912 --- /dev/null +++ b/main/services/peer-transport.test.ts @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import https from "node:https"; +import type { ServerResponse } from "node:http"; +import { X509Certificate } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; +import { PeerTransport, PeerEventFrames } from "./peer-transport.js"; +import { decryptPeerPairing, assertPeerPairingExpiry } from "./peer-pairing.js"; +import { peerOperationRequest, peerOperationResult } from "./peer-operation.js"; +import { createAidenRemoteRequestHandler } from "./aiden-remote-router.js"; +import { AidenRemotePairingService } from "./aiden-remote-pairing.js"; +import { PeerHostRegistry, type StoredPeerHost } from "./peer-host-registry.js"; + +test("desktop manual pairing consumes the native clients' canonical cryptographic vector", async () => { + const vector = JSON.parse( + await readFile( + new URL( + "../../protocol/aiden-remote/v1/fixtures/manual-pairing-vector.json", + import.meta.url, + ), + "utf8", + ), + ); + const envelope = vector.bootstrap; + const endpoint = JSON.parse(vector.payload).bootstrap.endpoint; + const decoded = decryptPeerPairing( + envelope, + vector.code, + endpoint, + Date.parse(envelope.expiresAt) - 1000, + ); + assert.equal(decoded.endpoint, endpoint); + assert.throws(() => + decryptPeerPairing(envelope, "0000-0000-0000-0000-0000", endpoint), + ); +}); + +test("closed peer operations reject injected routes and require mutation identity", () => { + for (const operation of ["cancel", "respondApproval"]) { + assert.throws(() => + peerOperationRequest({ operation, resourceId: "resource" }), + ); + assert.equal( + peerOperationRequest({ + operation, + resourceId: "resource", + idempotencyKey: "k".repeat(32), + }).idempotencyKey, + "k".repeat(32), + ); + } + assert.throws(() => + peerOperationRequest({ operation: "send", resourceId: "chat" }), + ); + assert.throws(() => + peerOperationRequest({ operation: "server", path: "/secrets" }), + ); + assert.throws(() => peerOperationRequest({ operation: "unknown" })); + assert.deepEqual( + peerOperationRequest({ + operation: "file", + workspaceId: "work", + resourceId: "file_handle", + }), + { method: "GET", path: "/workspaces/work/files/file_handle" }, + ); + assert.throws( + () => + peerOperationResult( + { operation: "server" }, + { nested: { credential: "secret" } }, + ), + /response contract/, + ); + assert.throws(() => + peerOperationResult({ operation: "chat" }, { id: "missing-fields" }), + ); +}); + +test("real HTTPS verifies CA and SPKI, rejects redirects/oversized JSON, and parses chunked SSE", async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "aiden-peer-test-")); + const identity = await loadOrCreateAidenRemoteTlsIdentity({ directory }); + const pairingService = new AidenRemotePairingService("host_tls", { + issueDevice: async (input) => ({ + credential: "a".repeat(43), + device: { + id: "device_tls", + name: input.name, + type: input.type, + clientVersion: input.clientVersion, + capabilities: [...input.capabilities!], + createdAt: Date.now(), + lastSeenAt: 0, + }, + }), + }); + const actualRouter = createAidenRemoteRequestHandler({ + instanceId: "host_tls", + displayName: () => "TLS fixture", + appVersion: "1", + pairing: pairingService, + devices: { + acquireDeviceAuthorization: () => () => undefined, + authenticate: async (credential) => + credential === "a".repeat(43) + ? { + id: "device_tls", + name: "Desktop", + revoked: false, + acceptsBotCapabilities: false, + capabilities: new Set(["server:read"]), + } + : null, + }, + connectionMode: () => "lan", + now: Date.now, + log: () => undefined, + }); + let redirected = 0; + let liveResponse: ServerResponse | undefined; + const server = https.createServer( + { key: identity.privateKey, cert: identity.certificateChain }, + (request, response) => { + if ( + request.url?.endsWith("/pairing/exchange") || + request.url?.endsWith("/server") + ) { + void actualRouter(request, response); + return; + } + if (request.url?.endsWith("/redirect")) { + response.writeHead(302, { Location: "/target" }); + response.end(); + return; + } + if (request.url === "/target") redirected++; + if (request.url?.endsWith("/live")) { + liveResponse = response; + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write("data: start\n\n"); + return; + } + if (request.url?.endsWith("/events")) { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write(': hello\n\nid: 1\ndata: {"text":'); + response.end('"hello"}\n\n'); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + request.url?.endsWith("/large") + ? JSON.stringify({ text: "a".repeat(1_048_576) }) + : '{"ok":true}', + ); + }, + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const trust = { + endpoint: `https://127.0.0.1:${address.port}/api/aiden/v1`, + serverSpkiSha256: identity.serverSpkiSha256, + caCertificateDerBase64: new X509Certificate( + identity.caCertificate, + ).raw.toString("base64"), + }; + try { + const client = new PeerTransport(trust); + assert.equal( + ( + (await client.json({ + path: "/server", + credential: "a".repeat(43), + })) as { name: string } + ).name, + "TLS fixture", + ); + let savedHosts: StoredPeerHost[] = []; + const registry = new PeerHostRegistry({ + storage: { + load: async () => [], + save: async (hosts) => { + savedHosts = hosts; + }, + }, + localInstanceId: async () => "different_local_host", + deviceName: "Desktop", + clientVersion: "1", + platform: "mac", + }); + const opened = pairingService.begin(trust.endpoint, trust.serverSpkiSha256); + const view = await registry.pair({ ...trust, ...opened.bootstrap }); + assert.equal(view.id, "host_tls"); + assert.equal(savedHosts.length, 1); + assert.equal(view.state, "connected"); + registry.close(); + await assert.rejects( + new PeerTransport({ + ...trust, + serverSpkiSha256: `sha256/${Buffer.alloc(32).toString("base64")}`, + }).json({ path: "/server" }), + ); + await assert.rejects( + client.json({ path: "/redirect", credential: "a".repeat(43) }), + ); + assert.equal(redirected, 0); + await assert.rejects(client.json({ path: "/large" })); + const frames: string[] = []; + await client.events({ path: "/events" }, (frame) => frames.push(frame)); + assert.deepEqual(frames, ['id: 1\ndata: {"text":"hello"}']); + t.mock.timers.enable({ apis: ["setTimeout"] }); + let received!: () => void; + let nextFrame = new Promise((resolve) => { + received = resolve; + }); + const live = client.events({ path: "/live" }, () => received()); + const rejected = assert.rejects(live); + await nextFrame; + // Completed frames keep the frame deadline alive, but cannot extend the session cap. + for (let i = 0; i < 14; i++) { + t.mock.timers.tick(20_000); + nextFrame = new Promise((resolve) => { + received = resolve; + }); + liveResponse!.write("data: pulse\n\n"); + await nextFrame; + } + t.mock.timers.tick(20_000); + await rejected; + // Even a peer that has sent headers and one valid frame must finish its next frame. + nextFrame = new Promise((resolve) => { + received = resolve; + }); + const stalled = client.events({ path: "/live" }, () => received()); + const stalledRejected = assert.rejects(stalled); + await nextFrame; + liveResponse!.write("data: partial"); + t.mock.timers.tick(30_000); + await stalledRejected; + t.mock.timers.reset(); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } +}); + +test( + "SSE one-byte trickles and heartbeat floods have bounded work and size", + { timeout: 5000 }, + () => { + const parser = new PeerEventFrames(); + const one = Buffer.from("a"); + for (let i = 0; i < 1_048_576; i++) + parser.push( + one, + () => {}, + () => {}, + ); + assert.throws( + () => + parser.push( + Buffer.from("aaaaa"), + () => {}, + () => {}, + ), + /large/, + ); + const flood = new PeerEventFrames(); + let boundaries = 0; + assert.throws( + () => + flood.push( + Buffer.from(": ping\n\n".repeat(16_385)), + () => { + throw new Error("Heartbeat emitted"); + }, + () => { + boundaries++; + }, + ), + /budget/, + ); + assert.equal(boundaries, 16_384); + const split = new PeerEventFrames(); + const frames: string[] = []; + for (const byte of Buffer.from("data: ☃\r\n\r\n")) + split.push( + Buffer.from([byte]), + (frame) => frames.push(frame), + () => {}, + ); + split.end(); + assert.deepEqual(frames, ["data: ☃"]); + }, +); + +test("pairing tolerates bounded skew but rejects nonsense expiry", () => { + const now = Date.now(); + for (const delta of [-120_000, 0, 300_000, 420_000]) + assertPeerPairingExpiry(new Date(now + delta).toISOString(), now); + for (const delta of [-120_001, 420_001]) + assert.throws(() => + assertPeerPairingExpiry(new Date(now + delta).toISOString(), now), + ); + assert.throws(() => assertPeerPairingExpiry("nonsense", now)); +}); + +test("every exposed operation rejects malformed DTOs and preserves legitimate content", () => { + const operations = [ + "server", + "summaries", + "workspaces", + "models", + "chat", + "roots", + "children", + "stream", + "approval", + "files", + "file", + "git", + "createChat", + "send", + "renameChat", + "deleteChat", + "cancel", + "respondApproval", + "selectFolder", + "createWorkspace", + ]; + for (const operation of operations) + assert.throws(() => peerOperationResult({ operation }, {}), operation); + const models = { + providers: [], + defaults: { secret: "model-id", headers: "another-model" }, + }; + assert.deepEqual( + peerOperationResult({ operation: "models" }, models), + models, + ); + assert.deepEqual( + peerOperationResult({ operation: "approval" }, { approval: null }), + { approval: null }, + ); + assert.throws(() => + peerOperationResult( + { operation: "approval" }, + { approval: null, credential: "private" }, + ), + ); + assert.equal( + peerOperationResult({ operation: "deleteChat" }, undefined), + undefined, + ); +}); diff --git a/main/services/peer-transport.ts b/main/services/peer-transport.ts new file mode 100644 index 000000000..a0bfb4c3a --- /dev/null +++ b/main/services/peer-transport.ts @@ -0,0 +1,333 @@ +import https from "node:https"; +import { checkServerIdentity } from "node:tls"; +import { createHash, X509Certificate } from "node:crypto"; +import { TextDecoder } from "node:util"; +import { + assertAidenRemoteEndpoint, + parseAidenRemoteJson, +} from "./aiden-remote-protocol.js"; + +export interface PeerTrust { + endpoint: string; + serverSpkiSha256: string; + caCertificateDerBase64?: string; +} + +export interface PeerRequest { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + path: string; + credential?: string; + body?: unknown; + idempotencyKey?: string; + revision?: string; + signal?: AbortSignal; +} + +const MAX_JSON_BYTES = 1_048_576; +const MAX_FRAME_BYTES = 1_048_576; +const DEADLINE_MS = 30_000; + +/** Linear byte scanner; each input byte is visited once, including one-byte trickles. */ +export class PeerEventFrames { + private buffer = Buffer.allocUnsafe(MAX_FRAME_BYTES + 4); + private length = 0; + private previousLf = false; + private betweenCr = false; + private total = 0; + private frames = 0; + private decoder = new TextDecoder("utf-8", { fatal: true }); + push( + chunk: Buffer, + onFrame: (frame: string) => void, + onBoundary: () => void, + ): void { + this.total += chunk.length; + if (this.total > 16 * MAX_FRAME_BYTES) + throw new Error("Stream byte budget exceeded."); + for (const byte of chunk) { + if (this.length >= this.buffer.length) + throw new Error("Frame too large."); + this.buffer[this.length++] = byte; + if (byte === 10 && this.previousLf) { + if (++this.frames > 16_384) + throw new Error("Stream frame budget exceeded."); + const raw = this.buffer.subarray(0, this.length); + const frame = this.decoder.decode(raw).replace(/\r?\n\r?\n$/u, ""); + if (Buffer.byteLength(frame) > MAX_FRAME_BYTES) + throw new Error("Frame too large."); + this.length = 0; + this.previousLf = false; + this.betweenCr = false; + onBoundary(); + if ( + frame && + !frame.split(/\r?\n/u).every((line) => line.startsWith(":")) + ) + onFrame(frame); + } else if (byte === 10) { + this.previousLf = true; + this.betweenCr = false; + } else if (byte === 13 && this.previousLf && !this.betweenCr) { + this.betweenCr = true; + } else { + this.previousLf = false; + this.betweenCr = false; + } + } + } + end(): void { + if (this.length) throw new Error("Incomplete SSE frame."); + } +} + +export class PeerTransportError extends Error { + constructor( + readonly code: + | "unavailable" + | "invalid_response" + | "authentication_required" + | "request_failed", + readonly status?: number, + ) { + super( + code === "authentication_required" + ? "Pair this device again to reconnect." + : "The other device could not complete this request.", + ); + } +} + +export function validatePeerTrust(trust: PeerTrust): PeerTrust { + assertAidenRemoteEndpoint(trust.endpoint); + if (!/^sha256\/[A-Za-z0-9+/]{43}=$/u.test(trust.serverSpkiSha256)) { + throw new Error("Invalid server identity."); + } + if (trust.caCertificateDerBase64 !== undefined) { + if ( + trust.caCertificateDerBase64.length > 8192 || + !/^[A-Za-z0-9+/]+={0,2}$/u.test(trust.caCertificateDerBase64) + ) { + throw new Error("Invalid server trust certificate."); + } + const cert = new X509Certificate( + Buffer.from(trust.caCertificateDerBase64, "base64"), + ); + if (!cert.ca) throw new Error("Server trust certificate must be a CA."); + } + return { ...trust }; +} + +/** Route fragments must not escape the fixed API prefix or introduce another authority. */ +export function peerRequestUrl(endpoint: string, route: string): URL { + assertAidenRemoteEndpoint(endpoint); + if ( + route.length > 4096 || + !route.startsWith("/") || + route.startsWith("//") || + /[\\#\r\n]/u.test(route) + ) { + throw new Error("Invalid peer operation path."); + } + const result = new URL(`${endpoint}${route}`); + const base = new URL(endpoint); + if ( + result.origin !== base.origin || + !result.pathname.startsWith(`${base.pathname}/`) + ) { + throw new Error("Peer operation escaped its API endpoint."); + } + return result; +} + +function headers( + input: PeerRequest, + streaming: boolean, +): Record { + const result: Record = { + "Aiden-Protocol-Version": "1", + Accept: streaming ? "text/event-stream" : "application/json", + "Accept-Encoding": "identity", + }; + if (input.credential !== undefined) { + if (!/^[A-Za-z0-9_-]{32,256}$/u.test(input.credential)) + throw new Error("Invalid peer credential."); + result.Authorization = `Bearer ${input.credential}`; + } + for (const [name, value] of [ + ["Idempotency-Key", input.idempotencyKey], + ["If-Match", input.revision], + ] as const) { + if (value !== undefined) { + if (!/^[\x21-\x7e]{1,128}$/u.test(value)) + throw new Error("Invalid peer operation metadata."); + result[name] = value; + } + } + return result; +} + +/** Main-process transport. Redirects are rejected; neither cookies nor default browser sessions are used. */ +export class PeerTransport { + readonly trust: PeerTrust; + constructor(trust: PeerTrust) { + this.trust = validatePeerTrust(trust); + } + + private async read( + input: PeerRequest, + onFrame?: (frame: string) => void, + ): Promise { + const target = peerRequestUrl(this.trust.endpoint, input.path); + const requestHeaders = headers(input, !!onFrame); + const body = + input.body === undefined + ? undefined + : Buffer.from(JSON.stringify(input.body)); + if (body && body.length > MAX_JSON_BYTES) + throw new Error("Peer request is too large."); + if (body) { + requestHeaders["Content-Type"] = "application/json"; + requestHeaders["Content-Length"] = String(body.length); + } + if (input.signal?.aborted) throw new PeerTransportError("unavailable"); + return new Promise((resolve, reject) => { + let settled = false; + let deadline: ReturnType | undefined; + let sessionDeadline: ReturnType | undefined; + const finish = (error?: Error, value?: unknown) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + clearTimeout(sessionDeadline); + input.signal?.removeEventListener("abort", abort); + if (error) reject(error); + else resolve(value); + }; + const request = https.request( + target, + { + method: input.method ?? "GET", + headers: requestHeaders, + agent: false, + rejectUnauthorized: true, + ...(this.trust.caCertificateDerBase64 + ? { + ca: new X509Certificate( + Buffer.from(this.trust.caCertificateDerBase64, "base64"), + ).toString(), + } + : {}), + checkServerIdentity: (hostname, certificate) => { + const invalid = checkServerIdentity(hostname, certificate); + if (invalid) return invalid; + try { + const key = new X509Certificate(certificate.raw).publicKey.export( + { type: "spki", format: "der" }, + ); + const fingerprint = `sha256/${createHash("sha256").update(key).digest("base64")}`; + if (fingerprint !== this.trust.serverSpkiSha256) + return new Error("Server identity changed."); + } catch { + return new Error("Invalid server identity."); + } + return undefined; + }, + }, + (response) => { + const status = response.statusCode ?? 0; + const fail = (error: Error) => { + finish(error); + response.destroy(); + request.destroy(); + }; + if (status < 200 || status >= 300) { + fail( + new PeerTransportError( + status === 401 || status === 403 + ? "authentication_required" + : "request_failed", + status, + ), + ); + return; + } + const mime = response.headers["content-type"]?.split(";")[0]?.trim(); + if ( + status !== 204 && + mime !== (onFrame ? "text/event-stream" : "application/json") + ) { + fail(new PeerTransportError("invalid_response")); + return; + } + if ( + response.headers["content-encoding"] && + response.headers["content-encoding"] !== "identity" + ) { + fail(new PeerTransportError("invalid_response")); + return; + } + const frames = onFrame ? new PeerEventFrames() : undefined; + const frameBoundary = () => { + clearTimeout(deadline); + deadline = setTimeout(abort, DEADLINE_MS); + }; + const decoder = new TextDecoder("utf-8", { fatal: true }); + let text = ""; + let bytes = 0; + response.on("data", (chunk: Buffer) => { + if (settled) return; + try { + bytes += chunk.length; + if (!onFrame && bytes > MAX_JSON_BYTES) + throw new Error("Response too large."); + if (onFrame && frames) frames.push(chunk, onFrame, frameBoundary); + else text += decoder.decode(chunk, { stream: true }); + } catch { + fail(new PeerTransportError("invalid_response")); + } + }); + response.on("end", () => { + try { + text += decoder.decode(); + frames?.end(); + finish( + undefined, + onFrame || status === 204 + ? undefined + : parseAidenRemoteJson(text, "peer response"), + ); + } catch { + fail(new PeerTransportError("invalid_response")); + } + }); + response.on("error", () => + finish(new PeerTransportError("unavailable")), + ); + response.on("aborted", () => + finish(new PeerTransportError("unavailable")), + ); + }, + ); + const abort = () => { + finish(new PeerTransportError("unavailable")); + request.destroy(); + }; + input.signal?.addEventListener("abort", abort, { once: true }); + request.on("error", () => finish(new PeerTransportError("unavailable"))); + request.setTimeout(onFrame ? 60_000 : DEADLINE_MS, abort); + deadline = setTimeout(abort, DEADLINE_MS); + if (onFrame) sessionDeadline = setTimeout(abort, 300_000); + request.end(body); + }); + } + + json(input: PeerRequest): Promise { + return this.read(input); + } + async events( + input: PeerRequest, + onFrame: (frame: string) => void, + ): Promise { + await this.read(input, onFrame); + } +} diff --git a/main/services/pi-compaction-core.test.ts b/main/services/pi-compaction-core.test.ts index 199167850..033599b99 100644 --- a/main/services/pi-compaction-core.test.ts +++ b/main/services/pi-compaction-core.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { appendFile, mkdtemp, mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { appendFile, mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -26,6 +26,7 @@ import { } from "./pi-compaction-session-store.js"; import type { ChatMessage } from "./types.js"; import { createPiSessionPort, type PiSessionPort } from "./pi-session-port.js"; +import { migratePiSessionJournal } from "./pi-session-migration.js"; const ZERO_COST = { input: 0, @@ -1206,6 +1207,121 @@ test("durable journals are private and delete with their chat", async (t) => { await assert.rejects(stat(metadata.path), { code: "ENOENT" }); }); +test("private history inspection preserves indexed, current, legacy, and quarantined journals without opening sessions", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-history-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = new PiCompactionSessionStore({ + root: async () => { throw new Error("inspection must not initialize the repository"); }, + readOnlyRoot: async () => root, + }); + assert.equal(await store.hasChatHistory("empty"), false); + assert.deepEqual(await readdir(root), []); + await writeFile(path.join(root, "aiden-journal-index.json"), JSON.stringify({ version: 1, chats: { indexed: [path.join(root, "old.jsonl.corrupt-1")] } })); + assert.equal(await store.hasChatHistory("indexed"), true); + await unlink(path.join(root, "aiden-journal-index.json")); + for (const [chatId, file, version] of [ + ["current", "current.jsonl", 4], + ["legacy", "legacy.jsonl", 3], + ["backup", "legacy.jsonl.v3-backup", 3], + ["quarantine", "current.jsonl.corrupt-1", 4], + ] as const) { + const header = JSON.stringify({ + ...(version === 4 ? { kind: "header" } : { type: "session" }), version, id: chatId, + metadata: { kind: "aiden-chat-compaction-v1", chatId }, + }); + await writeFile(path.join(root, file), `${header}\n{"private":"untouched"}\n`); + assert.equal(await store.hasChatHistory(chatId), true); + assert.equal(await readFile(path.join(root, file), "utf8"), `${header}\n{"private":"untouched"}\n`); + } + assert.equal(await store.hasChatHistory("unrelated-empty"), false); + assert.equal((await readdir(root)).length, 4); + const header = JSON.stringify({ kind: "header", version: 4, id: "multibyte-body", metadata: { kind: "aiden-chat-compaction-v1", chatId: "multibyte-body" } }); + const contents = `${header}\n${"x".repeat(65_536 - Buffer.byteLength(header) - 2)}€`; + await writeFile(path.join(root, "multibyte.jsonl"), contents); + assert.equal(await store.hasChatHistory("multibyte-body"), true, "decode only the complete header when the scan ends inside a multibyte body character"); + assert.equal(await store.hasChatHistory("still-unrelated"), false); + store.quarantineChatUntilRecovered("recovering", new Promise(() => {})); + assert.equal(await store.hasChatHistory("recovering"), true); +}); + +test("private history inspection fails closed on corrupt index, malformed journals, and unreadable paths", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-history-corrupt-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = new PiCompactionSessionStore({ root: async () => root }); + const indexPath = path.join(root, "aiden-journal-index.json"); + for (const contents of ["{broken", JSON.stringify({ version: 1, chats: { other: "not-an-array" } })]) { + await writeFile(indexPath, contents); + await assert.rejects(store.hasChatHistory("empty")); + assert.equal(await readFile(indexPath, "utf8"), contents); + } + await unlink(indexPath); + const journal = path.join(root, "unknown.jsonl.corrupt-1"); + await writeFile(journal, "{broken private history"); + await assert.rejects(store.hasChatHistory("empty")); + assert.equal(await readFile(journal, "utf8"), "{broken private history"); + await unlink(journal); + await symlink(path.join(root, "missing-private-file"), journal); + await assert.rejects(store.hasChatHistory("empty"), /symbolic link/u); + const absentRoot = path.join(root, "absent"); + assert.equal(await new PiCompactionSessionStore({ root: async () => absentRoot }).hasChatHistory("empty"), false); + await assert.rejects(stat(absentRoot), { code: "ENOENT" }); +}); + +test("header-only sessions created by old empty-chat reads are empty, but private body entries preserve them", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-header-only-")); + t.after(() => rm(root, { recursive: true, force: true })); + const original = new PiCompactionSessionStore({ root: async () => root }); + const session = await original.openChat("empty-chat-read"); + assert.equal(await original.hasChatHistory("empty-chat-read"), true, "an active session stays protected"); + const inspect = () => new PiCompactionSessionStore({ root: async () => root }).hasChatHistory("empty-chat-read"); + assert.equal(await inspect(), false, "a persisted index plus valid header alone is not private history"); + const metadata = await session.getMetadata(); + await appendFile(metadata.path, " \t\n\r\n"); + assert.equal(await inspect(), false, "trailing JSON whitespace is still empty"); + await session.appendMessage(user("private retained work")); + assert.equal(await inspect(), true); + assert.match(await readFile(metadata.path, "utf8"), /private retained work/u); + await unlink(metadata.path); + assert.equal(await inspect(), true, "an unresolved indexed path stays protected"); +}); + +test("completed migration of an empty legacy journal is disposable with indexed backup and receipt", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-empty-migration-")); + t.after(() => rm(root, { recursive: true, force: true })); + const chatId = "empty-promoted-chat"; + const promoted = path.join(root, "empty.jsonl"); + const header = { + type: "session", version: 3, id: chatId, timestamp: "2026-08-31T12:00:00.000Z", cwd: root, + metadata: { kind: "aiden-chat-compaction-v1", chatId }, + }; + await writeFile(promoted, `${JSON.stringify(header)}\n`); + const migration = await migratePiSessionJournal(promoted, chatId); + assert.equal(migration.receipt.counts.entries, 0); + const inspect = () => new PiCompactionSessionStore({ root: async () => root }).hasChatHistory(chatId); + assert.equal(await inspect(), false, "discovery recognizes actual empty migration scaffolding"); + const index = JSON.stringify({ version: 1, chats: { [chatId]: [promoted, migration.receipt.backupPath, migration.receiptPath] } }); + await writeFile(path.join(root, "aiden-journal-index.json"), index); + assert.equal(await inspect(), false, "indexed migration artifacts alone are not conversation history"); + const originalPromoted = await readFile(promoted, "utf8"); + const originalBackup = await readFile(migration.receipt.backupPath, "utf8"); + const originalReceipt = await readFile(migration.receiptPath, "utf8"); + await appendFile(promoted, '{"private":"preserve additional content"}\n'); + assert.equal(await inspect(), true); + await writeFile(promoted, originalPromoted); + await appendFile(migration.receipt.backupPath, '{"private":"preserve backup history"}\n'); + assert.equal(await inspect(), true); + await writeFile(migration.receipt.backupPath, originalBackup); + await writeFile(migration.receiptPath, JSON.stringify({ ...migration.receipt, validation: "failed" })); + assert.equal(await inspect(), true); + await writeFile(migration.receiptPath, originalReceipt); + await unlink(migration.receipt.backupPath); + assert.equal(await inspect(), true, "incomplete artifact sets remain protected"); + await writeFile(migration.receipt.backupPath, originalBackup); + await writeFile(migration.receiptPath, "{invalid receipt"); + await assert.rejects(inspect()); + assert.equal(await readFile(promoted, "utf8"), originalPromoted); +}); + test("opening a chat promotes its legacy v3 journal before current repository discovery", async (t) => { const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-v3-open-")); t.after(() => rm(temporary, { recursive: true, force: true })); diff --git a/main/services/pi-compaction-session-store.ts b/main/services/pi-compaction-session-store.ts index 587c167a6..4dcadd02f 100644 --- a/main/services/pi-compaction-session-store.ts +++ b/main/services/pi-compaction-session-store.ts @@ -1,5 +1,7 @@ -import { randomUUID } from "node:crypto"; -import { chmod, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import { constants as fsConstants } from "node:fs"; +import { chmod, lstat, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type AgentMessage, @@ -14,7 +16,8 @@ import { createCurrentPiSessionRepository, type PiSessionRepositoryPort, } from "./pi-session-repository-port.js"; -import { migratePiSessionJournal } from "./pi-session-migration.js"; +import { migratePiSessionJournal, parsePiSessionMigrationReceipt } from "./pi-session-migration.js"; +import { decodeUtf8, readRegularFile } from "./regular-file-read.js"; import { decodeLegacyPiSession } from "./pi-legacy-session.js"; import { piUpgradeBehaviorEnabledAtStartup, @@ -165,6 +168,86 @@ function currentJournalHeaderOwnsChat(headerLine: string, chatId: string): boole } } +/** Read a stable descriptor without decoding or retaining the private journal body. */ +async function inspectJournalHistory(filePath: string): Promise<{ chatId: string; hasBody: boolean }> { + const handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK); + try { + if (!(await handle.stat()).isFile()) throw new Error("Pi journal history is not a regular file."); + const buffer = Buffer.alloc(JOURNAL_HEADER_SCAN_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + const newline = bytes.indexOf(10); + const prefix = decodeUtf8(newline >= 0 ? bytes.subarray(0, newline) : bytes); + const header: unknown = JSON.parse(prefix); + const id = header && typeof header === "object" && !Array.isArray(header) + ? (header as { id?: unknown }).id : undefined; + if (typeof id !== "string" || !(journalHeaderOwnsChat(prefix, id) || currentJournalHeaderOwnsChat(prefix, id))) { + throw new Error("Pi journal history contains an unreadable session header."); + } + // Even a malformed body is private state worth preserving. Only an exact + // validated header followed by ASCII JSON whitespace counts as empty. + const hasContent = (chunk: Buffer) => chunk.some((byte) => byte !== 9 && byte !== 10 && byte !== 13 && byte !== 32); + if (newline >= 0 && hasContent(bytes.subarray(newline + 1))) return { chatId: id, hasBody: true }; + let position = bytesRead; + while (true) { + const next = await handle.read(buffer, 0, buffer.length, position); + if (next.bytesRead === 0) return { chatId: id, hasBody: false }; + if (hasContent(buffer.subarray(0, next.bytesRead))) return { chatId: id, hasBody: true }; + position += next.bytesRead; + } + } finally { + await handle.close(); + } +} + +/** Only the exact scaffolding produced by a completed migration of an empty v3 journal is disposable. */ +async function isCompletedEmptyMigration(promotedPath: string, chatId: string, root: string): Promise { + const backupPath = `${promotedPath}.v3-backup`; + const receiptPath = `${promotedPath}.migration-v1.json`; + if (!path.resolve(promotedPath).startsWith(`${path.resolve(root)}${path.sep}`)) return false; + try { + const receipt = parsePiSessionMigrationReceipt(JSON.parse(decodeUtf8(await readRegularFile(receiptPath, JOURNAL_HEADER_SCAN_BYTES)))); + if (receipt.chatId !== chatId || receipt.validation !== "passed" || + path.resolve(receipt.promotedPath) !== path.resolve(promotedPath) || + path.resolve(receipt.backupPath) !== path.resolve(backupPath) || + Object.values(receipt.counts).some((count) => count !== 0)) return false; + const backup = await readRegularFile(backupPath, JOURNAL_HEADER_SCAN_BYTES); + if (createHash("sha256").update(backup).digest("hex") !== receipt.sourceSha256) return false; + const backupText = decodeUtf8(backup); + if (backupText.split("\n").filter((line) => line.trim()).length !== 1) return false; + const legacy = decodeLegacyPiSession(backupText); + if (legacy.header.id !== chatId || legacy.entries.length !== 0 || legacy.tornFinalLine || + !journalHeaderOwnsChat(backupText.split("\n", 1)[0]!, chatId)) return false; + const current = decodeUtf8(await readRegularFile(promotedPath, JOURNAL_HEADER_SCAN_BYTES)); + const records: unknown[] = current.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)); + if (records.length !== 4 || !current.endsWith("\n")) return false; + const [header, lane, startedValue, finishedValue] = records; + if (!startedValue || typeof startedValue !== "object" || !finishedValue || typeof finishedValue !== "object") return false; + const started = startedValue as Record; + const finished = finishedValue as Record; + const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; + if (typeof started.id !== "string" || !new RegExp(`^migration-${uuid}$`, "u").test(started.id) || + typeof finished.id !== "string" || !new RegExp(`^migration-finished-${uuid}$`, "u").test(finished.id) || + !Number.isSafeInteger(started.timestamp) || Number(started.timestamp) < 0 || + !Number.isSafeInteger(finished.timestamp) || Number(finished.timestamp) < Number(started.timestamp)) return false; + return isDeepStrictEqual(header, { + kind: "header", version: 4, id: chatId, createdAt: Date.parse(legacy.header.timestamp), cwd: legacy.header.cwd, + ...(legacy.header.parentSession === undefined ? {} : { legacyParentSessionPath: legacy.header.parentSession }), + ...(legacy.header.metadata === undefined ? {} : { metadata: legacy.header.metadata }), + }) && isDeepStrictEqual(lane, { kind: "lane", seq: 1, lane: "main", leafId: null }) && + isDeepStrictEqual(started, { + kind: "record", seq: 2, id: started.id, lane: "main", type: "operation_started", timestamp: started.timestamp, + sourceLeafId: null, intent: { kind: "navigation", targetId: null, summarize: false }, + }) && isDeepStrictEqual(finished, { + kind: "record", seq: 3, id: finished.id, lane: "main", type: "operation_finished", timestamp: finished.timestamp, + runId: started.id, outcome: "completed", + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + async function fileExists(filePath: string): Promise { try { return (await stat(filePath)).isFile(); @@ -529,6 +612,8 @@ export async function recordPiEffectRecoveryBoundary( export interface PiCompactionSessionStoreOptions { root: () => Promise; + /** Resolve the location without creating it when inspecting cleanup eligibility. */ + readOnlyRoot?: () => Promise; rollout?: { load(): Promise; development: boolean; @@ -536,6 +621,15 @@ export interface PiCompactionSessionStoreOptions { }; } +/** + * Outcome of the rollout-aware open probe. Generation callers run the turn + * journalless when the probe reports a reason instead of a session, while + * `openChat` keeps its fail-closed throws for background callers. + */ +export type PiOpenChatIfEligibleResult = + | { session: PiSessionPort } + | { session: undefined; reason: "v4_creation_rollout" | "legacy_migration_deferred" }; + /** Durable, private Pi JSONL journals keyed one-to-one with Aiden chats. */ export class PiCompactionSessionStore { private repositoryPromise?: Promise<{ @@ -543,10 +637,7 @@ export class PiCompactionSessionStore { root: string; }>; private readonly sessions = new Map>(); - private readonly opening = new Map< - string, - Promise> - >(); + private readonly opening = new Map>(); private readonly quarantined = new Map>(); private indexMutation: Promise = Promise.resolve(); @@ -650,103 +741,225 @@ export class PiCompactionSessionStore { const existing = this.sessions.get(chatId); if (existing) return existing; const inFlight = this.opening.get(chatId); + if (inFlight) return this.sessionFromOpenChatOutcome(await inFlight); + + const opening = this.openChatInner(chatId, chat); + this.opening.set(chatId, opening); + try { + return this.sessionFromOpenChatOutcome(await opening); + } finally { + this.opening.delete(chatId); + } + } + + /** + * Rollout-aware open probe for the generation path. Mirrors {@link openChat} + * exactly: invalid identities, quarantined chats, and migration corruption + * still throw the same errors. The two rollout gates are reported as a + * structured reason instead of throwing, and a journal is only ever created + * through the shared open continuation (`openChatInner`). + */ + async openChatIfEligible( + chatId: string, + chat?: { createdAt: number }, + ): Promise { + if (!SAFE_SESSION_ID.test(chatId)) { + throw new Error("Invalid chat identity for the Pi compaction journal."); + } + this.assertNotQuarantined(chatId); + const existing = this.sessions.get(chatId); + if (existing) return { session: existing }; + const inFlight = this.opening.get(chatId); if (inFlight) return inFlight; - const opening = (async () => { - const { repo, root } = await this.repository(); - const rollout = await this.options.rollout?.load(); - const rolloutOptions = this.options.rollout && { - development: this.options.rollout.development, - behaviorEnabled: this.options.rollout.behaviorEnabled, - }; - const migrationFailures: Array<{ path: string; error: unknown }> = []; - const deferredMigrations: string[] = []; - const migrationPaths = await findMigrationJournals(root, chatId); - for (const migrationPath of migrationPaths) { - try { - if (rollout && rolloutOptions) { - const header = await readJournalPrefix(migrationPath); - const legacySource = journalHeaderOwnsChat(header, chatId) - ? migrationPath - : `${migrationPath}.v3-backup`; - const legacy = decodeLegacyPiSession(await readFile(legacySource, "utf8")); - if (!piUpgradeLegacyMigrationEligible(rollout, legacy.entries.length, rolloutOptions)) { - deferredMigrations.push(migrationPath); - continue; - } + const opening = this.openChatInner(chatId, chat); + this.opening.set(chatId, opening); + try { + return await opening; + } finally { + this.opening.delete(chatId); + } + } + + private sessionFromOpenChatOutcome( + outcome: PiOpenChatIfEligibleResult, + ): PiSessionPort { + if (outcome.session) return outcome.session; + if (outcome.reason === "legacy_migration_deferred") { + throw new Error("This legacy Pi journal is outside the active device rollout stage; its v3 bytes remain unchanged."); + } + throw new Error("Pi v4 journal creation is outside the active device rollout stage."); + } + + private async openChatInner( + chatId: string, + chat: { createdAt: number } | undefined, + ): Promise { + const { repo, root } = await this.repository(); + const rollout = await this.options.rollout?.load(); + const rolloutOptions = this.options.rollout && { + development: this.options.rollout.development, + behaviorEnabled: this.options.rollout.behaviorEnabled, + }; + const migrationFailures: Array<{ path: string; error: unknown }> = []; + const deferredMigrations: string[] = []; + const migrationPaths = await findMigrationJournals(root, chatId); + for (const migrationPath of migrationPaths) { + try { + if (rollout && rolloutOptions) { + const header = await readJournalPrefix(migrationPath); + const legacySource = journalHeaderOwnsChat(header, chatId) + ? migrationPath + : `${migrationPath}.v3-backup`; + const legacy = decodeLegacyPiSession(await readFile(legacySource, "utf8")); + if (!piUpgradeLegacyMigrationEligible(rollout, legacy.entries.length, rolloutOptions)) { + deferredMigrations.push(migrationPath); + continue; } - const migration = await migratePiSessionJournal(migrationPath, chatId); - await this.rememberPath(root, chatId, migration.receipt.backupPath); - await this.rememberPath(root, chatId, migration.receiptPath); - } catch (error) { - migrationFailures.push({ path: migrationPath, error }); } + const migration = await migratePiSessionJournal(migrationPath, chatId); + await this.rememberPath(root, chatId, migration.receipt.backupPath); + await this.rememberPath(root, chatId, migration.receiptPath); + } catch (error) { + migrationFailures.push({ path: migrationPath, error }); } - const failedPaths = new Set(migrationFailures.map((failure) => path.resolve(failure.path))); - const matches = (await repo.list()).filter( - (metadata) => - metadata.id === chatId && - metadata.metadata?.kind === SESSION_METADATA_KIND && - !failedPaths.has(path.resolve(metadata.path)), - ); - // Pi lists newest sessions first. Validate the whole body and quarantine - // a malformed duplicate before falling back to the next valid journal. - let session: PiSessionPort | undefined; - for (const metadata of matches) { - try { - const candidate = await repo.open(metadata); - await candidate.getBranch(); - session = candidate; - break; - } catch { - if (await repairTornFinalLine(metadata.path).catch(() => false)) { - try { - const repaired = await repo.open(metadata); - await repaired.getBranch(); - session = repaired; - break; - } catch { - // A complete-but-invalid prefix is not safe to guess at. - } + } + const failedPaths = new Set(migrationFailures.map((failure) => path.resolve(failure.path))); + const matches = (await repo.list()).filter( + (metadata) => + metadata.id === chatId && + metadata.metadata?.kind === SESSION_METADATA_KIND && + !failedPaths.has(path.resolve(metadata.path)), + ); + // Pi lists newest sessions first. Validate the whole body and quarantine + // a malformed duplicate before falling back to the next valid journal. + let session: PiSessionPort | undefined; + for (const metadata of matches) { + try { + const candidate = await repo.open(metadata); + await candidate.getBranch(); + session = candidate; + break; + } catch { + if (await repairTornFinalLine(metadata.path).catch(() => false)) { + try { + const repaired = await repo.open(metadata); + await repaired.getBranch(); + session = repaired; + break; + } catch { + // A complete-but-invalid prefix is not safe to guess at. } - await this.quarantine(root, chatId, metadata.path); } + await this.quarantine(root, chatId, metadata.path); } - if (session) { - for (const failure of migrationFailures) { - await this.quarantine(root, chatId, failure.path); - } - } else if (migrationFailures[0]) { - throw migrationFailures[0].error; + } + if (session) { + for (const failure of migrationFailures) { + await this.quarantine(root, chatId, failure.path); + } + } else if (migrationFailures[0]) { + throw migrationFailures[0].error; + } + if (!session && deferredMigrations.length > 0) { + return { session: undefined, reason: "legacy_migration_deferred" }; + } + if ( + !session && rollout && rolloutOptions && + !piUpgradeJournalCreationEligible(rollout, chat?.createdAt, rolloutOptions) + ) { + return { session: undefined, reason: "v4_creation_rollout" }; + } + session ??= await repo.create({ + id: chatId, + cwd: root, + metadata: { kind: SESSION_METADATA_KIND, chatId }, + }); + await recoverUncommittedTransaction(session); + const persisted = await session.getMetadata(); + await chmod(path.dirname(persisted.path), 0o700); + await chmod(persisted.path, 0o600); + await this.rememberPath(root, chatId, persisted.path); + this.sessions.set(chatId, session); + return { session }; + } + + /** Conservatively inspect private history without creating, repairing, or migrating a session. */ + async hasChatHistory(chatId: string): Promise { + if (!SAFE_SESSION_ID.test(chatId)) { + throw new Error("Invalid chat identity for the Pi compaction journal."); + } + if (this.sessions.has(chatId) || this.opening.has(chatId) || this.quarantined.has(chatId)) return true; + await this.indexMutation; + const root = await (this.options.readOnlyRoot ?? this.options.root)(); + try { + if (!(await lstat(root)).isDirectory()) throw new Error("Pi journal history root is not a directory."); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + let indexBytes: Buffer | undefined; + try { + indexBytes = await readRegularFile(path.join(root, JOURNAL_INDEX_FILE), 16 * 1024 * 1024); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (indexBytes) { + const index: unknown = JSON.parse(decodeUtf8(indexBytes)); + if (!index || typeof index !== "object" || Array.isArray(index) || + (index as Partial).version !== 1) { + throw new Error("Pi journal history index could not be read safely."); } - if (!session && deferredMigrations.length > 0) { - throw new Error("This legacy Pi journal is outside the active device rollout stage; its v3 bytes remain unchanged."); + const chats = (index as Partial).chats; + if (!chats || typeof chats !== "object" || Array.isArray(chats) || + Object.values(chats).some((paths) => !Array.isArray(paths) || paths.some((file) => typeof file !== "string" || !file))) { + throw new Error("Pi journal history index could not be read safely."); } - if ( - !session && rollout && rolloutOptions && - !piUpgradeJournalCreationEligible(rollout, chat?.createdAt, rolloutOptions) - ) { - throw new Error("Pi v4 journal creation is outside the active device rollout stage."); + if (Object.prototype.hasOwnProperty.call(chats, chatId)) { + for (const indexedPath of chats[chatId]!) { + const candidate = path.resolve(indexedPath); + if (!candidate.startsWith(`${path.resolve(root)}${path.sep}`)) { + throw new Error("Pi journal history index escaped its private storage root."); + } + if (candidate.endsWith(".jsonl.v3-backup") || candidate.endsWith(".jsonl.migration-v1.json")) { + const promoted = candidate.replace(/\.(?:v3-backup|migration-v1\.json)$/u, ""); + if (!(await isCompletedEmptyMigration(promoted, chatId, root))) return true; + continue; + } + if (!candidate.endsWith(".jsonl")) return true; + try { + const history = await inspectJournalHistory(candidate); + if (history.chatId !== chatId || (history.hasBody && !(await isCompletedEmptyMigration(candidate, chatId, root)))) return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + } } - session ??= await repo.create({ - id: chatId, - cwd: root, - metadata: { kind: SESSION_METADATA_KIND, chatId }, - }); - await recoverUncommittedTransaction(session); - const persisted = await session.getMetadata(); - await chmod(path.dirname(persisted.path), 0o700); - await chmod(persisted.path, 0o600); - await this.rememberPath(root, chatId, persisted.path); - this.sessions.set(chatId, session); - return session; - })(); - this.opening.set(chatId, opening); - try { - return await opening; - } finally { - this.opening.delete(chatId); } + const directories = [root]; + while (directories.length > 0) { + const directory = directories.pop()!; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error("Pi journal history contains an uninspectable symbolic link."); + if (entry.isDirectory()) { directories.push(candidate); continue; } + if (!entry.name.includes(".jsonl")) continue; + if (entry.name.endsWith(".migration-v1.json")) { + const receipt = parsePiSessionMigrationReceipt(JSON.parse(decodeUtf8(await readRegularFile(candidate, 65_536)))); + if (receipt.chatId === chatId && !(await isCompletedEmptyMigration(candidate.slice(0, -".migration-v1.json".length), chatId, root))) return true; + continue; + } + const history = await inspectJournalHistory(candidate); + if (history.chatId === chatId) { + if (entry.name.endsWith(".jsonl.v3-backup")) { + if (!(await isCompletedEmptyMigration(candidate.slice(0, -".v3-backup".length), chatId, root))) return true; + } else if (!entry.name.endsWith(".jsonl") || + (history.hasBody && !(await isCompletedEmptyMigration(candidate, chatId, root)))) return true; + } + } + } + return false; } async deleteChat(chatId: string): Promise { @@ -814,6 +1027,7 @@ export class PiCompactionSessionStore { export const piCompactionSessionStore = new PiCompactionSessionStore({ root: () => ensureUserDataDir("pi-compaction-sessions"), + readOnlyRoot: async () => path.join((await import("../platform.js")).app.getPath("userData"), "pi-compaction-sessions"), rollout: { load: () => piUpgradeRolloutStore.load(), development: isDevelopmentRuntime(process.env, Boolean(process.versions.electron)), diff --git a/main/services/pi-upgrade-evaluation.test.ts b/main/services/pi-upgrade-evaluation.test.ts index bdc915978..deaf7ee7e 100644 --- a/main/services/pi-upgrade-evaluation.test.ts +++ b/main/services/pi-upgrade-evaluation.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -22,6 +22,20 @@ async function passingMeasurements(): Promise { return runPiUpgradeReplayCases(); } +async function journalFilesUnder(root: string): Promise { + const matches: string[] = []; + const directories = [root]; + while (directories.length > 0) { + const directory = directories.pop()!; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) directories.push(candidate); + else if (entry.name.endsWith(".jsonl")) matches.push(candidate); + } + } + return matches; +} + test("executable replays emit the complete passing scorecard from observed outcomes", async () => { const measurements = await passingMeasurements(); const report = evaluatePiUpgradeReplay(measurements); @@ -194,6 +208,24 @@ test("operator advancement command is registered and uses the guarded store", as assert.match(llmText, /piUpgradeChatBehaviorEligible/u); assert.match(llmText, /enabled: piUpgradeCompactionEnabled/u); assert.match(lifecycleText, /compactionEligible/u); + + // Generation must run rollout-ineligible chats journalless instead of + // throwing, and the durable stores must stay fail-closed for those runs. + assert.match(llmText, /openChatIfEligible/u); + assert.match(lifecycleText, /openChatIfEligible/u); + assert.match(llmText, /createInMemoryPiSession/u); + assert.match(llmText, /piJournalless = true/u); + assert.match(llmText, /!piJournalless[\s\S]{0,200}markRecoveryRecorded/u); + assert.match(llmText, /!piJournalless[\s\S]{0,160}acknowledgeChatEffectsDurable/u); + const quarantineSites = [...llmText.matchAll(/piCompactionSessionStore\.quarantineChatUntilRecovered/gu)]; + assert.equal(quarantineSites.length, 2); + for (const site of quarantineSites) { + assert.match( + llmText.slice(Math.max(0, (site.index ?? 0) - 200), site.index ?? 0), + /!piJournalless/u, + "every durable quarantine call must be gated by the journalless flag", + ); + } }); test("production journal creation and legacy migration obey the device rollout without rewriting deferred v3", async (t) => { @@ -245,3 +277,75 @@ test("production journal creation and legacy migration obey the device rollout w }); await assert.rejects(rolledBack.openChat("rollback-new", { createdAt: 2 }), /outside the active device rollout stage/u); }); + +test("openChatIfEligible probes the rollout without creating or rewriting journals", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-open-eligible-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const policy = { version: 1 as const, stage: "new_chats" as const, activatedAt: 1, revision: 1 }; + + // (a) An eligible new chat delegates to the shared open continuation: a + // session comes back and a v4 journal is created on disk. + const createdRoot = path.join(temporary, "created"); + await mkdir(createdRoot, { recursive: true }); + const created = new PiCompactionSessionStore({ + root: async () => createdRoot, + rollout: { load: async () => policy, development: false, behaviorEnabled: true }, + }); + const createdOutcome = await created.openChatIfEligible("eligible-new", { createdAt: 2 }); + assert.ok(createdOutcome.session); + const createdPath = (await createdOutcome.session.getMetadata()).path; + assert.ok((await stat(createdPath)).isFile()); + assert.equal(JSON.parse((await readFile(createdPath, "utf8")).split("\n")[0]!).version, 4); + + // (b) A pre-activation journal-less chat is reported ineligible and no + // journal file appears on disk. + const preActivationRoot = path.join(temporary, "pre-activation"); + await mkdir(preActivationRoot, { recursive: true }); + const preActivation = new PiCompactionSessionStore({ + root: async () => preActivationRoot, + rollout: { load: async () => policy, development: false, behaviorEnabled: true }, + }); + assert.deepEqual(await preActivation.openChatIfEligible("pre-activation", { createdAt: 0 }), { + session: undefined, + reason: "v4_creation_rollout", + }); + assert.deepEqual(await journalFilesUnder(preActivationRoot), []); + + // (c) A deferred-v3 chat is reported legacy_migration_deferred with its v3 + // bytes byte-identical afterward. + const legacyRoot = path.join(temporary, "legacy-deferred"); + const legacyDirectory = path.join(legacyRoot, "--legacy--"); + await mkdir(legacyDirectory, { recursive: true }); + const journal = path.join(legacyDirectory, "legacy.jsonl"); + const fixture = (await readFile(path.resolve("main/services/fixtures/pi-legacy/uncompacted.jsonl"), "utf8")).split("\n"); + const header = JSON.parse(fixture[0]!) as Record; + header.id = "deferred-legacy"; + header.cwd = legacyRoot; + header.metadata = { kind: "aiden-chat-compaction-v1", chatId: "deferred-legacy" }; + fixture[0] = JSON.stringify(header); + const original = fixture.join("\n"); + await writeFile(journal, original, { mode: 0o600 }); + const deferred = new PiCompactionSessionStore({ + root: async () => legacyRoot, + rollout: { load: async () => policy, development: false, behaviorEnabled: true }, + }); + assert.deepEqual(await deferred.openChatIfEligible("deferred-legacy", { createdAt: 0 }), { + session: undefined, + reason: "legacy_migration_deferred", + }); + assert.equal(await readFile(journal, "utf8"), original); + + // (d) behaviorEnabled=false rolls the gate back: an otherwise-eligible new + // chat is reported v4_creation_rollout and no journal is created. + const rollbackRoot = path.join(temporary, "rollback"); + await mkdir(rollbackRoot, { recursive: true }); + const rolledBack = new PiCompactionSessionStore({ + root: async () => rollbackRoot, + rollout: { load: async () => policy, development: false, behaviorEnabled: false }, + }); + assert.deepEqual(await rolledBack.openChatIfEligible("rollback-new", { createdAt: 2 }), { + session: undefined, + reason: "v4_creation_rollout", + }); + assert.deepEqual(await journalFilesUnder(rollbackRoot), []); +}); diff --git a/main/services/provider-artwork-core.test.ts b/main/services/provider-artwork-core.test.ts index 290a12ed5..482bbac43 100644 --- a/main/services/provider-artwork-core.test.ts +++ b/main/services/provider-artwork-core.test.ts @@ -1,6 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { decodeProviderArtworkSource } from "./provider-artwork-core.js"; +import { + decodeProviderArtworkSource, + persistStoredProviderArtwork, +} from "./provider-artwork-core.js"; +import { normalizeProviderArtwork } from "../../renderer/shared/provider-artwork.js"; + +const VALID_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; test("provider artwork accepts PNG and inert SVG sources", () => { assert.equal( @@ -51,3 +58,59 @@ test("provider artwork rejects malformed base64 and oversized PNG dimensions bef /dimensions/u, ); }); + +test("already-normalized artwork persists without another decode", () => { + let reencodeCalls = 0; + const artwork = persistStoredProviderArtwork( + { mimeType: "image/png", dataBase64: VALID_PNG }, + () => { + reencodeCalls += 1; + throw new Error("should not re-encode valid artwork"); + }, + ); + assert.equal(reencodeCalls, 0); + assert.deepEqual( + artwork, + normalizeProviderArtwork({ mimeType: "image/png", dataBase64: VALID_PNG }), + ); +}); + +test("invalid artwork is dropped, and oversized PNG bytes are re-encoded", () => { + assert.equal(persistStoredProviderArtwork(undefined, () => { + throw new Error("unused"); + }), undefined); + assert.equal( + persistStoredProviderArtwork({ mimeType: "image/svg+xml" }, () => { + throw new Error("unused"); + }), + undefined, + ); + + const oversizedPng = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(oversizedPng); + Buffer.from("IHDR", "ascii").copy(oversizedPng, 12); + oversizedPng.writeUInt32BE(65, 16); + oversizedPng.writeUInt32BE(65, 20); + const oversizedBase64 = oversizedPng.toString("base64"); + assert.equal( + normalizeProviderArtwork({ mimeType: "image/png", dataBase64: oversizedBase64 }), + undefined, + ); + + const recovered = persistStoredProviderArtwork( + { mimeType: "image/png", dataBase64: oversizedBase64 }, + (input) => { + assert.equal(input.name, "icon.png"); + assert.equal(input.dataBase64, oversizedBase64); + return { mimeType: "image/png", dataBase64: VALID_PNG }; + }, + ); + assert.deepEqual(recovered, { mimeType: "image/png", dataBase64: VALID_PNG }); + + assert.equal( + persistStoredProviderArtwork({ mimeType: "image/png", dataBase64: "not-png" }, () => { + throw new Error("decode failed"); + }), + undefined, + ); +}); diff --git a/main/services/provider-artwork-core.ts b/main/services/provider-artwork-core.ts index 3a6ef8dfa..ec6a892d3 100644 --- a/main/services/provider-artwork-core.ts +++ b/main/services/provider-artwork-core.ts @@ -1,5 +1,27 @@ +import { + normalizeProviderArtwork, + type ProviderArtwork, +} from "../../renderer/shared/provider-artwork.js"; + export const PROVIDER_ARTWORK_MAX_SOURCE_BYTES = 512 * 1024; +/** Keep artwork that already matches the display contract, or re-encode PNG bytes. */ +export function persistStoredProviderArtwork( + value: unknown, + reencode: (input: { name: string; dataBase64: string }) => ProviderArtwork, +): ProviderArtwork | undefined { + const validated = normalizeProviderArtwork(value); + if (validated) return validated; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const dataBase64 = (value as { dataBase64?: unknown }).dataBase64; + if (typeof dataBase64 !== "string" || dataBase64.length === 0) return undefined; + try { + return reencode({ name: "icon.png", dataBase64 }); + } catch { + return undefined; + } +} + export function decodeProviderArtworkSource(value: unknown): { bytes: Buffer; kind: "png" | "svg"; diff --git a/main/services/provider-artwork.test.ts b/main/services/provider-artwork.test.ts new file mode 100644 index 000000000..531bfffec --- /dev/null +++ b/main/services/provider-artwork.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("normalized provider icons are validated and oversize stored artwork can be recovered", () => { + const source = readFileSync(new URL("./provider-artwork.ts", import.meta.url), "utf8"); + assert.match( + source, + /nativeImage\.createFromDataURL\(\s*`data:image\/svg\+xml;base64,\$\{Buffer\.from\(source\.safeSvg!, "utf8"\)\.toString\("base64"\)\}`,?\s*\)/u, + ); + assert.match(source, /if \(!normalizeProviderArtwork\(artwork\)\)/u); + assert.match( + source, + /persistStoredProviderArtwork\(value, \(input\) => normalizeProviderArtworkInput\(input\)\)/u, + ); +}); + +test("provider saves persist recovered artwork instead of dropping oversize icons", () => { + const source = readFileSync(new URL("../handlers/providers.ts", import.meta.url), "utf8"); + assert.match(source, /artwork: persistableProviderArtwork\(p\.artwork\)/u); + assert.doesNotMatch(source, /artwork:\s*normalizeProviderArtwork\(p\.artwork\)/u); +}); diff --git a/main/services/provider-artwork.ts b/main/services/provider-artwork.ts index fc557c1d1..5310484aa 100644 --- a/main/services/provider-artwork.ts +++ b/main/services/provider-artwork.ts @@ -1,9 +1,14 @@ import { nativeImage } from "../platform.js"; import { PROVIDER_ARTWORK_MAX_PNG_BYTES, + normalizeProviderArtwork, type ProviderArtwork, } from "../../renderer/shared/provider-artwork.js"; -import { decodeProviderArtworkSource } from "./provider-artwork-core.js"; +import { + decodeProviderArtworkSource, + persistStoredProviderArtwork, +} from "./provider-artwork-core.js"; + const TARGET_EDGE = 64; export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork { @@ -36,5 +41,14 @@ export function normalizeProviderArtworkInput(value: unknown): ProviderArtwork { if (png.length === 0 || png.length > PROVIDER_ARTWORK_MAX_PNG_BYTES) { throw new Error("The normalized provider icon is too complex. Choose a simpler image."); } - return { mimeType: "image/png", dataBase64: png.toString("base64") }; + const artwork = { mimeType: "image/png" as const, dataBase64: png.toString("base64") }; + if (!normalizeProviderArtwork(artwork)) { + throw new Error("The normalized provider icon is too complex. Choose a simpler image."); + } + return artwork; +} + +/** Persist only artwork that already matches the display contract, or re-encode it. */ +export function persistableProviderArtwork(value: unknown): ProviderArtwork | undefined { + return persistStoredProviderArtwork(value, (input) => normalizeProviderArtworkInput(input)); } diff --git a/main/services/schedule-tool.test.ts b/main/services/schedule-tool.test.ts index b25da48eb..f7d166b18 100644 --- a/main/services/schedule-tool.test.ts +++ b/main/services/schedule-tool.test.ts @@ -88,6 +88,10 @@ function fakeDependencies() { const calls = { validatedScripts: [] as Array<{ script: string; workspaceRoot?: string }>, }; + const settings: { lastProviderId?: string; lastModel?: string } = { + lastProviderId: "local-provider", + lastModel: "local-model", + }; const workspace: Workspace = { id: "workspace-1", name: "Project", @@ -164,8 +168,24 @@ function fakeDependencies() { return `${input.workspaceRoot}/.aiden/scripts/${input.script}`; }, isSchedulingEnabled: async () => true, + getSettings: async () => structuredClone(settings), + selectionProvider: async (providerId) => + providerId === "local-provider" + ? { + id: "local-provider", + kind: "openai", + label: "Local Provider", + baseUrl: "http://localhost:1234/v1", + models: ["local-model"], + modelMetadata: { "local-model": { source: "provider", name: "Local Model" } }, + defaultModel: "local-model", + needsKey: false, + deployment: "local", + isBuiltin: true, + } + : undefined, }; - return { dependencies, tasks, calls }; + return { dependencies, tasks, calls, settings }; } function jsonResult(value: AgentToolResult): Record { @@ -1385,3 +1405,106 @@ test("schedule mutations require live approval without exposing prompt contents" /mode to script, access to full/u, ); }); + +test("standard schedule creation rejects LLM tasks with no chat model and no app default", async () => { + const fake = fakeDependencies(); + fake.settings.lastProviderId = undefined; + fake.settings.lastModel = undefined; + const tool = createScheduleTaskTool( + { kind: "standard", defaultWorkspaceId: "workspace-1" }, + fake.dependencies, + ); + await assert.rejects( + tool.execute("create", { + action: "create", + name: "Doomed brief", + cron: "0 9 * * *", + prompt: "Summarize updates.", + }), + /Choose a provider before creating this scheduled task \(no app default is set\)/u, + ); + assert.equal(fake.tasks.length, 0); +}); + +test("standard schedule creation pins the attached chat model selection", async () => { + const fake = fakeDependencies(); + fake.settings.lastProviderId = undefined; + fake.settings.lastModel = undefined; + const args = { + action: "create" as const, + name: "Pinned brief", + cron: "0 9 * * *", + timezone: "UTC", + prompt: "Summarize updates.", + }; + const prepared = await prepareStandardScheduleApproval( + args, + ASSISTANT_MODEL_SELECTION, + fake.dependencies, + ); + assert.match(prepared.summary, /Local Provider \/ Local Model/u); + const tool = createScheduleTaskTool( + { + kind: "standard", + defaultWorkspaceId: "workspace-1", + modelSelection: ASSISTANT_MODEL_SELECTION, + }, + fake.dependencies, + ); + await tool.execute("create-pinned", args); + assert.equal(fake.tasks[0]?.providerId, "local-provider"); + assert.equal(fake.tasks[0]?.model, "local-model"); +}); + +test("standard schedule creation falls back to the app default without pinning it", async () => { + const fake = fakeDependencies(); + const args = { + action: "create" as const, + name: "Default brief", + cron: "0 9 * * *", + timezone: "UTC", + prompt: "Summarize updates.", + }; + const prepared = await prepareStandardScheduleApproval(args, undefined, fake.dependencies); + assert.match(prepared.summary, /App default/u); + const tool = createScheduleTaskTool( + { kind: "standard", defaultWorkspaceId: "workspace-1" }, + fake.dependencies, + ); + await tool.execute("create-default", args); + assert.equal(fake.tasks[0]?.providerId, undefined); + assert.equal(fake.tasks[0]?.model, undefined); +}); + +test("standard schedule edits preserve a pinned task provider", async () => { + const fake = fakeDependencies(); + fake.settings.lastProviderId = undefined; + fake.settings.lastModel = undefined; + fake.tasks.push({ + ...scheduledTask( + { + name: "Pinned brief", + enabled: true, + mode: "llm", + cron: "0 9 * * *", + timezone: "UTC", + prompt: "Summarize updates.", + permission: "read-only", + providerId: "provider-1", + model: "model-1", + }, + "task-1", + ), + updatedAt: 5, + }); + const tool = createScheduleTaskTool({ kind: "standard" }, fake.dependencies); + await tool.execute("update", { + action: "update", + id: "task-1", + taskName: "Pinned brief", + expectedUpdatedAt: 5, + name: "Pinned brief renamed", + }); + assert.equal(fake.tasks[0]?.providerId, "provider-1"); + assert.equal(fake.tasks[0]?.model, "model-1"); +}); diff --git a/main/services/schedule-tool.ts b/main/services/schedule-tool.ts index c13b8022d..50e910418 100644 --- a/main/services/schedule-tool.ts +++ b/main/services/schedule-tool.ts @@ -8,11 +8,13 @@ import { } from "./schedule-guard.js"; import { nextScheduledRun, systemTimezone, validateTimezone } from "./schedule-store.js"; import type { + AppSettings, McpServer, ScheduledRun, ScheduledMcpServerBinding, ScheduledTask, ScheduledTaskInput, + StoredProvider, Workspace, } from "./types.js"; import { @@ -308,6 +310,8 @@ export interface ScheduleToolDependencies { listMcpServers(): Promise; validateScript(input: { script: string; workspaceRoot?: string }): Promise; isSchedulingEnabled(): Promise; + getSettings(): Promise; + selectionProvider(providerId: string): Promise; } const defaultDependencies: ScheduleToolDependencies = { @@ -346,6 +350,10 @@ const defaultDependencies: ScheduleToolDependencies = { (await import("./config-store.js")).configStore .getSettings() .then((settings) => settings.scheduledTasksEnabled !== false), + getSettings: async () => (await import("./config-store.js")).configStore.getSettings(), + selectionProvider: async (providerId) => + (await import("./provider-registry.js")).providerRegistry.selectionProvider(providerId) ?? + (await import("./config-store.js")).configStore.getProvider(providerId), }; function result(value: unknown): AgentToolResult { @@ -898,10 +906,79 @@ function sameIds(left: readonly string[] | undefined, right: readonly string[]): return (left?.length ?? 0) === right.length && right.every((id, index) => left?.[index] === id); } -function standardSelection( +const NO_PROVIDER_SET = "No provider set"; +const APP_DEFAULT = "App default"; + +/** Settings-resolved app-default runtime selection used only for approval display. */ +interface StandardDefaultModel { + providerId?: string; + model?: string; + providerLabel?: string; + modelLabel?: string; +} + +function appDefaultProviderLabel(providerLabel: string | undefined): string { + if (!providerLabel) return APP_DEFAULT; + return approvalDisplayValue( + `App default (${providerLabel})`, + ASSISTANT_AUTOMATION_PROVIDER_NAME_LIMIT, + APP_DEFAULT, + ); +} + +async function resolveStandardDefaultModel( + dependencies: ScheduleToolDependencies, +): Promise { + const settings = await dependencies.getSettings(); + const providerId = settings.lastProviderId?.trim(); + if (!providerId) return {}; + const provider = await dependencies.selectionProvider(providerId); + const model = settings.lastModel?.trim() || provider?.defaultModel?.trim(); + return { + providerId, + ...(model ? { model } : {}), + providerLabel: provider?.label?.trim() || undefined, + ...(model && provider?.modelMetadata?.[model]?.name?.trim() + ? { modelLabel: provider.modelMetadata[model].name.trim() } + : {}), + }; +} + +function standardDefaultSelection(defaults: StandardDefaultModel): AssistantScheduleModelSelection { + if (!defaults.providerId) { + return { + providerId: "no-provider", + providerName: NO_PROVIDER_SET, + model: "no-model", + modelName: NO_PROVIDER_SET, + providerFingerprint: "sha256:unbound", + }; + } + const model = defaults.model; + return { + providerId: approvalDisplayValue( + defaults.providerId, + ASSISTANT_AUTOMATION_PROVIDER_ID_LIMIT, + "no-provider", + ), + providerName: appDefaultProviderLabel(defaults.providerLabel), + model: approvalDisplayValue(model ?? "app-default", ASSISTANT_AUTOMATION_MODEL_ID_LIMIT, "app-default"), + modelName: model + ? approvalDisplayValue( + defaults.modelLabel ?? model, + ASSISTANT_AUTOMATION_MODEL_NAME_LIMIT, + APP_DEFAULT, + ) + : APP_DEFAULT, + providerFingerprint: "sha256:unbound", + }; +} + +async function standardSelection( existing: ScheduledTask | undefined, selection: AssistantScheduleModelSelection | undefined, -): AssistantScheduleModelSelection { + defaultModel: () => Promise, +): Promise { if (existing?.providerId && existing.model) { return { providerId: existing.providerId, @@ -913,13 +990,7 @@ function standardSelection( }; } if (selection) return validateAssistantScheduleModelSelection(selection); - return { - providerId: "scheduler-default", - providerName: "Scheduler default", - model: "scheduler-default", - modelName: "Scheduler default", - providerFingerprint: "sha256:unbound", - }; + return standardDefaultSelection(await defaultModel()); } async function standardScope( @@ -1028,6 +1099,12 @@ async function resolveStandardScheduleApproval( } } + let defaultModelPromise: Promise | undefined; + const defaultModel = (): Promise => { + defaultModelPromise ??= resolveStandardDefaultModel(dependencies); + return defaultModelPromise; + }; + let input: ScheduledTaskInput; if (action === "create" || action === "update") { if (params.workspaceId?.trim() && params.clearWorkspace) { @@ -1098,7 +1175,17 @@ async function resolveStandardScheduleApproval( if (workspaceId && mcpServerIds.length > 0) { throw new Error("Scheduled tasks must choose either one project or MCP servers, not both."); } - const selected = standardSelection(existing, selection); + if (mode === "llm" && !existing?.providerId && !selection) { + const appDefault = await defaultModel(); + if (!appDefault.providerId && (action === "create" || existing!.mode !== "llm")) { + throw new Error( + action === "create" + ? "Choose a provider before creating this scheduled task (no app default is set)." + : "Choose a provider before switching this scheduled task to LLM mode (no app default is set).", + ); + } + } + const selected = await standardSelection(existing, selection, defaultModel); input = { id: existing?.id, name: bounded( @@ -1155,7 +1242,7 @@ async function resolveStandardScheduleApproval( } else if ((input.mcpServerIds?.length ?? 0) > 0) { input.mcpServerBindings = scope.mcpServerBindings; } - const selected = standardSelection(existing, selection); + const selected = await standardSelection(existing, selection, defaultModel); const schedulerEnabled = await dependencies.isSchedulingEnabled(); const cron = required(input.cron, "cron"); const timezone = validateTimezone(input.timezone ?? systemTimezone()); diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index 7e568b7d1..d28bf532d 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -473,7 +473,7 @@ test("persisted history seeds a reopened terminal buffer", async () => { const session = await service.create("workspace-1", "/tmp", owner.owner); // The restored history is available via snapshot, so the renderer can - // re-hydrate xterm with the prior session's output. + // re-hydrate the Ghostty surface with the prior session's output. const snapshot = service.snapshot(session.id, owner.owner); assert.equal(snapshot.buffer, "prior output\n"); await service.flushHistory(); diff --git a/main/services/terminal.ts b/main/services/terminal.ts index c3b6de795..4d7286769 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -303,7 +303,7 @@ export class TerminalService { throw new Error("The workspace changed before the terminal could start."); } // Restore the sanitized prior-session output so the terminal reopens with - // its history. The renderer writes this buffer to xterm on hydrate, so no + // its history. The renderer writes this buffer to the Ghostty surface on hydrate, so no // renderer change is required for the seed. const restoredHistory = await this.historyStore?.read(workspaceId); if (ownerInvalidated()) { diff --git a/main/services/types.ts b/main/services/types.ts index ce2ec9ad5..b7fa89fc0 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -321,6 +321,8 @@ export interface ChatMeta { } export interface Chat extends ChatMeta { + /** Main-owned receipt for an idempotent first-message commit; never renderer-authored. */ + firstMessageCommit?: { turnId: string; fingerprint: string }; /** Per-chat opt-in. The global Computer Use beta setting remains authoritative. */ computerUseEnabled?: boolean; messages: ChatMessage[]; diff --git a/main/services/visible-chat-projection.ts b/main/services/visible-chat-projection.ts index a81f53736..68a11d88a 100644 --- a/main/services/visible-chat-projection.ts +++ b/main/services/visible-chat-projection.ts @@ -37,8 +37,9 @@ export interface VisibleChatMessage { /** Strip private provider protocol before a Chat crosses into the renderer. */ export function chatForRenderer(chat: Chat | null): Chat | null { if (!chat) return null; + const { firstMessageCommit: _privateFirstMessageCommit, ...visibleChat } = chat; return { - ...chat, + ...visibleChat, messages: chat.messages.map((message) => { const { pi: _privatePiProtocol, ...visible } = message; return { diff --git a/package-lock.json b/package-lock.json index 359063cea..7ad551aac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aiden-agent", - "version": "0.39.0", + "version": "0.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aiden-agent", - "version": "0.39.0", + "version": "0.40.0", "hasInstallScript": true, "dependencies": { "@earendil-works/pi-agent-core": "0.84.4", @@ -16,10 +16,9 @@ "@radix-ui/colors": "^3.0.0", "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.36", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "0.12.0", - "@xterm/xterm": "^6.0.0", "acorn": "8.17.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1750,6 +1749,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1791,6 +1807,13 @@ "node": ">= 4" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2199,28 +2222,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -5322,27 +5323,6 @@ "node": ">=10.0.0" } }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" - }, - "node_modules/@xterm/addon-web-links": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", - "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", - "license": "MIT" - }, - "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -5398,16 +5378,15 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5431,28 +5410,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -5639,23 +5596,6 @@ "semver": "bin/semver.js" } }, - "node_modules/app-builder-lib/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -5692,13 +5632,6 @@ "node": ">=18" } }, - "node_modules/app-builder-lib/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib/node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -8084,6 +8017,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8125,6 +8075,13 @@ "node": ">= 4" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -10184,10 +10141,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-schema-typed": { diff --git a/package.json b/package.json index c461348db..d66a3fc8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aiden-agent", - "version": "0.39.0", + "version": "0.40.0", "private": true, "description": "A macOS AI workspace agent for local and hosted models", "keywords": [ @@ -27,7 +27,7 @@ "type": "module", "main": "build/main/index.js", "scripts": { - "build": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && vite build && node scripts/verify-gemini-live-worklet.mjs && npm run build:electron", + "build": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && vite build && node scripts/verify-gemini-live-worklet.mjs && node scripts/verify-ghostty-terminal-assets.mjs && npm run build:electron", "computer-use:vendor": "node scripts/vendor-cua-driver.mjs", "build:native": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && node scripts/build-foundation-models-helper.mjs --required", "build:native:optional": "npm run build:worktree-remover && npm run build:bot-inbox-writer && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && node scripts/build-foundation-models-helper.mjs --optional", @@ -46,31 +46,32 @@ "generative-ui:vendor": "node scripts/vendor-generative-ui-libs.mjs", "pretest:generative-ui": "npm run build:subagent-file-mutator", "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", - "pretest": "npm run build:worktree-remover && npm run test:browser && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", - "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", + "pretest": "npm run build:worktree-remover && npm run test:browser && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:ghostty-terminal && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", + "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:ghostty-terminal && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/inline-metadata-hierarchy.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:sidebar": "tsx --test renderer/components/chat-sidebar.test.tsx renderer/lib/sidebar-workspace-groups.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts", - "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", + "test:peer-hosts": "tsx --test main/services/peer-host-registry.test.ts main/services/peer-transport.test.ts", + "test:aiden-remote": "npm run test:peer-hosts && tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", "test:aiden-remote-chat-summaries": "tsx --test main/services/aiden-remote-chat-summaries.test.ts", "test:memory-policy": "tsx --test main/services/memory-policy.test.ts main/services/aiden-remote-memory-settings.test.ts renderer/components/settings/memory-settings.test.tsx", - "test:aiden-service-boundary": "tsx --test main/services/chat-application-service.test.ts main/services/chat-generation-owner.test.ts main/services/workspace-application-service.test.ts main/services/workspace-environment-application-service.test.ts main/services/workspace-worktree-application-service.test.ts main/services/scheduled-task-application-service.test.ts main/services/bot-application-service.test.ts", + "test:aiden-service-boundary": "tsx --test main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/chat-application-service.test.ts main/services/chat-generation-owner.test.ts main/services/workspace-application-service.test.ts main/services/workspace-environment-application-service.test.ts main/services/workspace-worktree-application-service.test.ts main/services/scheduled-task-application-service.test.ts main/services/bot-application-service.test.ts", "ios:asc-monitor": "node scripts/ios-asc-monitor.mjs", "ios:activitykit-process-proof": "node scripts/ios-live-activity-process-proof.mjs", "test:ios-release": "ruby ios/ci/select_testflight_build_number_test.rb && node --test scripts/check-ios-testflight-policy.test.mjs scripts/check-ios-app-store-metadata.test.mjs scripts/check-ios-shipping-target.test.mjs scripts/ios-asc-monitor.test.mjs scripts/ios-live-activity-process-proof.test.mjs", "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/check-ci-policy.test.mjs scripts/patch-pi-oauth-branding.test.mjs scripts/patch-electron-builder-keychain.test.mjs scripts/publish-github-release.test.mjs", - "test:scheduled": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-notification.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-script.test.ts main/services/schedule-tool.test.ts renderer/lib/scheduled-task-view.test.ts", + "test:scheduled": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-notification.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-script.test.ts main/services/schedule-tool.test.ts renderer/components/scheduled-task-editor.test.tsx renderer/lib/scheduled-task-view.test.ts", "test:artificial-analysis": "tsx --test main/services/artificial-analysis-cache.test.ts main/services/artificial-analysis-runtime-core.test.ts main/services/artificial-analysis-catalog-core.test.ts main/services/provider-model-info-core.test.ts renderer/lib/settings-section.test.ts", "test:model-insights": "tsx --test main/services/openrouter-benchmark.test.ts main/services/models.test.ts main/services/provider-model-info-core.test.ts main/handlers/ipc-contract.test.ts", "test:model-pad": "tsx --test renderer/components/settings/model-pad-settings.test.tsx renderer/lib/google-provider-migration.test.ts renderer/lib/model-pad-layout.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/pi-provider-display.test.ts", "test:command-system": "tsx --test main/services/native-menu-command-contract.test.ts main/services/renderer-readiness-core.test.ts main/services/shortcut-registration-core.test.ts main/services/shortcut-transaction-core.test.ts main/services/superseding-task-core.test.ts renderer/lib/appearance-intent.test.ts renderer/lib/command-palette-contract.test.ts renderer/lib/command-palette-recent.test.ts renderer/lib/command-system-core.test.ts renderer/lib/shortcut-settings-contract.test.ts renderer/lib/use-model-selection.test.ts renderer/shared/keybindings.test.ts", - "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts renderer/components/settings/skills-settings.test.tsx", + "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts renderer/components/settings/skills-settings.test.tsx", "test:display-image": "tsx --test main/services/display-image-artifact-store.test.ts main/services/display-image-extension.test.ts main/services/generation-timeline.test.ts renderer/components/message-bubble.test.tsx renderer/lib/ipc-stream.test.ts", "test:ask-user-question": "tsx --test renderer/shared/ask-user-question.test.ts main/services/ask-user-question-coordinator.test.ts main/services/ask-user-question-extension.test.ts renderer/components/ask-user-question-composer.test.ts", - "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts renderer/lib/ipc-stream.test.ts", + "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts main/handlers/chats.test.ts renderer/lib/ipc-stream.test.ts", "test:btw": "tsx --test main/services/rpiv-btw/*.test.ts renderer/shared/btw.test.ts renderer/components/btw-card.test.tsx", "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", - "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", + "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", "test:config-recovery": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", "pretest:subagents": "npm run build:worktree-remover && npm run build:subagent-run-store && node scripts/build-subagent-run-store.mjs --test && npm run build:subagent-file-mutator && node scripts/build-subagent-file-mutator.mjs --test && npm run build:subagent-shell-runner && node scripts/build-subagent-shell-runner.mjs --test && npm run test:subagents:inventory && npm run test:subagents:workspace-write && npm run test:subagents:phase5a && npm run test:subagents:phase5b && npm run test:subagents:phase5c && npm run test:subagents:phase5d && npm run test:subagents:phase5e && npm run test:subagents:phase6a && npm run test:subagents:phase6b && npm run test:subagents:phase7a && npm run test:subagents:soak:contracts", "test:subagents:inventory": "tsx --test main/services/subagents/subagent-mcp-inventory-core.test.ts main/services/subagents/subagent-inference-process-core.test.ts", @@ -95,6 +96,7 @@ "test:e2e:list": "playwright test --config=playwright.config.ts --list", "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts", "test:terminal:coverage": "tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal-spawn-helper.ts --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 main/services/terminal.test.ts && tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal.ts --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-functions=90 main/services/terminal.test.ts", + "test:ghostty-terminal": "tsx --test renderer/lib/ghostty-terminal/core.test.ts renderer/lib/ghostty-terminal/keyCodes.test.ts renderer/lib/ghostty-terminal/runtime.test.ts renderer/lib/ghostty-terminal/surface.test.ts renderer/components/terminal-drawer.test.tsx", "test:provider-failure": "tsx --test main/services/provider-failure.test.ts", "test:web-search": "tsx --test main/services/web-search.test.ts main/services/web-search-core.test.ts main/services/web-search-exa-core.test.ts main/services/web-search-provider-registry-core.test.ts main/services/web-search-provider-registry.test.ts main/services/web-search-credential-core.test.ts main/services/web-search-auth-reuse.test.ts main/services/web-search-rollout.test.ts main/services/web-search-wave1-adapters.test.ts main/services/web-search-wave1-ai.test.ts main/services/web-search-wave2-batch-a.test.ts main/services/web-search-wave2-batch-b.test.ts main/services/web-search-wave4-batch-a.test.ts main/services/web-search-wave4-batch-b.test.ts main/handlers/web-search-contract.test.ts renderer/components/settings/web-search-settings.test.tsx renderer/lib/settings-section.test.ts", "test:concentrate": "tsx --test main/services/concentrate-provider.test.ts", @@ -109,8 +111,8 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", - "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", + "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", @@ -147,10 +149,9 @@ "@radix-ui/colors": "^3.0.0", "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.36", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "0.12.0", - "@xterm/xterm": "^6.0.0", "acorn": "8.17.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index 0d36d3d18..f36cd6d32 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -3526,7 +3526,9 @@ "deviceType": { "enum": [ "iphone", - "ipad" + "ipad", + "mac", + "linux" ] }, "clientVersion": { diff --git a/renderer/components/btw-card.test.tsx b/renderer/components/btw-card.test.tsx index cd5b10e39..d942953db 100644 --- a/renderer/components/btw-card.test.tsx +++ b/renderer/components/btw-card.test.tsx @@ -68,7 +68,8 @@ test("BTW composer dispatch returns before durable chat append", () => { test("BTW slash eligibility is scoped to ordinary chat surfaces", () => { const pane = readFileSync(new URL("../main/chat-pane.tsx", import.meta.url), "utf8"); - assert.match(pane, /const sideQuestionBlockedReason = chat\.data\?\.botId \|\| bot\.data/u); + assert.match(pane, /const sideQuestionBlockedReason = draft[\s\S]*chat\.data\?\.botId \|\| bot\.data/u); + assert.match(pane, /Send the first message before asking a side question/u); assert.match(pane, /effectiveWorkspaceId === ASSISTANT_WORKSPACE_ID/u); assert.match(pane, /sideQuestionBlockedReason=\{sideQuestionBlockedReason\}/u); }); diff --git a/renderer/components/chat-sidebar.test.tsx b/renderer/components/chat-sidebar.test.tsx index c4330e025..31afde601 100644 --- a/renderer/components/chat-sidebar.test.tsx +++ b/renderer/components/chat-sidebar.test.tsx @@ -38,10 +38,11 @@ test("new agent uses the same sidebar row style as scheduled", () => { assert.doesNotMatch(section, /variant="accent"/u); }); -test("newAgent delegates explicit creation to the active workspace", () => { +test("newAgent opens a transient draft in the active workspace", () => { const sidebar = source("./chat-sidebar.tsx"); assert.match(sidebar, /const newAgentInWorkspace = React\.useCallback/u); - assert.match(sidebar, /chatsApi\.create\(\{ workspaceId \}\)/u); + assert.match(sidebar, /createChatDraft\(workspaceId\)/u); + assert.doesNotMatch(sidebar, /chatsApi\.create\(/u); assert.match(sidebar, /const newAgent = React\.useCallback\(async \(\) => \{/u); assert.match(sidebar, /if \(!activeId\) return;/u); assert.match(sidebar, /await newAgentInWorkspace\(activeId\)/u); diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index e589c1c60..c194ff83b 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -1,3 +1,4 @@ +import { createChatDraft, discardChatDraft } from "../lib/chat-draft"; // Unified workspace/chat sidebar with alternate workspace-grouped and recent // projections, route-driven selection, and workspace/chat management actions. @@ -740,7 +741,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { return () => unregister.forEach((dispose) => dispose()); }, [chatNavigationTargets, openChat, registerCommand, shortcutAssignments]); - // Move to a workspace and land on one of its chats (creating one if empty). + // Move to a workspace and open its latest chat, or an unsaved draft if empty. const enterWorkspace = React.useCallback( async (id: string, allowDirtyDiscard = false) => { if (environmentPanel.gitOperationBusy) { @@ -761,13 +762,13 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { toast.error("Reload Aiden before creating a chat in this workspace."); return false; } - const target = list[0] ?? (await chatsApi.create({ workspaceId: id })); - await qc.invalidateQueries({ queryKey: queryKeys.chats }); + const target = list[0] ?? createChatDraft(id).chat; const previousWorkspaceId = activeId; select(id); try { await navigate({ to: "/chat/$chatId", params: { chatId: target.id } }); } catch (error) { + if (!list.length) discardChatDraft(target.id); if (previousWorkspaceId) select(previousWorkspaceId); throw error; } @@ -934,11 +935,15 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { if (workspaceId !== activeId && environmentPanel.agentBusy) { environmentPanel.cancelAgent?.(); } - const created = await chatsApi.create({ workspaceId }); - await qc.invalidateQueries({ queryKey: queryKeys.chats }); + const created = createChatDraft(workspaceId).chat; select(workspaceId); setExpandedWorkspaceIds((current) => new Set(current).add(workspaceId)); - await navigate({ to: "/chat/$chatId", params: { chatId: created.id } }); + try { + await navigate({ to: "/chat/$chatId", params: { chatId: created.id } }); + } catch (error) { + discardChatDraft(created.id); + throw error; + } } catch (error) { toast.error(error instanceof Error ? error.message : "Aiden could not create a chat."); } diff --git a/renderer/components/composer.test.tsx b/renderer/components/composer.test.tsx index fa3ed992f..129456f15 100644 --- a/renderer/components/composer.test.tsx +++ b/renderer/components/composer.test.tsx @@ -1,11 +1,37 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ProviderIcon } from "./provider-icon"; +import type { ProviderArtwork } from "../shared/provider-artwork"; function source(relativePath: string): string { return readFileSync(new URL(relativePath, import.meta.url), "utf8"); } +const PROVIDER_ARTWORK: ProviderArtwork = { + mimeType: "image/png", + dataBase64: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", +}; + +test("custom provider artwork keeps its original pixels instead of becoming a mask", () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /^ { const composer = source("./composer.tsx"); const bar = source("./composer-context-bar.tsx"); @@ -57,7 +83,8 @@ test("composer context controls stay compact without exposing provider copy", () assert.match(composer, /aria-controls=\{permissionOptionsId\}/u); assert.doesNotMatch(composer, /aria-haspopup=\{true\}/u); assert.match(composer, /group-data-\[open=true\]\/access:visible/u); - assert.match(composer, /bg-control\/80/u); + assert.match(composer, /rounded-dialog bg-popover p-1/u); + assert.doesNotMatch(composer, /bg-control\/80/u); assert.match(composer, /selected\s*\? "bg-popover shadow-control"/u); assert.doesNotMatch(composer, /group-hover\/access:max-h/u); assert.match(composer, /aria-disabled=\{disabled \|\| undefined\}/u); @@ -185,7 +212,7 @@ test("composer slash palette is an overlaid textarea-owned accessible listbox", const optimisticClear = composer.indexOf('setText("");'); const sendAwait = composer.indexOf("await submit("); assert.ok(optimisticClear >= 0 && optimisticClear < sendAwait); - assert.match(composer, /if \(sendPendingRef\.current\) return false;/u); + assert.match(composer, /if \(sendPendingRef\.current \|\| firstSendPendingRef\.current\) return false;/u); assert.match(composer, /type: "send-started"/u); assert.match(composer, /failedSendDraft\(payload\.draftText, currentDraft\)/u); assert.match(composer, /failedSendAttachments\([\s\S]{0,160}payload\.attachments/u); @@ -216,7 +243,7 @@ test("selected session slash commands dispatch through explicit Aiden-owned work assert.match(composer, /authenticatedProviders\.map\(\(provider\)/u); assert.match(composer, /openWorktreeOnMount=\{worktreeRequest > 0\}/u); assert.match(composer, /programmaticReturnFocusRef=\{inputRef\}/u); - assert.match(composer, /readOnly=\{sessionCommandBusy\}/u); + assert.match(composer, /readOnly=\{sessionCommandBusy \|\| firstSendPending\}/u); assert.match(composer, /role="status" aria-live="polite"/u); assert.match(branchPicker, /openManagedWorktree \? "worktree" : null/u); assert.match(chatPane, /chatsApi\.copyVisibleHistory\([\s\S]{0,100}throughAssistantMessageId/u); @@ -285,7 +312,7 @@ test("model picker details sit beside the menu without overlapping the pad", () ); assert.match( modelPicker, - /className="pointer-events-none w-56 shrink-0 rounded-popover bg-popover p-3 text-primary shadow-popover"/u, + /className="pointer-events-auto flex h-\[min\(22\.5rem,70vh\)\] w-56 shrink-0 flex-col overflow-hidden rounded-popover bg-popover p-3 text-primary shadow-popover"/u, ); assert.doesNotMatch(modelPicker, /left-\[calc\(100%\+0\.5rem\)\]/u); assert.doesNotMatch(modelPicker, /right: showExternalDetails/u); @@ -294,4 +321,44 @@ test("model picker details sit beside the menu without overlapping the pad", () styles, /\.model-pad:focus-visible\s*\{\s*outline: none !important;\s*box-shadow:\s*inset 0 0 0 2px var\(--focus-ring\)/u, ); + assert.match(pad, /import \{ ProviderIcon \} from "\.\/provider-icon"/u); + assert.match(pad, /artwork=\{puckPoint\.providerArtwork\}/u); + assert.match( + styles, + /\.model-pad-knob\s*\{[^}]*color: var\(--model-pad-knob-foreground\)/u, + ); + assert.match( + styles, + /\.model-pad-knob\[data-confirmed="true"\]\s*\{[^}]*color: var\(--accent-foreground\)/u, + ); +}); + +test("first-send draft freeze blocks edits and browser annotation delivery until commit", () => { + const composer = source("./composer.tsx"); + assert.match(composer, /firstSendPendingRef\.current = freezeWhileSending/u); + assert.match(composer, /inert=\{firstSendPending \|\| undefined\}/u); + assert.match(composer, /if \(firstSendPendingRef\.current \|\| !available\(\)\) return false/u); + assert.match(composer, /readOnly=\{sessionCommandBusy \|\| firstSendPending\}/u); + assert.match(composer, /role="status"[^\n]*Sending…/u); +}); + + +test("reopening a draft uses its shared pending state instead of fresh composer state", () => { + const composer = source("./composer.tsx"); + const pane = source("../main/chat-pane.tsx"); + assert.match(pane, /firstMessageSaving=\{draft\?\.sending === true\}/u); + assert.match(composer, /const firstSendPending = firstMessageSaving \|\| \(freezeWhileSending && sending\)/u); + assert.match(composer, /!firstMessageSaving &&/u); + assert.match(composer, /if \(sendPendingRef\.current \|\| firstSendPendingRef\.current\) return false/u); +}); + +test("voice recovery preserves the draft and offers a direct settings action", () => { + const composer = source("./composer.tsx"); + const recorder = source("../lib/use-voice-recorder.ts"); + assert.match(composer, /voice.lastError/u); + assert.match(composer, /Open voice settings/u); + assert.match(composer, /Your draft is still here/u); + assert.match(composer, /voice.dismissError/u); + assert.match(recorder, /setLastError\(message\)/u); + assert.match(composer, /onOpenSettings && readinessSettingsSection/u); }); diff --git a/renderer/components/composer.tsx b/renderer/components/composer.tsx index 364541835..e96f691db 100644 --- a/renderer/components/composer.tsx +++ b/renderer/components/composer.tsx @@ -117,6 +117,7 @@ interface ComposerProps { ready: boolean; /** Actionable explanation for a disabled send state. */ readinessMessage?: string; + readinessSettingsSection?: SettingsSection; /** True once this chat has a persisted message. */ hasMessages: boolean; /** Stable identifier used to select an empty-chat prompt. */ @@ -135,6 +136,10 @@ interface ComposerProps { canStopGeneration?: boolean; /** Blocks both click and Enter submission while a model-scoped option is being saved. */ configurationBusy?: boolean; + /** New-agent drafts cannot accept edits while their first message commits. */ + freezeWhileSending?: boolean; + /** Survives navigating away and reopening a draft whose commit is pending. */ + firstMessageSaving?: boolean; inputRef?: React.RefObject; workspace?: Workspace; /** Current git branch of the workspace folder, or undefined if not a repo. */ @@ -269,6 +274,7 @@ function composerDraftReducer( export function Composer({ ready, readinessMessage, + readinessSettingsSection, hasMessages, chatId, onSend, @@ -279,6 +285,8 @@ export function Composer({ isGenerating, canStopGeneration = isGenerating, configurationBusy = false, + freezeWhileSending = false, + firstMessageSaving = false, inputRef, workspace, gitBranch, @@ -329,6 +337,7 @@ export function Composer({ React.useLayoutEffect(() => { draftRef.current = draft; }, [draft]); + const firstSendPendingRef = React.useRef(false); const slashActionPendingRef = React.useRef(false); const sessionCommandBusyRef = React.useRef(false); const slashPaletteBlockedRef = React.useRef(slashPaletteBlocked); @@ -374,7 +383,7 @@ export function Composer({ return Boolean(input?.isConnected && !input.disabled && !input.readOnly && input.getClientRects().length && !input.closest('[aria-hidden="true"], [inert]')); }; const receive = (annotation: BrowserAnnotation) => { - if (!available()) return false; + if (firstSendPendingRef.current || !available()) return false; const result = browserAnnotationAttachments(annotation, attachmentsRef.current, visionSupported !== false, crypto.randomUUID()); const comment = annotation.comment.trim(); const fallback = result.attachments.some((item) => item.kind === "text") ? "" : browserAnnotationContext(annotation); @@ -398,6 +407,8 @@ export function Composer({ const attachmentDescriptionId = React.useId(); const [sending, setSending] = React.useState(false); const sendPendingRef = React.useRef(false); + const firstSendPending = firstMessageSaving || (freezeWhileSending && sending); + firstSendPendingRef.current = firstSendPending; const [permissionSaving, setPermissionSaving] = React.useState(false); const [confirmFullAccess, setConfirmFullAccess] = React.useState(false); const [permissionMenuOpen, setPermissionMenuOpen] = React.useState(false); @@ -454,6 +465,7 @@ export function Composer({ attaching, }) && !configurationBusy && + !firstMessageSaving && !sessionCommandBusy; const settings = useSettings(); const skillCatalog = useDiscoveredSkills(workspace?.id); @@ -481,7 +493,9 @@ export function Composer({ !composing && (!selectedSkillState || selectedSkillState.state === "valid"); const voice = useVoiceRecorder( - (transcript) => setText((prev) => (prev.trim() ? `${prev.trim()} ${transcript}` : transcript)), + (transcript) => { + if (!firstSendPendingRef.current) setText((prev) => (prev.trim() ? `${prev.trim()} ${transcript}` : transcript)); + }, { provider: settings.data?.voiceProvider ?? "openai", localModel: settings.data?.localVoiceModel, @@ -814,8 +828,9 @@ export function Composer({ }): Promise => { // React state does not close the same-tick Enter + click window. Claim // the send synchronously before making any optimistic UI changes. - if (sendPendingRef.current) return false; + if (sendPendingRef.current || firstSendPendingRef.current) return false; sendPendingRef.current = true; + firstSendPendingRef.current = freezeWhileSending; setSending(true); setText(""); @@ -875,15 +890,17 @@ export function Composer({ throw error; } finally { sendPendingRef.current = false; + firstSendPendingRef.current = false; setSending(false); } }, - [onSend, onQueue, isGenerating, hasQueuedMessages, setText, updateAttachments], + [onSend, onQueue, isGenerating, hasQueuedMessages, freezeWhileSending, setText, updateAttachments], ); const selectSlashResult = React.useCallback( async (result: SlashResult) => { if ( + firstSendPendingRef.current || slashActionPendingRef.current || !slashSession || result.kind !== slashSession.kind || @@ -1091,6 +1108,7 @@ export function Composer({ }, [onRenameChat, renameTitle, renaming]); const beginAttachmentRead = (status: string): boolean => { + if (firstSendPendingRef.current) return false; if (gitOperationBusy) { toast.info("Wait for the current Git operation to finish before attaching files."); return false; @@ -1256,6 +1274,7 @@ export function Composer({ }; const handlePaste = (event: React.ClipboardEvent) => { + if (firstSendPendingRef.current) { event.preventDefault(); return; } const images = Array.from(event.clipboardData.items).flatMap((item) => { if (item.kind !== "file" || !CLIPBOARD_IMAGE_MIME_TYPES.has(item.type.toLowerCase())) { return []; @@ -1282,6 +1301,7 @@ export function Composer({ }; const removeAttachment = (id: string) => { + if (firstSendPendingRef.current) return; attachmentRevisionRef.current += 1; updateAttachments((prev) => prev.filter((a) => a.id !== id)); }; @@ -1455,7 +1475,8 @@ export function Composer({ return ( <>
-
+ {firstSendPending ? Sending… : null} +
{ + if (firstSendPendingRef.current) return; setText(event.target.value); updateSelection({ start: event.target.selectionStart, @@ -1762,9 +1784,17 @@ export function Composer({ ) : null}
) : null} - {!ready && readinessMessage && text.trim().length > 0 ? ( + {voice.lastError ? ( +
+ {voice.lastError} Your draft is still here. + {onOpenSettings ? : null} + +
+ ) : null} + {!ready && readinessMessage ? ( {readinessMessage} + {onOpenSettings && readinessSettingsSection ? : null} ) : null}
@@ -1845,7 +1875,7 @@ export function Composer({ Boolean(workspaceChangeBlockedReason) || undefined } - className="invisible pointer-events-none absolute bottom-full left-0 z-20 flex min-w-34 translate-y-1 flex-col items-stretch overflow-hidden rounded-dialog bg-control/80 p-1 opacity-0 shadow-control-hover transition-[opacity,transform,visibility] duration-100 ease-out group-data-[open=true]/access:visible group-data-[open=true]/access:pointer-events-auto group-data-[open=true]/access:translate-y-0 group-data-[open=true]/access:opacity-100" + className="invisible pointer-events-none absolute bottom-full left-0 z-20 flex min-w-34 translate-y-1 flex-col items-stretch overflow-hidden rounded-dialog bg-popover p-1 opacity-0 shadow-control-hover transition-[opacity,transform,visibility] duration-100 ease-out group-data-[open=true]/access:visible group-data-[open=true]/access:pointer-events-auto group-data-[open=true]/access:translate-y-0 group-data-[open=true]/access:opacity-100" > {PERMISSION_ORDER.map((value, index) => { const meta = PERMISSION_META[value]; diff --git a/renderer/components/git-push-dialog.tsx b/renderer/components/git-push-dialog.tsx index ce28e90b9..3ebbabfaa 100644 --- a/renderer/components/git-push-dialog.tsx +++ b/renderer/components/git-push-dialog.tsx @@ -214,7 +214,7 @@ export function GitPushDialog({
-

diff --git a/renderer/components/model-picker.tsx b/renderer/components/model-picker.tsx index 4efc7f6bb..103eb7af9 100644 --- a/renderer/components/model-picker.tsx +++ b/renderer/components/model-picker.tsx @@ -257,7 +257,8 @@ function ModelHoverDetails({ ].filter((row): row is [string, string] => Boolean(row)); return ( -