diff --git a/AGENTS.md b/AGENTS.md index 8e44569d..effb3408 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,68 @@ # AGENTS.md +Chat removal remains pending during the four-second Undo toast; the backend +delete is sent only on toast close. Window shutdown before close cancels that +uncommitted grace. A refused delete restores the Chat and shows an error, +matching History's existing rollback. The toast is not proof of durable deletion. + +Enabled skills are offered by name enum in the model's skill tool schema; +disabled skills and empty catalogs are not offered. Content stays loaded only +through the skill tool. Native Code watch uses SDK reconnect and refreshes +durable state on each accepted connection; owner abort closes that subscription. + +File tools canonicalize existing paths and the nearest existing parent of new +paths before external_directory checks. An in-project symlink cannot bypass +external read/write permission. The tool uses the checked canonical path. + +Pending native call starts permit hangup only from that initiating window; +pending ownership uses request identity so an older same-window completion +cannot erase the newer pending owner. Active IPC ownership commits after start. + +Native logout accepts the producer's 200 or 204 without opening logout_url. +Failed keyring reads preserve stored bytes. A rotated pair whose owner identity +read fails transiently is saved under the previously verified owner, while the +session stays signed out until a later successful identity verification. + +Renderer bus reconnects after transport errors or unexpected EOF, with a +one-second retry owned by the active listeners. Last listener cancels stream +and retry. Query/message caches resync on interruption; no wire replay claim. +Automatic missing-ID Chat redirect replaces its history entry; Back never +returns to that invalid redirect entry. + +Connected MCP tools default to ask by exact exposed tool name before explicit +user/agent/Bot rules. Saved grants remain effective for non-computer-input calls; +computer-use inputs keep mandatory per-action consent. + +Subagents inherit parent-agent deny rules after their own rules, so plan's +read-only restriction cannot escape through task delegation or saved grants. +Computer-use observation is identified by a leading observation verb, never +an app/window/element noun anywhere in the action name. + +Bot/task PATCH parsers leave omitted fields absent: rename preserves persona, +tool filter, permission rules, mascot and routine enabled state. Create defaults +remain unchanged. Locale parity compares keys in both directions; generated +browser-connector screenshots use per-test output, never tracked evidence. + +Remote restore failures cannot block window creation: boot continues signed +out, retaining unreadable credential bytes. Credential-store set/delete still +refuse invalid stores; no automatic replacement or erasure is authorized. + +Native call starts fence pending authentication by generation; superseding +start/end cannot create an orphan session or acquire IPC ownership late. +Browser-host request bodies accumulate bytes before UTF-8 decoding once; +the two-million-byte cap applies to bytes, not decoded characters. + +SDK 0.4.9 / api-types 0.3.8 archives carry pending-image reconnect and visible +failure on live server-error frames. Relative consumer paths and lock hashes +must match producer archives; shared tests do not prove deployed voice service. +Resampling retains the previous input sample across 128-sample worklet chunks; +chunked 44.1/48 kHz signal matches contiguous interpolation exactly. + +Local `glob` and `grep` enforce `read` permission on their search root and each +discovered file before returning names or reading contents. A per-file deny +cannot be bypassed through search. Regression: core session tests with denied +secret.txt on both tools. + Signed-in Work/Bot Channels manages owned group metadata through the approved exact channel SDK pair. List/filter/create/get/rename/full member replacement/delete only. Rename omits membership until explicitly edited; unlisted stored members remain @@ -235,6 +298,7 @@ preview fixtures. Only visible gallery iframes load; offscreen previews unload t | `CORTEX_STAGING_API_ORIGIN` | same | Required non-production HTTPS origin for staging; embedded in main only, invalid or ambiguous configuration fails build | | `CORTEX_DATA_DIR` | `packages/desktop/src/main.ts`, `scripts/dev-api.ts` | Engine data dir (default `/engine`) | | `CORTEX_CATALOG_URL` | same | Override `https://models.dev/api.json` | +| `CORTEX_UPDATE_FEED_URL` | `packages/desktop/src/main.ts`, `updater.ts` | Optional native update feed; HTTPS or literal loopback only | | `CORTEX_LOCALE` | `main.ts` | Native menu locale and the app default when Settings has no saved locale (fallback: first supported OS language, else English) | | `CORTEX_START_HASH` | `main.ts` | Initial route hash, e.g. `#/settings?section=providers` | | `CORTEX_RENDERER_URL` | `main.ts` | Load the renderer from a dev server | @@ -626,8 +690,10 @@ missing glyphs. See [`docs/i18n.md`](./docs/i18n.md). **Production release and signing are not configured.** The old build/publish workflows were removed; CI packages with `--publish never`, `-c.mac.identity=null`, `-c.mac.notarize=false`. -The old publish actions, `publish:` block and workflow README are removed. No auto-updater -is wired in main. Workflow configuration is not a passing run. +The old publish actions, `publish:` block and workflow README are removed. Main wires +the native updater only when `CORTEX_UPDATE_FEED_URL` supplies a validated HTTPS +or literal loopback feed. Production signing/publishing remains unconfigured. +Workflow configuration is not a passing run. `publish-staging.yml` is an explicit main-only dispatch gated by exact-SHA successful CI, including Windows build/package/launch. It defines unsigned Linux x64 AppImage/deb diff --git a/bun.lock b/bun.lock index 70ff2975..78ab1b09 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "name": "@cortex/app", "version": "0.2.0", "dependencies": { - "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.5.tgz", + "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.8.tgz", "@cortex/client": "workspace:*", "@cortex/i18n": "workspace:*", "@cortex/schema": "workspace:*", @@ -71,11 +71,11 @@ "name": "@cortex/desktop", "version": "0.2.0", "dependencies": { - "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.5.tgz", + "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.8.tgz", "@cortex/core": "workspace:*", "@cortex/i18n": "workspace:*", "@cortex/schema": "workspace:*", - "@cortex/sdk": "file:../../vendor/trunk/cortex-sdk-0.4.6.tgz", + "@cortex/sdk": "file:../../vendor/trunk/cortex-sdk-0.4.9.tgz", "@cortex/server": "workspace:*", }, }, @@ -161,7 +161,7 @@ "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], - "@cortex/api-types": ["@cortex/api-types@../../vendor/trunk/cortex-api-types-0.3.5.tgz", {}, "sha512-dkXfkALjBTjz0bDh05+uoKXHj982/DJLMLGECNBDZaNT82IjZXADJ+geN9WcBlvgWzhe5Qr1zL/IWeB/VF9+Xg=="], + "@cortex/api-types": ["@cortex/api-types@../../vendor/trunk/cortex-api-types-0.3.8.tgz", {}, "sha512-8U3Z5yvE4BIsIuYCSCJu0a/7x1wTYN40RwktZ3CG9gAjqtxUHh2YCYxnzk7SlvmuU13N+Whzayf2/er55v2SgA=="], "@cortex/app": ["@cortex/app@workspace:packages/app"], @@ -177,7 +177,7 @@ "@cortex/schema": ["@cortex/schema@workspace:packages/schema"], - "@cortex/sdk": ["@cortex/sdk@../../vendor/trunk/cortex-sdk-0.4.6.tgz", { "peerDependencies": { "@cortex/api-types": "0.3.5" }, "optionalPeers": ["@cortex/api-types"] }, "sha512-vuCgK3gaPMAgCExA7SsMxjNvhvAgMf7Tygcm1qePP+SmDeQ/62vVM/c4Bl7T/gNyxFgF+2YkEC12penPQog2Gw=="], + "@cortex/sdk": ["@cortex/sdk@../../vendor/trunk/cortex-sdk-0.4.9.tgz", { "peerDependencies": { "@cortex/api-types": "0.3.8" }, "optionalPeers": ["@cortex/api-types"] }, "sha512-02VGzxf2rVeWvEKXEJyPCUnWD3kgraGkgdOaUgw/bngyuLYZdax5HfYOVisM4eqgkq3MUglorrl6EgbOSK2GcQ=="], "@cortex/server": ["@cortex/server@workspace:packages/server"], diff --git a/docs/audit-2026-10-08.md b/docs/audit-2026-10-08.md new file mode 100644 index 00000000..c5462466 --- /dev/null +++ b/docs/audit-2026-10-08.md @@ -0,0 +1,65 @@ +# Desktop architecture and interaction audit + +Fresh GitHub baseline: `e6028e5c`. One desktop PR accompanies the backend audit +PR. No merge or deployment is part of this work. + +## Confirmed corrections + +- Glob/grep enforce read permission on roots and discovered files. Canonical + paths and nearest existing parents prevent symlink escapes from bypassing + external-directory permission. +- Child sessions inherit parent-agent deny rules; plan delegation cannot turn + into a write. Connected MCP tools default to ask before explicit overrides. + Computer-use observation requires a leading observation verb, not an action + name containing an app/window noun. Enabled skills expose selectable names. +- Bot/task PATCH leaves omitted defaults absent, preserving permission, + persona, tool filter, mascot and paused scheduled-task state. +- Native call starts fence pending authentication and ownership. Hangup can + cancel a pending start without acquiring another window's call controls. +- Browser loopback bodies count bytes and decode UTF-8 once, preserving split + multibyte sequences and the existing browser-read output limit. +- Unreadable credentials cannot prevent window creation or erase ciphertext. + A rotated pair survives transient identity reads without promoting an + unverified account. Native logout accepts the producer's 200 response. +- Renderer event subscriptions reconnect and release their resources; cached + queries and transcripts resync. Code watches use the SDK reconnect policy + and refresh on each accepted connection. Missing-ID Chat redirects replace + history rather than trapping Back. +- Refused deferred Chat deletion restores the route and shows an error. The + four-second Undo grace remains uncommitted until the toast closes. +- Locale key parity is bidirectional; tests no longer overwrite tracked browser + connector screenshots. SDK 0.4.9/API types 0.3.8 adopt backend stream/audio fixes. + +## Verification + +Actual built Electron passed 239 end-to-end scenarios under Xvfb, including +owner transitions, auth, live chat, streaming and work flows. Subsequent targeted +checks covered later changes. The source integration suite passed 509 tests with +one optional skip, types/lint clean and no i18n audit problems. The final +forced-DELETE-500 Electron case restored the Chat and showed an error, with +types/lint clean. CI evidence must match the latest pushed SHA. + +Native auth used real loopback transport. Corrupt-store boot opened an actual +Electron window signed out, preserving the corrupt bytes. Shared resampling +passed exact 44.1/48 kHz segmented-versus-contiguous signal tests; Chromium +verified actual microphone capture, worklet setup and playback credit. + +Current rendered Chat redirects were inspected at 960x640 and 1440x900 in +light/dark themes after fonts and finite animations settled. No stable blank, +overlap or horizontal overflow appeared. Older early captures were rejected +as readiness failures, not counted as product failures. + +## Decisions and acceptance boundaries + +Undo deletion follows the existing History contract: DELETE occurs on toast +close. Window shutdown during the grace cancels an uncommitted deletion. The +toast alone never establishes durable deletion. The alleged pending Code-title +defect had no current source/probe evidence and was not retained. + +Foreign tracked pairing screenshots are excluded from this change. Unselected +intermediate SDK archives are excluded; previous tracked archives remain for +provenance, while manifests select only the current pair. + +Linux Electron checks are not signed macOS/Windows package or real-user provider +acceptance. Actual deployed Bot voice/device/farm availability remains outside +the controlled test evidence. A finite audit cannot prove no unknown bugs remain. diff --git a/docs/browser-connector.md b/docs/browser-connector.md index 00342aef..6ffa5a4f 100644 --- a/docs/browser-connector.md +++ b/docs/browser-connector.md @@ -1,5 +1,9 @@ # Chrome connector +Loopback bodies enforce the two-million-byte cap before decoding UTF-8 once; +split multibyte sequences survive transport. `browser_read` retains its existing +50,000-character limit independently of the HTTP body cap. + `packages/chrome-extension` (Manifest V3, load unpacked from the folder the **Install extension** button opens) talks to the desktop app over a loopback HTTP endpoint (`127.0.0.1`, ports 47821-47825) owned by `packages/desktop/src/browser-host.ts`. Chrome native messaging was not used: it needs a per-OS manifest registration and an extension ID that only exists after store publishing. diff --git a/docs/connection-modes.md b/docs/connection-modes.md index 31062302..f0cc433d 100644 --- a/docs/connection-modes.md +++ b/docs/connection-modes.md @@ -1,5 +1,10 @@ # Connection modes +Native logout accepts 200/204, ignoring logout_url. Keyring read failures keep +the ciphertext. After token rotation, transient identity-read failures preserve +the new pair under the previously verified owner but do not promote a signed-in +identity. Revoked tokens and owner mismatches still clear the stored pair. + ## Task8 bounded Work/Bot Work/Bot -> Channels uses the exact e5e-approved SDK pair for owned group metadata. diff --git a/docs/engine.md b/docs/engine.md index b0c1006b..b818773b 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -1,5 +1,29 @@ # Engine +Chat deletion waits for the four-second Undo grace; a closed window cancels +an uncommitted grace, not a persisted delete. Refused deletes restore the Chat +and display an error. Enabled skill names appear in the model tool's enum, +while SKILL.md content loads only when selected. + +File-path permission checks use canonical targets, including existing parent +directories of files to create. Symlink escapes require external_directory +approval before read/write or discovered-file content reaches the model. + +The client reports unexpected event-stream EOF; the renderer owns reconnect +and releases its stream/retry when the last subscriber leaves. Cached local +queries and transcript snapshots re-read after interruption, without replay. + +Child sessions inherit parent-agent deny rules; plan delegation cannot write, +edit or execute shell commands. Saved grants never override those denials. + +Partial Bot updates preserve omitted configuration and permission fields; +partial scheduled-task updates preserve pause state. Create defaults never +apply implicitly to a PATCH. + +Native boot opens the window signed out when persisted remote restore fails; +invalid credential-store bytes remain untouched. Calls fence pending starts +before authentication resolves and before acquiring IPC ownership. + `@cortex/core`, created with `createCore(opts)` (`packages/core/src/index.ts`). Nothing touches the network or starts timers until `start()`, which registers the computer-use preset (when a driver was found), scans plugins, connects enabled MCP servers and starts @@ -260,13 +284,17 @@ Agents (`agent.ts`): `build` (default), `plan` (read-only; write/edit/bash denie `general` and `explore` (subagents). Tools (`tool.ts`): `read write edit list glob grep bash webfetch todowrite task skill`. File tools exist only when the session has a `directory`; paths outside it ask `external_directory`. `task` spawns a child session; -children never get `task`. MCP tools are exposed as `_` and always ask. +children never get `task`. MCP tools are exposed as `_` and default +to ask before user/agent/Bot rules; explicit allow and saved grants still apply. +Computer-use inputs always ask even under allow and never save Always. ## Permissions Rules `(tool, pattern, allow|ask|deny)`, last match wins, default ask. Order: defaults (`bash`, `write`, `edit`, `external_directory` ask) → user rules (`/api/permissions/rules`) → agent → bot. A configured deny beats saved approvals. +`glob` and `grep` check `read` permission on the search root and every discovered +file before returning names or reading contents; per-file denials remain effective. Replies: `once`, `always` (saved per project directory), `reject` (stops the loop and cancels the session's other pending asks). Computer-use input tools never save `always` ([computer-use.md](./computer-use.md)). diff --git a/packages/app/package.json b/packages/app/package.json index 13d8b05d..b69bacc3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1 +1 @@ -{ "name": "@cortex/app", "version": "0.2.0", "private": true, "type": "module", "dependencies": { "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.5.tgz", "@cortex/client": "workspace:*", "@cortex/schema": "workspace:*", "@cortex/i18n": "workspace:*" } } +{ "name": "@cortex/app", "version": "0.2.0", "private": true, "type": "module", "dependencies": { "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.8.tgz", "@cortex/client": "workspace:*", "@cortex/schema": "workspace:*", "@cortex/i18n": "workspace:*" } } diff --git a/packages/app/src/kit/styles.css b/packages/app/src/kit/styles.css index abc74b65..462aa279 100644 --- a/packages/app/src/kit/styles.css +++ b/packages/app/src/kit/styles.css @@ -259,7 +259,7 @@ html[data-vt="theme"]::view-transition-group(content), html[data-vt="theme"]::vi .dock .hint { font-size: 11px; color: var(--t3); } .code { border-radius: 12px; background: var(--code-bg); border: 1px solid var(--line); overflow: hidden; margin: 6px 0 10px; } .code-head { height: 32px; display: flex; align-items: center; padding: 0 6px 0 12px; border-bottom: 1px solid var(--line); color: var(--t2); font-size: 11px; } -.code-head span { flex: 1; font-family: var(--mono); } +.code-head span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); } .code pre { margin: 0; padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 19px; overflow: auto; } .typing { display: inline-flex; gap: 3px; } .typing i { width: 5px; height: 5px; border-radius: 3px; background: var(--t3); animation: blink 1s infinite both; } .typing i:nth-child(2) { animation-delay: .15s; } .typing i:nth-child(3) { animation-delay: .3s; } @@ -409,7 +409,7 @@ textarea.input { height: 80px; padding: 8px 10px; resize: none; } .diff { margin: 0 14px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--code-bg); overflow: hidden; animation: rise 280ms var(--ease-out) both; } .split-r:has(> .diff) { overflow: auto; } /* Header (32px), one code line (20px), pre padding (16px), borders (2px). */ -.split-r > .diff { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 70px; } +.split-r > .diff { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); min-height: 70px; } .diff pre, .term { margin: 0; padding: 8px 0; font-family: var(--mono); font-size: 12px; line-height: 20px; overflow: auto; } .diff pre div { padding: 0 14px; white-space: pre; } .diff .add { background: color-mix(in srgb, var(--green) 14%, transparent); } diff --git a/packages/app/src/screens/chat/live-chat.tsx b/packages/app/src/screens/chat/live-chat.tsx index 77d9c94e..b9149955 100644 --- a/packages/app/src/screens/chat/live-chat.tsx +++ b/packages/app/src/screens/chat/live-chat.tsx @@ -117,7 +117,7 @@ function LocalHome() { export function Chat() { const { params, go, entryKey } = useNav(); const id = params.get("id"); - React.useEffect(() => { if (!id && !isPreview()) go("home"); }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + React.useEffect(() => { if (!id && !isPreview()) go("home", undefined, null, true); }, [id]); // eslint-disable-line react-hooks/exhaustive-deps if (!isPreview() && params.has("source")) return ; if (id) return ; const start = previewChatStart(); @@ -348,7 +348,7 @@ function LiveChat({ id }: { id: string }) { if (opening.current) return; let undone = false; toast.add({ title: t("chat.toast.deleted"), description: title, data: { undo: true, icon: "trash", onUndo: () => { undone = true; go("chat", { id }); } }, - onClose: () => { if (!undone) api.sessions.delete(id).catch(() => {}); } }); + onClose: () => { if (!undone) api.sessions.delete(id).catch(() => { if (readHash().route === "home") go("chat", { id }); toast.add({ title: t("chat.err.generic.title"), data: { icon: "alert-triangle" } }); }); } }); go("home"); }; const movable = session.state === "ready" && session.data.kind === "chat" && !session.data.parentID; diff --git a/packages/app/src/shell/nav.tsx b/packages/app/src/shell/nav.tsx index 8981d316..0b3af7cd 100644 --- a/packages/app/src/shell/nav.tsx +++ b/packages/app/src/shell/nav.tsx @@ -10,7 +10,7 @@ export const useNav = () => React.useContext(NavCtx); // ponytail: Electron supplies Navigation; remove this narrow type when lib.dom declares it. export const navigation = (window as unknown as { navigation: EventTarget & { canGoBack: boolean; canGoForward: boolean; currentEntry: { key: string } } }).navigation; -export function go(route: Route, params?: Record, conversation: { text: string; model: string } | null = null) { +export function go(route: Route, params?: Record, conversation: { text: string; model: string } | null = null, replace = false) { const sp = readHash().params; for (const k of [...sp.keys()]) if (!["theme", "shot", "preview"].includes(k)) sp.delete(k); for (const [k, v] of Object.entries(params ?? {})) sp.set(k, v); @@ -19,7 +19,7 @@ export function go(route: Route, params?: Record, conversation: const preview = sp.has("preview") || sp.has("shot"); const state = preview ? { cortexChat: conversation } : null; // Personal and fixture conversations can share a URL; history still needs distinct identities. - if (hash === location.hash && !conversation && !history.state?.cortexChat && !["home", "code"].includes(route)) history.replaceState(state, "", hash); + if (replace || (hash === location.hash && !conversation && !history.state?.cortexChat && !["home", "code"].includes(route))) history.replaceState(state, "", hash); else history.pushState(state, "", hash); dispatchEvent(new Event("cortex-variant")); } diff --git a/packages/app/src/state/live.ts b/packages/app/src/state/live.ts index 3fed2230..b5c8424c 100644 --- a/packages/app/src/state/live.ts +++ b/packages/app/src/state/live.ts @@ -5,11 +5,40 @@ import { api } from "../api"; type Listener = (e: Event) => void; const listeners = new Set(); +const resync = new Set<() => void>(); let unsub: (() => void) | null = null; -export function onEvent(l: Listener) { +let reconnect: ReturnType | undefined; +let stale = false; +const connect = () => { + reconnect = undefined; + if (!listeners.size || unsub) return; + // Resync only once the replacement stream is attached: a snapshot taken earlier could miss events + // sent before it, and one taken during the outage would fail. Failed opens keep `stale` for the next try. + unsub = api.subscribe((e) => listeners.forEach((f) => f(e)), { + onOpen: () => { if (stale) { stale = false; resync.forEach((refresh) => refresh()); } }, + onError: () => { + unsub?.(); + unsub = null; + stale = true; + if (listeners.size) reconnect = setTimeout(connect, 1000); + }, + }); +}; +export function onEvent(l: Listener, refresh?: () => void) { listeners.add(l); - if (!unsub) unsub = api.subscribe((e) => listeners.forEach((f) => f(e)), { onError: () => {} }); - return () => { listeners.delete(l); }; + if (refresh) resync.add(refresh); + if (!unsub && !reconnect) connect(); + return () => { + listeners.delete(l); + if (refresh) resync.delete(refresh); + if (!listeners.size) { + unsub?.(); + unsub = null; + clearTimeout(reconnect); + reconnect = undefined; + stale = false; + } + }; } export type Load = { state: "loading" } | { state: "ready"; data: T } | { state: "error"; code: string }; @@ -23,7 +52,7 @@ export function useQuery(load: () => Promise, deps: unknown[], when?: (e: load().then((data) => n === seq.current && set({ state: "ready", data }), (err: { code?: string }) => n === seq.current && set({ state: "error", code: err?.code ?? "internal" })); }, deps); // eslint-disable-line react-hooks/exhaustive-deps React.useEffect(() => { run(); }, [run]); - React.useEffect(() => (when ? onEvent((e) => { if (when(e)) run(); }) : undefined), [run]); // eslint-disable-line react-hooks/exhaustive-deps + React.useEffect(() => (when ? onEvent((e) => { if (when(e)) run(); }, run) : undefined), [run]); // eslint-disable-line react-hooks/exhaustive-deps return { ...s, reload: run }; } @@ -76,6 +105,19 @@ export function useMessages(sessionID: string | undefined) { }; publish(); if (!sessionID) return () => { live = false; }; + const refresh = () => api.sessions.messages(sessionID).then((msgs) => { + if (!live) return; + for (const message of msgs) { + putMessage(message.info, true); + for (const part of message.parts) putPart(part, true); + } + publish(); + }, (err: { code?: string }) => { + // The session was deleted while the stream was down; transient failures keep the last view. + if (!live || err?.code !== "not_found") return; + records.clear(); settled.clear(); status = "idle"; + publish(); + }); const off = onEvent((e) => { if (!live) return; switch (e.type) { @@ -108,15 +150,8 @@ export function useMessages(sessionID: string | undefined) { default: return; } publish(); - }); - api.sessions.messages(sessionID).then((msgs) => { - if (!live) return; - for (const message of msgs) { - putMessage(message.info, true); - for (const part of message.parts) putPart(part, true); - } - publish(); - }, () => {}); + }, refresh); + void refresh(); return () => { live = false; off(); records.clear(); settled.clear(); }; }, [sessionID]); return state.sessionID === sessionID ? { msgs: state.msgs, status: state.status } : { msgs: [], status: "idle" as const }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4a3ceb41..08ed8ae1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -146,13 +146,16 @@ export function createClient(opts: ClientOptions) { health: () => get<{ ok: true; version: string }>("/api/health"), /** Subscribe to the live bus. Returns an unsubscribe function. Reconnect is the caller's choice. */ - subscribe(onEvent: (e: Event) => void, o: { onError?: (err: unknown) => void } = {}): () => void { + subscribe(onEvent: (e: Event) => void, o: { onError?: (err: unknown) => void; onOpen?: () => void } = {}): () => void { const ctrl = new AbortController() void (async () => { try { const res = await doFetch(new Request(`${base}/api/events`, { headers: { accept: "text/event-stream" }, signal: ctrl.signal })) if (!res.ok || !res.body) throw new CortexApiError("internal", `Event stream failed with status ${res.status}`, res.status) + // The server attaches its bus before answering, so events from here on are delivered. + if (!ctrl.signal.aborted) o.onOpen?.() for await (const e of parseSSE(res.body, ctrl.signal)) onEvent(e) + if (!ctrl.signal.aborted) o.onError?.(new CortexApiError("internal", "Event stream closed", 0)) } catch (err) { if (!ctrl.signal.aborted) o.onError?.(err) } diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 167ca9e1..16aca263 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -13,6 +13,30 @@ const streamOf = (chunks: string[]) => }) describe("client", () => { + it("reports unexpected event-stream EOF so its owner can reconnect", async () => { + const client = createClient({ baseUrl: "http://engine.test", fetch: async () => new Response(streamOf([])) }) + let failed: (error: unknown) => void = () => { throw new Error("error signal not initialized") } + const error = new Promise((resolve) => { failed = resolve }) + const stop = client.subscribe(() => undefined, { onError: failed }) + try { await expect(error).resolves.toMatchObject({ code: "internal" }) } + finally { stop() } + }) + + it("signals open only for an accepted event stream", async () => { + const order: string[] = [] + let settled: () => void = () => { throw new Error("error signal not initialized") } + const done = new Promise((resolve) => { settled = resolve }) + const ok = createClient({ baseUrl: "http://engine.test", fetch: async () => new Response(streamOf([])) }) + const stopOk = ok.subscribe(() => undefined, { onOpen: () => order.push("open"), onError: () => { order.push("error"); settled() } }) + await done + stopOk() + const refusedDone = new Promise((resolve) => { settled = resolve }) + const refused = createClient({ baseUrl: "http://engine.test", fetch: async () => new Response(null, { status: 503 }) }) + const stopRefused = refused.subscribe(() => undefined, { onOpen: () => order.push("open"), onError: () => { order.push("error"); settled() } }) + await refusedDone + stopRefused() + expect(order).toEqual(["open", "error", "error"]) + }) it("trims only trailing URL slashes without backtracking", async () => { const base = `https://engine.test/${"/".repeat(100_000)}prefix` const urls: string[] = [] diff --git a/packages/core/src/computer-use.ts b/packages/core/src/computer-use.ts index 7d575031..0e1a417b 100644 --- a/packages/core/src/computer-use.ts +++ b/packages/core/src/computer-use.ts @@ -7,7 +7,7 @@ import type { McpConfig } from "@cortex/schema"; export const COMPUTER_USE_SERVER = "computer-use"; /** Tools that only observe; everything else (click, type, keys, scroll, launch…) is an input action. */ -const OBSERVE = /(^|_)(screenshot|list|get|find|read|describe|inspect|tree|state|windows?|apps?|elements?|size|position)(_|$)/i; +const OBSERVE = /^(screenshot|list|get|find|read|describe|inspect|tree|state)(_|$)/i; export const isComputerUseInput = (tool: string) => tool.startsWith(`${COMPUTER_USE_SERVER}_`) && !OBSERVE.test(tool.slice(COMPUTER_USE_SERVER.length + 1)); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 47ca2b0b..f75e3d84 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,4 +1,5 @@ import { dynamicTool, isStepCount, jsonSchema, streamText, type ModelMessage, type ToolSet } from "ai" +import { z } from "zod" import { capabilities, newId, @@ -229,7 +230,8 @@ export class SessionService { } private rules(session: Session, agent: Agent, bot?: BotContext): PermissionRule[] { - return [...DEFAULT_RULES, ...(this.d.rules?.() ?? []), ...agent.permission, ...(bot?.permission ?? [])] + const parent = session.parentID ? getAgent(this.get(session.parentID).agent) : undefined + return [...DEFAULT_RULES, ...this.d.mcp.tools().map((tool): PermissionRule => ({ tool: tool.name, pattern: "*", action: "ask" })), ...(this.d.rules?.() ?? []), ...agent.permission, ...(bot?.permission ?? []), ...(parent?.permission.filter((rule) => rule.action === "deny") ?? [])] } private toolServices(): ToolServices { @@ -357,6 +359,11 @@ export class SessionService { controller.abort() }) : undefined + if (toolset?.skill) { + const available = (await this.d.skills.list(session.directory)).filter((skill) => skill.enabled); + if (available.length) toolset.skill = { ...toolset.skill, inputSchema: z.object({ name: z.enum(available.map((skill) => skill.name)) }) }; + else delete toolset.skill; + } const system = [agent.prompt, bot?.system, instructions, session.directory ? `Working directory: ${session.directory}` : undefined].filter(Boolean).join("\n\n") const result = streamText({ model: resolved.language, diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 7954d534..70eb82c4 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -1,5 +1,5 @@ import { exec } from "node:child_process" -import { glob as fsGlob, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" +import { glob as fsGlob, mkdir, open, readdir, readlink, realpath, stat, writeFile } from "node:fs/promises" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { z } from "zod" import type { BrowserBridge } from "./browser" @@ -50,12 +50,46 @@ export const truncate = (s: string, max = MAX_OUTPUT) => (s.length > max ? s.sli /** Resolve against the session directory; paths escaping it need an `external_directory` approval. */ async function target(ctx: ToolContext, p: string): Promise { const dir = ctx.directory! - const abs = isAbsolute(p) ? resolve(p) : resolve(dir, p) - const rel = relative(dir, abs) + let abs = isAbsolute(p) ? resolve(p) : resolve(dir, p) + let parent = abs + const missing: string[] = [] + for (;;) { + try { abs = join(await realpath(parent), ...missing); break } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(parent) === parent) throw error + // A dangling link is not a missing component: follow it so its outside target is checked. + const link = await readlink(parent).catch(() => undefined) + if (link !== undefined) { parent = resolve(dirname(parent), link); continue } + missing.unshift(basename(parent)); parent = dirname(parent) + } + } + const rel = relative(await realpath(dir), abs) if (rel.startsWith("..") || isAbsolute(rel)) await ctx.ask(abs, { path: abs }, "external_directory") return abs } +/** + * Read the approved canonical path through one descriptor, refusing it when a link swapped in after + * approval redirects the open. ponytail: a double swap between open and the identity check still + * races; Node has no openat2(RESOLVE_NO_SYMLINKS). Upgrade to a native resolver if that matters. + */ +async function readApproved(abs: string, max = Infinity): Promise { + const fh = await open(abs, "r") + try { + const [held, named] = await Promise.all([fh.stat({ bigint: true }), stat(abs, { bigint: true })]) + if (await realpath(abs) !== abs || held.dev !== named.dev || held.ino !== named.ino) throw new CortexError("tool_failed", "The file changed while it was being opened") + if (!held.isFile() || held.size > max) return undefined + return await fh.readFile("utf8") + } finally { await fh.close() } +} + +/** Per-candidate checks in glob/grep: a refusal must not reveal the candidate's name. */ +async function quiet(check: () => Promise): Promise { + try { return await check() } catch (error) { + throw new CortexError(error instanceof CortexError ? error.code : "tool_failed", "A matching file is not readable under the current permissions") + } +} + const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", "build"]) const skip = (p: string) => SKIP_DIRS.has(basename(p)) @@ -67,7 +101,9 @@ const read = defineTool({ async execute({ path, offset = 1, limit = 2000 }, ctx) { const file = await target(ctx, path) await ctx.ask(file, { path: file }) - const lines = (await readFile(file, "utf8")).split("\n") + const content = await readApproved(file) + if (content === undefined) throw new CortexError("tool_failed", "Not a regular file") + const lines = content.split("\n") const slice = lines.slice(offset - 1, offset - 1 + limit) return { title: relative(ctx.directory!, file) || file, output: truncate(slice.map((l, i) => `${offset + i}: ${l}`).join("\n")), metadata: { lines: lines.length } } }, @@ -94,7 +130,8 @@ const edit = defineTool({ parameters: z.object({ path: z.string(), oldString: z.string().min(1), newString: z.string(), replaceAll: z.boolean().optional() }), async execute({ path, oldString, newString, replaceAll }, ctx) { const file = await target(ctx, path) - const text = await readFile(file, "utf8") + const text = await readApproved(file) + if (text === undefined) throw new CortexError("tool_failed", "Not a regular file") const count = text.split(oldString).length - 1 if (count === 0) throw new CortexError("tool_failed", "oldString not found in file") if (count > 1 && !replaceAll) throw new CortexError("tool_failed", `oldString occurs ${count} times; add context or set replaceAll`) @@ -125,8 +162,10 @@ const glob = defineTool({ parameters: z.object({ pattern: z.string(), path: z.string().optional() }), async execute({ pattern, path }, ctx) { const cwd = await target(ctx, path ?? ".") + await ctx.ask(cwd, { path: cwd }, "read") const out: string[] = [] for await (const f of fsGlob(pattern, { cwd, exclude: skip })) { + await quiet(async () => { const abs = await target(ctx, join(cwd, f)); await ctx.ask(abs, { path: abs }, "read") }) out.push(f) if (out.length >= 1000) break } @@ -141,6 +180,7 @@ const grep = defineTool({ parameters: z.object({ pattern: z.string(), path: z.string().optional(), include: z.string().optional() }), async execute({ pattern, path, include }, ctx) { const cwd = await target(ctx, path ?? ".") + await ctx.ask(cwd, { path: cwd }, "read") let re: RegExp try { re = new RegExp(pattern) @@ -150,11 +190,12 @@ const grep = defineTool({ const hits: string[] = [] // ponytail: pure-JS scan, fine for project-sized trees; swap for ripgrep if large monorepos get slow. for await (const f of fsGlob(include ?? "**/*", { cwd, exclude: skip })) { - const abs = join(cwd, f) + const abs = await quiet(() => target(ctx, join(cwd, f))) const st = await stat(abs).catch(() => undefined) if (!st?.isFile() || st.size > 1_000_000) continue - const text = await readFile(abs, "utf8").catch(() => "") - if (text.includes("\u0000")) continue + await quiet(() => ctx.ask(abs, { path: abs }, "read")) + const text = await readApproved(abs, 1_000_000).catch(() => undefined) + if (text === undefined || text.includes("\u0000")) continue text.split("\n").forEach((line, i) => { if (hits.length < 500 && re.test(line)) hits.push(`${f}:${i + 1}: ${line.slice(0, 300)}`) }) diff --git a/packages/core/test/computer-use.test.ts b/packages/core/test/computer-use.test.ts index d13af7a1..338d7350 100644 --- a/packages/core/test/computer-use.test.ts +++ b/packages/core/test/computer-use.test.ts @@ -10,6 +10,9 @@ describe("computer use (Cua Driver over MCP)", () => { expect(isComputerUseInput("computer-use_list_windows")).toBe(false) expect(isComputerUseInput("computer-use_click")).toBe(true) expect(isComputerUseInput("computer-use_type_text")).toBe(true) + for (const name of ["launch_app", "quit_app", "click_element", "scroll_window", "close_window", "move_window", "resize_window", "focus_app"]) { + expect(isComputerUseInput(`computer-use_${name}`)).toBe(true) + } expect(isComputerUseInput("other_click")).toBe(false) }) diff --git a/packages/core/test/services.test.ts b/packages/core/test/services.test.ts index 75e60193..80a82a6c 100644 --- a/packages/core/test/services.test.ts +++ b/packages/core/test/services.test.ts @@ -181,7 +181,15 @@ describe("mcp", () => { expect(info.tools.map((t) => t.name)).toEqual(["add"]) expect(core.mcp.tools().map((t) => t.name)).toEqual(["demo_add"]) const s = core.sessions.create({ model: { providerID: "fake", modelID: "reasoner" } }) + let asked = 0 + const stopAsk = core.bus.on("permission.asked", (event) => { + asked++ + expect(event.properties.permission.tool).toBe("demo_add") + core.permissions.reply(event.properties.permission.id, "once") + }) await core.sessions.promptAndWait(s.id, { parts: [{ type: "text", text: "add" }] }) + stopAsk() + expect(asked).toBe(1) expect(srv.requests[0].tools.map((t: any) => t.function.name)).toContain("demo_add") expect(core.sessions.messages(s.id)[1]!.parts.find((x) => x.type === "tool")).toMatchObject({ tool: "demo_add", state: { status: "completed", output: "5" } }) await core.mcp.setEnabled("demo", false) diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts index 12462ff8..6a00d73b 100644 --- a/packages/core/test/session.test.ts +++ b/packages/core/test/session.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, it } from "vitest" @@ -216,6 +216,28 @@ describe("session runner (fake OpenAI-compatible SSE)", () => { expect(srv.requests).toHaveLength(3) }) + it.each(["grep", "glob"])("%s cannot bypass a denied read path", async (name) => { + const dir = mkdtempSync(join(tmpdir(), "cortex-search-perm-")) + writeFileSync(join(dir, "secret.txt"), "PRIVATE_SECRET\n") + const srv = await fakeOpenAI([ + { deltas: [toolCall("search", name, { pattern: name === "grep" ? "." : "*.txt" })], finish: "tool_calls" }, + { deltas: [{ content: "done" }], finish: "stop" }, + ]) + close = srv.close + const core = testCore(srv.url) + core.permissionRules.set([{ tool: "read", pattern: "*secret.txt", action: "deny" }]) + const session = core.sessions.create({ model: { providerID: "fake", modelID: "reasoner" }, directory: dir }) + try { + await core.sessions.promptAndWait(session.id, { parts: [{ type: "text", text: "Search the files" }] }) + const tool = core.sessions.messages(session.id).flatMap(row => row.parts).find(part => part.type === "tool") + expect(tool).toMatchObject({ state: { status: "error" } }) + expect(JSON.stringify(tool)).not.toContain("PRIVATE_SECRET") + expect(JSON.stringify(core.sessions.messages(session.id))).not.toContain("secret.txt") + } finally { + await core.close() + } + }) + it("abort stops a pending permission wait", async () => { const dir = mkdtempSync(join(tmpdir(), "cortex-abort-")) const srv = await fakeOpenAI([{ deltas: [toolCall("b1", "bash", { command: "echo hi" })], finish: "tool_calls" }]) @@ -251,6 +273,81 @@ describe("session runner (fake OpenAI-compatible SSE)", () => { expect(tool).toMatchObject({ state: { status: "completed", output: "child report" } }) }) + it("checks external-directory permission for an in-tree symlink escape", async () => { + const root = mkdtempSync(join(tmpdir(), "cortex-symlink-")); + const dir = join(root, "project"); + mkdirSync(dir); + writeFileSync(join(root, "secret.txt"), "PRIVATE"); + symlinkSync(root, join(dir, "outside"), process.platform === "win32" ? "junction" : "dir"); + const srv = await fakeOpenAI([{ deltas: [toolCall("r1", "read", { path: "outside/secret.txt" })], finish: "tool_calls" }]); + close = srv.close; + const core = testCore(srv.url); + core.permissionRules.set([{ tool: "external_directory", pattern: "*", action: "deny" }]); + const session = core.sessions.create({ directory: dir, model: { providerID: "fake", modelID: "reasoner" } }); + try { + await core.sessions.promptAndWait(session.id, { parts: [{ type: "text", text: "read" }] }); + expect(JSON.stringify(core.sessions.messages(session.id))).not.toContain("PRIVATE"); + expect(core.sessions.messages(session.id).flatMap((message) => message.parts).find((part) => part.type === "tool")).toMatchObject({ state: { status: "error" } }); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + // File symlinks need elevated rights on Windows. + it.skipIf(process.platform === "win32")("checks external-directory permission for a write through a dangling in-tree symlink", async () => { + const root = mkdtempSync(join(tmpdir(), "cortex-dangling-")); + const dir = join(root, "project"); + mkdirSync(dir); + symlinkSync(join(root, "outside.txt"), join(dir, "link.txt")); + const srv = await fakeOpenAI([{ deltas: [toolCall("w1", "write", { path: "link.txt", content: "ESCAPED" })], finish: "tool_calls" }]); + close = srv.close; + const core = testCore(srv.url); + core.permissionRules.set([{ tool: "external_directory", pattern: "*", action: "deny" }, { tool: "write", pattern: "*", action: "allow" }]); + const session = core.sessions.create({ directory: dir, model: { providerID: "fake", modelID: "reasoner" } }); + try { + await core.sessions.promptAndWait(session.id, { parts: [{ type: "text", text: "write" }] }); + expect(core.sessions.messages(session.id).flatMap((message) => message.parts).find((part) => part.type === "tool")).toMatchObject({ state: { status: "error" } }); + expect(existsSync(join(root, "outside.txt"))).toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + it("a plan child cannot bypass the parent's read-only permission", async () => { + const dir = mkdtempSync(join(tmpdir(), "cortex-plan-child-")) + const srv = await fakeOpenAI([ + { deltas: [toolCall("t1", "task", { description: "sub", prompt: "write file", agent: "general" })], finish: "tool_calls" }, + { deltas: [toolCall("w1", "write", { path: "blocked.txt", content: "blocked" })], finish: "tool_calls" }, + { deltas: [{ content: "child done" }], finish: "stop" }, + { deltas: [{ content: "parent done" }], finish: "stop" }, + ]) + close = srv.close + const core = testCore(srv.url) + const s = core.sessions.create({ agent: "plan", directory: dir, model: { providerID: "fake", modelID: "reasoner" } }) + let asked = 0 + const stop = core.bus.on("permission.asked", (event) => { asked++; core.permissions.reply(event.properties.permission.id, "once") }) + try { + await core.sessions.promptAndWait(s.id, { parts: [{ type: "text", text: "delegate" }] }) + expect(asked).toBe(0) + const child = core.sessions.list({ parentID: s.id })[0] + expect(child).toBeDefined() + const tool = core.sessions.messages(child!.id).flatMap((m) => m.parts).find((p) => p.type === "tool") + expect(tool).toMatchObject({ state: { status: "error" } }) + } finally { stop(); rmSync(dir, { recursive: true, force: true }) } + }) + + it("offers enabled skill names through the model tool schema", async () => { + const dir = mkdtempSync(join(tmpdir(), "cortex-skill-discovery-")); + const skillDir = join(dir, ".cortex", "skills", "fixture-skill"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fixture-skill\ndescription: Fixture instructions\n---\nOnly loaded after selection."); + const srv = await fakeOpenAI([{ deltas: [{ content: "done" }], finish: "stop" }]); + close = srv.close; + const core = testCore(srv.url); + const session = core.sessions.create({ directory: dir, model: { providerID: "fake", modelID: "reasoner" } }); + try { + await core.sessions.promptAndWait(session.id, { parts: [{ type: "text", text: "available skills" }] }); + const schema = srv.requests[0].tools.find((tool: { function: { name: string } }) => tool.function.name === "skill").function.parameters; + expect(schema.properties.name.enum).toEqual(["fixture-skill"]); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + it("bot sessions use persona and memory as system prompt", async () => { const srv = await fakeOpenAI([{ deltas: [{ content: "hi" }], finish: "stop" }]) close = srv.close diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1a531f45..40430ea1 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -8,7 +8,7 @@ "@cortex/server": "workspace:*", "@cortex/schema": "workspace:*", "@cortex/i18n": "workspace:*", - "@cortex/sdk": "file:../../vendor/trunk/cortex-sdk-0.4.6.tgz", - "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.5.tgz" + "@cortex/sdk": "file:../../vendor/trunk/cortex-sdk-0.4.9.tgz", + "@cortex/api-types": "file:../../vendor/trunk/cortex-api-types-0.3.8.tgz" } } diff --git a/packages/desktop/src/browser-host.ts b/packages/desktop/src/browser-host.ts index 8e280816..f8432294 100644 --- a/packages/desktop/src/browser-host.ts +++ b/packages/desktop/src/browser-host.ts @@ -10,11 +10,14 @@ const POLL_MS = 20_000 const isExtensionOrigin = (o: string | undefined) => !!o && /^chrome-extension:\/\/[a-p]{32}$/.test(o) async function body(req: IncomingMessage): Promise> { - let raw = "" + const chunks: Buffer[] = [] + let bytes = 0 for await (const chunk of req) { - raw += chunk - if (raw.length > MAX_BODY) throw new CortexError("invalid_request", "Body too large") + bytes += chunk.length + if (bytes > MAX_BODY) throw new CortexError("invalid_request", "Body too large") + chunks.push(chunk) } + const raw = Buffer.concat(chunks).toString("utf8") try { const v = raw ? JSON.parse(raw) : {} if (v && typeof v === "object" && !Array.isArray(v)) return v as Record diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index a95d5c33..71a74be1 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -70,7 +70,10 @@ async function boot() { bootStage("core-created"); await core.start({ computerUse: findCuaDriver() }); bootStage("core-started"); - if (core.connection.get().mode !== "local") await remote.restore(core.connection.remoteOrigin()); + if (core.connection.get().mode !== "local") { + try { await remote.restore(core.connection.remoteOrigin()); } + catch { console.warn("Remote session could not be restored; continuing signed out"); } + } // Test hook: route one provider to a local endpoint (E2E streaming without network). Ignored in packaged builds. if (testBase) { bootStage("provider-update"); @@ -103,21 +106,25 @@ async function boot() { ipcMain.handle("cortex:call:available", () => { try { return calls.available(callOrigin()); } catch { return "hidden"; } }); // One call per host: only the renderer that started it may drive it. let callOwner: number | undefined; + let pendingCall: { owner: number } | undefined; const owns = (e: Electron.IpcMainEvent) => e.sender.id === callOwner; ipcMain.handle("cortex:call:start", async (e, botId: string) => { - callOwner = e.sender.id; + const pending = pendingCall = { owner: e.sender.id }; const send = (channel: string, ...args: unknown[]) => { if (!e.sender.isDestroyed() && !e.sender.isCrashed()) e.sender.send(channel, ...args); }; - await calls.start(callOrigin(), String(botId), { - snapshot: (s) => send("cortex:call:snapshot", s), - play: (pcm, sequence, generation) => send("cortex:call:play", pcm, sequence, generation), - flush: () => send("cortex:call:flush"), - }, e.sender); + try { + await calls.start(callOrigin(), String(botId), { + snapshot: (s) => send("cortex:call:snapshot", s), + play: (pcm, sequence, generation) => send("cortex:call:play", pcm, sequence, generation), + flush: () => send("cortex:call:flush"), + }, e.sender); + callOwner = e.sender.id; + } finally { if (pendingCall === pending) pendingCall = undefined; } }); ipcMain.on("cortex:call:capture", (e, pcm: Uint8Array) => { if (owns(e) && pcm instanceof Uint8Array && pcm.byteLength === 640) calls.capture(pcm); }); ipcMain.on("cortex:call:played", (e, sequence: number, generation: number) => { if (owns(e)) calls.played(Number(sequence), Number(generation)); }); ipcMain.on("cortex:call:mute", (e, muted: boolean) => { if (owns(e)) calls.mute(muted === true); }); ipcMain.on("cortex:call:interrupt", (e) => { if (owns(e)) calls.interrupt(); }); - ipcMain.on("cortex:call:end", (e) => { if (owns(e)) calls.end(); }); + ipcMain.on("cortex:call:end", (e) => { if (owns(e) || e.sender.id === pendingCall?.owner) calls.end(); }); // Test hook for E2E receipts (frame counts). Ignored in packaged builds. if (!app.isPackaged) ipcMain.handle("cortex:call:stats", () => calls.stats() ?? null); // Server-sent events cannot cross invoke(); they are pumped over a dedicated channel. diff --git a/packages/desktop/src/remote-call.ts b/packages/desktop/src/remote-call.ts index 3addb33c..a4226f99 100644 --- a/packages/desktop/src/remote-call.ts +++ b/packages/desktop/src/remote-call.ts @@ -23,6 +23,7 @@ const BOT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; export function createCallHost(auth: CallAuth, fetchImpl: typeof fetch = fetch) { let current: { session: CallSession; release(): void } | undefined; let last: CallSession | undefined; + let generation = 0; const request = async (origin: string, path: string, init: RequestInit) => { const { token, signal } = await auth(origin); const response = await fetchImpl(`${origin}${path}`, { @@ -32,7 +33,7 @@ export function createCallHost(auth: CallAuth, fetchImpl: typeof fetch = fetch) return { status: response.status, body: response.status === 204 ? null : await response.json().catch(() => null) }; }; // End first so the renderer receives the final snapshot, then detach. - const end = () => { const c = current; current = undefined; const done = c?.session.end(); c?.release(); void done; }; + const end = () => { generation++; const c = current; current = undefined; const done = c?.session.end(); c?.release(); void done; }; return { /** The `bot_call` block only; Chat Live's `live` is never consulted. */ async available(origin: string): Promise<"offer" | "unavailable" | "hidden"> { @@ -48,7 +49,16 @@ export function createCallHost(auth: CallAuth, fetchImpl: typeof fetch = fetch) async start(origin: string, botId: string, on: CallEvents, owner?: CallOwnerEvents): Promise { if (!BOT.test(botId)) throw new CortexError("invalid_request", "Invalid Bot"); end(); - const { signal } = await auth(origin); + const mine = generation; + // Watch the owner during authentication too: a window lost here must not get a server call. + let lost = false; + const lose = () => { lost = true; }; + owner?.once("destroyed", lose); + owner?.once("render-process-gone", lose); + const { signal } = await auth(origin).finally(() => { + owner?.removeListener("destroyed", lose); owner?.removeListener("render-process-gone", lose); + }); + if (mine !== generation || signal.aborted || lost) throw new CortexError("invalid_request", "Call start was cancelled"); const transport: CallTransport = { post: (path, body) => request(origin, path, { method: "POST", body: JSON.stringify(body) }).catch(() => ({ status: 0, body: null })), del: async (path) => { await request(origin, path, { method: "DELETE" }); }, @@ -84,6 +94,7 @@ export function createCallHost(auth: CallAuth, fetchImpl: typeof fetch = fetch) if ((s.phase === "ended" || s.phase === "error") && current?.session === session) { const c = current; current = undefined; c.release(); } }); await session.start(); + if (mine !== generation) throw new CortexError("invalid_request", "Call start was cancelled"); }, capture(pcm: Uint8Array) { current?.session.capture(pcm); }, played(sequence: number, generation: number) { current?.session.played(sequence, generation); }, diff --git a/packages/desktop/src/remote-code.ts b/packages/desktop/src/remote-code.ts index 8b80ce8c..5845afa1 100644 --- a/packages/desktop/src/remote-code.ts +++ b/packages/desktop/src/remote-code.ts @@ -111,8 +111,8 @@ export function createRemoteCodeBinding(client: CortexClient, chat: MainRemoteCh void (async () => { try { for await (const event of client.subscribePath(`/v1/code/sessions/${id(rawID)}/events`, { - signal: AbortSignal.any([controller.signal, chat.signal]), maxReconnects: 0, - onResponse: () => { guard(); resolveReady(); }, + signal: AbortSignal.any([controller.signal, chat.signal]), + onResponse: () => { guard(); changed(); resolveReady(); }, })) { guard(); if (event.resource !== "code_session") throw new Error("Invalid Code event owner"); diff --git a/packages/desktop/src/remote-session.ts b/packages/desktop/src/remote-session.ts index 04dda8ee..5ed7da2f 100644 --- a/packages/desktop/src/remote-session.ts +++ b/packages/desktop/src/remote-session.ts @@ -133,7 +133,12 @@ export class RemoteSession { identity.lifetime.signal.addEventListener("abort", cancel, { once: true }); probe.token = pair.access_token; try { - const response = await this.#native(probe, origin, "/v1/me"); + const keepRotated = async () => { + if (identity.accountID) await this.#store({ ...pair, origin, accountID: Account.shape.id.parse(identity.accountID) }, identity); + throw new CortexError("provider_error", "Remote sign-in is temporarily unavailable"); + }; + const response = await this.#native(probe, origin, "/v1/me").catch(keepRotated); + if (response.status === 429 || response.status >= 500) await keepRotated(); if (response.status !== 200) throw new Error("Native identity refused"); const account = Account.parse(await response.json()); identity.lifetime.signal.throwIfAborted(); @@ -157,8 +162,9 @@ export class RemoteSession { await this.#pair(identity, origin, await response.json()); clearTimeout(identity.expiry); this.#watchExpiry(identity); - } catch { + } catch (error) { const owned = this.#active === identity || this.#candidate === identity; + transient ||= error instanceof CortexError && error.code === "provider_error"; this.#invalidate(identity); if (owned && !transient) await this.#store(); throw transient ? new CortexError("provider_error", "Remote sign-in is temporarily unavailable") : new CortexError("provider_auth_failed", "Remote sign-in is required"); @@ -193,8 +199,10 @@ export class RemoteSession { const selected = this.#origin!; const candidate = this.#create(selected, "", crypto.randomUUID()); this.#candidate = candidate; + let read = false; try { const raw = await this.#credentials?.get("remote-session"); + read = true; if (!raw) { this.#cancelCandidate(); return; } const saved = Saved.parse(JSON.parse(raw)); if (saved.origin !== selected) { this.#cancelCandidate(); return; } @@ -206,7 +214,7 @@ export class RemoteSession { this.#revision = crypto.randomUUID(); } catch (error) { // A transient refresh failure keeps the saved pair so the next restore can retry. - if (this.#candidate === candidate) { this.#cancelCandidate(); if (!(error instanceof CortexError && error.code === "provider_error")) await this.#store(); } + if (this.#candidate === candidate) { this.#cancelCandidate(); if (read && !(error instanceof CortexError && error.code === "provider_error")) await this.#store(); } } } @@ -519,7 +527,7 @@ export class RemoteSession { if (active.refreshing) await active.refreshing; if (active.expiresAt! <= Date.now()) await this.#refresh(active, origin); const response = await this.#native(active, origin, "/v1/auth/logout", {}); - if (response.status !== 204) throw failure(active); + if (response.status !== 204 && response.status !== 200) throw failure(active); return this.state(origin); } catch { throw failure(active); } finally { diff --git a/packages/desktop/test/browser-host.test.ts b/packages/desktop/test/browser-host.test.ts index 95a1d96a..e640c5ac 100644 --- a/packages/desktop/test/browser-host.test.ts +++ b/packages/desktop/test/browser-host.test.ts @@ -36,6 +36,24 @@ describe("browser host", () => { expect((await req("/share", { body: { id: 1, title: "", url: "https://a.test/" } })).status).toBe(401) expect(bridge.status().connected).toBe(false) }) + it("preserves multibyte page text through the real loopback body", async () => { + const { bridge, req } = await boot() + const { code } = bridge.startPairing() + const { token } = await (await req("/pair", { body: { code } })).json() + await req("/share", { token, body: { id: 5, title: "Docs", url: "https://a.test/" } }) + const read = bridge.read(5) + const { commands } = await (await req("/poll", { method: "GET", token })).json() + const text = "€".repeat(100_000) + expect((await req("/result", { token, body: { id: commands[0].id, ok: true, title: "Docs", url: "https://a.test/", text } })).status).toBe(204) + await expect(read).resolves.toMatchObject({ text: text.slice(0, 50_000) }) + }) + it("rejects oversized multibyte bodies by bytes", async () => { + const { bridge, req } = await boot() + const { code } = bridge.startPairing() + const { token } = await (await req("/pair", { body: { code } })).json() + expect((await req("/share", { token, body: { id: 5, title: "€".repeat(700_000), url: "https://a.test/" } })).status).toBe(400) + expect(bridge.status().tabs).toHaveLength(0) + }) it("rejects a non-loopback Host header", async () => { const { bridge } = await boot() const port = bridge.status().port! diff --git a/packages/desktop/test/persistent-auth.test.ts b/packages/desktop/test/persistent-auth.test.ts index 0883cdb5..6508ada8 100644 --- a/packages/desktop/test/persistent-auth.test.ts +++ b/packages/desktop/test/persistent-auth.test.ts @@ -25,7 +25,7 @@ function fixture() { if (revoked || body.refresh_token !== `fixture-refresh-${version}`) return json({}, 401); return json(pair(++version)); } - if (path === "/v1/auth/logout") { revoked = true; return new Response(null, { status: 204 }); } + if (path === "/v1/auth/logout") { revoked = true; return json({ logout_url: "https://native.example.test/logout" }); } if (path === "/v1/auth/magic-auth") return new Response(null, { status: 204 }); if (path === "/v1/auth/magic-auth/verify") { ++version; return new Response(JSON.stringify({ status: "session", access_token: token(version) }), { status: 200, headers: [["Content-Type", "application/json"], ["Set-Cookie", `cortex_rt=fixture-refresh-${version}; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`]] }); } if (path === "/v1/instance") return json({ mode: "cloud", auth: { mode: "cortex", required: true } }); @@ -113,6 +113,19 @@ describe("persistent main native device authentication", () => { } finally { next.clear(); } } finally { first.clear(); } }); + it("preserves stored ciphertext while the keyring is unavailable", async () => { + const credentials = memoryCredentials(), f = fixture(); + const first = new RemoteSession({ credentials, fetch: f.transport }); + await login(first); first.clear(); + const saved = credentials.get("remote-session"); + const next = new RemoteSession({ credentials: { ...credentials, get: () => { throw new Error("Credential protection unavailable"); } }, fetch: f.transport }); + try { + await next.restore(origin); + expect(next.state(origin).signedIn).toBe(false); + expect(credentials.get("remote-session")).toBe(saved); + } finally { next.clear(); } + }); + it("keeps the saved pair when refresh fails transiently", async () => { const credentials = memoryCredentials(), f = fixture(); const first = new RemoteSession({ credentials, fetch: f.transport }); @@ -126,6 +139,23 @@ describe("persistent main native device authentication", () => { expect(credentials.get("remote-session")).toBe(saved); } finally { next.clear(); } }); + it("retains a rotated pair when the identity read fails transiently without signing in", async () => { + const credentials = memoryCredentials(), f = fixture(); + const first = new RemoteSession({ credentials, fetch: f.transport }); + await login(first); first.clear(); + const outage: typeof fetch = async (input, init) => new URL((input instanceof Request ? input : new Request(input, init)).url).pathname === "/v1/me" ? json({}, 503) : f.transport(input, init); + const next = new RemoteSession({ credentials, fetch: outage }); + try { + await next.restore(origin); + expect(next.state(origin).signedIn).toBe(false); + const saved = await credentials.get("remote-session"); + if (!saved) throw new Error("rotated pair was lost"); + expect(JSON.parse(saved).refresh_token).toBe("fixture-refresh-2"); + const recovered = new RemoteSession({ credentials, fetch: f.transport }); + try { await recovered.restore(origin); expect(recovered.state(origin).signedIn).toBe(true); } + finally { recovered.clear(); } + } finally { next.clear(); } + }); it("refuses owner changes after rotation and clears revoked storage without new device login", async () => { for (const refusal of ["owner", "revoked"]) { const credentials = memoryCredentials(), f = fixture(); diff --git a/packages/desktop/test/remote-call.test.ts b/packages/desktop/test/remote-call.test.ts index c7ae93d0..89516f11 100644 --- a/packages/desktop/test/remote-call.test.ts +++ b/packages/desktop/test/remote-call.test.ts @@ -35,6 +35,58 @@ beforeAll(async () => { afterAll(() => { for (const s of held) s.destroy(); return new Promise((r) => server.close(() => r())); }); describe("createCallHost", () => { + it("end cancels a start held in authentication before a server session exists", async () => { + let release: () => void = () => { throw new Error("gate missing"); }; + const gate = new Promise((resolve) => { release = resolve; }); + const life = new AbortController(); + const host = createCallHost(async () => { await gate; return { token: "t", signal: life.signal }; }); + const before = seen.length; + const start = host.start(origin, "00000000-0000-4000-8000-000000000001", { snapshot: () => undefined, play: () => undefined, flush: () => undefined }); + const cancelled = expect(start).rejects.toThrow("cancelled"); + host.end(); + release(); + await cancelled; + expect(seen.slice(before)).toHaveLength(0); + }); + + it("refuses a start whose owner window died during authentication", async () => { + let release: () => void = () => { throw new Error("gate missing"); }; + const gate = new Promise((resolve) => { release = resolve; }); + const life = new AbortController(); + const host = createCallHost(async () => { await gate; return { token: "t", signal: life.signal }; }); + const owner = new EventEmitter(); + const before = seen.length; + const start = host.start(origin, "00000000-0000-4000-8000-000000000001", { snapshot: () => undefined, play: () => undefined, flush: () => undefined }, owner); + const cancelled = expect(start).rejects.toThrow("cancelled"); + owner.emit("destroyed"); + release(); + await cancelled; + expect(seen.slice(before)).toHaveLength(0); + expect(owner.listenerCount("destroyed") + owner.listenerCount("render-process-gone")).toBe(0); + }); + + it("supersedes an awaiting start before creating a second server session", async () => { + sessions = "grant"; + const life = new AbortController(); + let releaseAuth: () => void = () => { throw new Error("auth gate not initialized"); }; + const firstAuth = new Promise((resolve) => { releaseAuth = resolve; }); + let authCalls = 0; + const host = createCallHost(async () => { + if (++authCalls === 1) await firstAuth; + return { token: "t", signal: life.signal }; + }); + const on = { snapshot: () => undefined, play: () => undefined, flush: () => undefined }; + const before = seen.length; + const first = host.start(origin, "00000000-0000-4000-8000-000000000001", on); + const superseded = expect(first).rejects.toThrow(); + await host.start(origin, "00000000-0000-4000-8000-000000000001", on); + releaseAuth(); + await superseded; + expect(seen.slice(before).filter((r) => r.method === "POST" && r.url === "/v1/live/sessions")).toHaveLength(1); + host.end(); + sessions = "refuse"; + }); + it("offers only on bot_call, hides for signed-out windows, and keeps the bearer in main", async () => { const life = new AbortController(); const host = createCallHost(async () => ({ token: "main-only-bearer", signal: life.signal })); diff --git a/packages/desktop/test/remote-chat.test.ts b/packages/desktop/test/remote-chat.test.ts index fc95f7cb..c6bfbfb8 100644 --- a/packages/desktop/test/remote-chat.test.ts +++ b/packages/desktop/test/remote-chat.test.ts @@ -146,6 +146,26 @@ it("narrows a refused live diff to its public workspace tag and never exposes pr await expect(remoteChatFetch(new Request(origin + diff, { method: "POST" }), owner)).rejects.toMatchObject({ code: "invalid_request" }); }); +it("reconnects a Code watch after EOF and resyncs before new events", async () => { + let calls = 0; + const client = createCortexClient({ baseUrl: origin, fetch: async () => { + const body = new ReadableStream({ start(controller) { if (++calls === 1) controller.close(); } }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } }); + const life = new AbortController(); + const chat = createRemoteChatBinding(client, origin, "fixture-epoch", life.signal, () => {}, "usr_fixture"); + const code = createRemoteCodeBinding(client, chat, () => {}); + const changed = vi.fn(); + vi.useFakeTimers(); + const watch = code.watch(cnv, changed); + try { + await watch.ready; + await vi.advanceTimersByTimeAsync(250); + expect(calls).toBe(2); + expect(changed).toHaveBeenCalledTimes(2); + } finally { watch.close(); life.abort(); vi.useRealTimers(); } +}); + it("refuses IPC-forced cloud create and cloud prompt in main with no producer write while no farm is admitted", async () => { routes.set("/v1/code/capabilities", json({ runtimes: { available: false, unavailable_reason: "code_compute_not_configured" } })); routes.set("/v1/code/sessions", json({ id: cnv, runtime: "cloud", model_slug: "fixture", title: "", state: "cloud_only" }, 201)); diff --git a/packages/i18n/locales/fr/workBot.json b/packages/i18n/locales/fr/workBot.json index 60c1c44d..78d68447 100644 --- a/packages/i18n/locales/fr/workBot.json +++ b/packages/i18n/locales/fr/workBot.json @@ -33,8 +33,6 @@ "look.amber": "Ambre", "look.plum": "Prune", "look.slate": "Ardoise", - "parentMessage": "Message au Bot original", - "parentSend": "Envoyer au Bot", "copy.owner": "Partager une copie indépendante", "copy.inbox": "Invitations à copier", "copy.independent": "Chaque invitation acceptée crée une copie indépendante. Les conversations, la mémoire, les secrets, les connexions, les canaux et l’ordinateur du propriétaire ne sont pas inclus.", diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index d5c94e69..26e7fc82 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -579,7 +579,12 @@ export const BotCreateInput = z.object({ permission: z.array(PermissionRule).default([]), }) export type BotCreateInput = z.input -export const BotUpdateInput = BotCreateInput.partial() +export const BotUpdateInput = BotCreateInput.partial().extend({ + persona: z.string().optional(), + mascot: Mascot.optional(), + tools: ToolFilter.optional(), + permission: z.array(PermissionRule).optional(), +}) export type BotUpdateInput = z.input export const TaskCreateInput = z.object({ title: z.string().min(1), @@ -592,7 +597,7 @@ export const TaskCreateInput = z.object({ enabled: z.boolean().default(true), }) export type TaskCreateInput = z.input -export const TaskUpdateInput = TaskCreateInput.partial() +export const TaskUpdateInput = TaskCreateInput.partial().extend({ enabled: z.boolean().optional() }) export type TaskUpdateInput = z.input export const SpaceCreateInput = z.object({ kind: SpaceItem.shape.kind, title: z.string().min(1), content: z.string().optional(), url: z.string().optional() }) export type SpaceCreateInput = z.infer diff --git a/packages/schema/test/schema.test.ts b/packages/schema/test/schema.test.ts index 94103587..7dcf8628 100644 --- a/packages/schema/test/schema.test.ts +++ b/packages/schema/test/schema.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { Event, EVENT_TYPES, McpConfig, newId, Part, Schedule } from "../src/index" +import { BotUpdateInput, Event, EVENT_TYPES, McpConfig, newId, Part, Schedule, TaskUpdateInput } from "../src/index" describe("schema", () => { afterEach(() => vi.restoreAllMocks()) @@ -23,4 +23,8 @@ describe("schema", () => { expect(() => McpConfig.parse({ name: "bad name", type: "remote", url: "https://x.test" })).toThrow() expect(() => Schedule.parse({ type: "daily", time: "9am" })).toThrow() }) + it("leaves omitted Bot and task PATCH fields omitted", () => { + expect(BotUpdateInput.parse({ name: "Renamed" })).toEqual({ name: "Renamed" }) + expect(TaskUpdateInput.parse({ title: "Renamed" })).toEqual({ title: "Renamed" }) + }) }) diff --git a/tests/e2e/browser-connector.spec.ts b/tests/e2e/browser-connector.spec.ts index f1e7aa00..16db3319 100644 --- a/tests/e2e/browser-connector.spec.ts +++ b/tests/e2e/browser-connector.spec.ts @@ -1,13 +1,10 @@ import { test, expect } from "@playwright/test"; -import fs from "node:fs"; -import path from "node:path"; -import { launch, root } from "./fixtures"; +import { launch } from "./fixtures"; const ORIGIN = `chrome-extension://${"a".repeat(32)}`; -const shots = path.join(root, "evidence/browser-connector"); for (const theme of ["dark", "light"]) { - test(`local browser page pairs and revokes (${theme})`, async () => { + test(`local browser page pairs and revokes (${theme})`, async ({}, testInfo) => { const { app, page } = await launch({ hash: `#/browser-authorization?theme=${theme}` }); try { const status = page.getByTestId("browser-status"); @@ -17,8 +14,7 @@ for (const theme of ["dark", "light"]) { const code = (await page.getByTestId("browser-code").textContent())!.replace(/\W/g, ""); expect(code).toMatch(/^[A-Z2-9]{8}$/); await expect(status).toHaveAttribute("data-mode", "waiting"); - fs.mkdirSync(shots, { recursive: true }); - await page.screenshot({ path: path.join(shots, `pairing-${theme}.png`) }); + await page.screenshot({ path: testInfo.outputPath(`pairing-${theme}.png`) }); // Drive the loopback endpoint exactly as the extension does. const port = await page.evaluate(async () => (await (window as unknown as { cortex: { browser: { status(): Promise<{ port: number }> } } }).cortex.browser.status()).port); @@ -27,12 +23,12 @@ for (const theme of ["dark", "light"]) { await call("/share", token, { id: 7, title: "Quarterly report", url: "https://example.com/reports/q3" }); await expect(status).toHaveAttribute("data-mode", "connected"); await expect(page.getByTestId("browser-tabs")).toContainText("Quarterly report"); - await page.screenshot({ path: path.join(shots, `connected-${theme}.png`) }); + await page.screenshot({ path: testInfo.outputPath(`connected-${theme}.png`) }); await page.getByTestId("browser-revoke").click(); await expect(page.getByTestId("browser-tabs")).toHaveCount(0); await expect(page.getByText("No tab shared")).toBeVisible(); - await page.screenshot({ path: path.join(shots, `empty-${theme}.png`) }); + await page.screenshot({ path: testInfo.outputPath(`empty-${theme}.png`) }); } finally { await app.close(); } }); } diff --git a/tests/e2e/chat-missing.spec.ts b/tests/e2e/chat-missing.spec.ts index 378dfcdb..0592a957 100644 --- a/tests/e2e/chat-missing.spec.ts +++ b/tests/e2e/chat-missing.spec.ts @@ -15,6 +15,31 @@ const request = (page: Page, route: string, method: string, body?: unknown) => p return { status: r.status, body: await r.json() }; }, { route, method, body }); +test("a failed deferred delete restores the Chat with an error", async () => { + const { app, page } = await launch({ hash: "#/home?theme=light", env: { CORTEX_CATALOG_URL: "data:application/json,{}" } }); + try { + const result = await request(page, "/api/sessions", "POST", { kind: "chat", title: "Delete failure", model: { providerID: "fake", modelID: "reasoner" } }); + expect(result.status).toBe(201); + const session = result.body as Session; + await page.evaluate(({ id }) => { location.hash = `#/chat?id=${id}&theme=light`; }, session); + await expect(page.locator(".content-top .title")).toHaveText("Delete failure"); + await app.evaluate(async ({ ipcMain }) => { + const handlers = (ipcMain as unknown as { _invokeHandlers: Map unknown> })._invokeHandlers; + const original = handlers.get("cortex:fetch")!; + ipcMain.removeHandler("cortex:fetch"); + ipcMain.handle("cortex:fetch", (event, req) => req.method === "DELETE" + ? { status: 500, headers: [["content-type", "application/json"]], body: JSON.stringify({ error: { code: "internal", message: "fixture refusal" } }) } + : original(event, req)); + }); + await page.getByRole("button", { name: chat.options, exact: true }).click(); + await page.getByRole("menuitem", { name: chat["menu.delete"], exact: true }).click(); + await expect(page).toHaveURL(/#\/home/); + await expect(page).toHaveURL(new RegExp(session.id), { timeout: 10_000 }); + await expect(page.getByText(chat["err.generic.title"], { exact: true })).toBeVisible(); + expect((await request(page, `/api/sessions/${session.id}`, "GET")).status).toBe(200); + } finally { await app.close(); } +}); + for (const theme of ["light", "dark"]) test(`Deleted Chat shows truthful missing-page recovery — ${theme}`, async () => { const fake = await startFakeProvider(); const { app, page, dataDir } = await launch({ hash: `#/home?theme=${theme}`, locale: "en", env: { diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index a4c413e7..443bc02c 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -1,6 +1,15 @@ import { test, expect } from "@playwright/test"; import { launch } from "./fixtures"; +test("a chat link without an ID replaces its entry instead of trapping Back", async () => { + const { app, page } = await launch({ hash: "#/chat", env: { CORTEX_CATALOG_URL: "data:application/json,{}" } }); + try { + await expect(page).toHaveURL(/#\/home$/); + const routes = await page.evaluate(() => (window as unknown as { navigation: { entries(): { url: string }[] } }).navigation.entries().map((entry) => new URL(entry.url).hash)); + expect(routes).not.toContain("#/chat"); + } finally { await app.close(); } +}); + test("skipped native route and theme transitions preserve updates and report callback errors", async () => { const { app, page } = await launch({ hash: "#/home?preview&theme=light", env: { CORTEX_CATALOG_URL: "data:application/json,{}" } }); const errors: string[] = []; diff --git a/tests/e2e/remote-chat.spec.ts b/tests/e2e/remote-chat.spec.ts index 9b4fbde6..c5f012f6 100644 --- a/tests/e2e/remote-chat.spec.ts +++ b/tests/e2e/remote-chat.spec.ts @@ -458,6 +458,10 @@ test("M1 historical-image refusal carries the exact next draft into a fresh remo // Register the signal before triggering the owned new-chat handoff. await Promise.all([ gate.evaluate((gate) => gate.wait()), + page.waitForURL((url) => { + const id = new URLSearchParams(url.hash.split("?")[1]).get("id"); + return !!id && id !== original.id; + }), page.getByRole("button", { name: chatCopy["remote.newChatWithDraft"], exact: true }).click(), ]); const destination = new URLSearchParams(new URL(page.url()).hash.split("?")[1]); diff --git a/tests/e2e/remote-restore.spec.ts b/tests/e2e/remote-restore.spec.ts index 0c4a1f4c..7a29b424 100644 --- a/tests/e2e/remote-restore.spec.ts +++ b/tests/e2e/remote-restore.spec.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { RemoteAuthState } from "@cortex/schema"; -import { root } from "./fixtures"; +import { launch, root } from "./fixtures"; const account = "usr_00000000000000000000000001"; @@ -49,6 +49,31 @@ async function call(page: Page, route: string, method = "GET", body?: unknown } const auth = async (page: Page) => (await call(page, "/api/connection/auth")).body; +test("opens the window signed out when the persisted credential store is corrupt", async () => { + const fixture = await backend(); + const first = await launch(); + const firstProcess = first.app.process(); + const store = path.join(first.dataDir, "remote-credentials.json"); + try { + expect((await call(first.page, "/api/connection", "PUT", { mode: "selfhost", url: fixture.origin, signedIn: false })).status).toBe(200); + await first.app.close(); + fs.writeFileSync(store, "{"); + const next = await launch({ env: { CORTEX_DATA_DIR: first.dataDir } }); + try { + expect((await auth(next.page)).signedIn).toBe(false); + expect(fs.readFileSync(store, "utf8")).toBe("{"); + expect(fixture.count("/v1/auth/refresh")).toBe(0); + } finally { + await next.app.close(); + fs.rmSync(next.dataDir, { recursive: true, force: true }); + } + } finally { + if (firstProcess.exitCode === null) await first.app.close(); + fs.rmSync(first.dataDir, { recursive: true, force: true }); + await fixture.close(); + } +}); + test("restores saved remote credentials after restart and erases revoked ones", async () => { const fixture = await backend(); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-e2e-restore-")); diff --git a/tests/unit/live-events.test.ts b/tests/unit/live-events.test.ts new file mode 100644 index 00000000..6d7ef51e --- /dev/null +++ b/tests/unit/live-events.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, it, vi } from "vitest"; + +type Options = { onError: (error: unknown) => void; onOpen?: () => void }; +const subscriptions = vi.hoisted(() => [] as { stop: ReturnType; error: (error: unknown) => void; open: () => void }[]); +vi.mock("../../packages/app/src/api", () => ({ api: { subscribe: (_event: unknown, options: Options) => { + const stop = vi.fn(); + subscriptions.push({ stop, error: options.onError, open: () => options.onOpen?.() }); + return stop; +} } })); +import { onEvent } from "../../packages/app/src/state/live"; + +afterEach(() => { vi.useRealTimers(); subscriptions.length = 0; }); + +it("reconnects an errored stream and closes it when its last listener leaves", () => { + vi.useFakeTimers(); + const refresh = vi.fn(); + const stop = onEvent(() => undefined, refresh); + expect(subscriptions).toHaveLength(1); + subscriptions[0].open(); + subscriptions[0].error(new Error("transport closed")); + vi.runOnlyPendingTimers(); + expect(subscriptions).toHaveLength(2); + stop(); + expect(subscriptions[1].stop).toHaveBeenCalledOnce(); + vi.runOnlyPendingTimers(); + expect(subscriptions).toHaveLength(2); + expect(refresh).not.toHaveBeenCalled(); +}); + +it("resyncs only after a replacement stream opens, surviving a failed reopen", () => { + vi.useFakeTimers(); + const refresh = vi.fn(); + const stop = onEvent(() => undefined, refresh); + subscriptions[0].open(); + expect(refresh).not.toHaveBeenCalled(); + subscriptions[0].error(new Error("transport closed")); + expect(refresh).not.toHaveBeenCalled(); + vi.runOnlyPendingTimers(); + subscriptions[1].error(new Error("still down")); + expect(refresh).not.toHaveBeenCalled(); + vi.runOnlyPendingTimers(); + subscriptions[2].open(); + expect(refresh).toHaveBeenCalledOnce(); + stop(); +}); diff --git a/tests/unit/locales.test.ts b/tests/unit/locales.test.ts index e4413faa..9ec3b933 100644 --- a/tests/unit/locales.test.ts +++ b/tests/unit/locales.test.ts @@ -43,6 +43,7 @@ describe("locale catalogs", () => { const p = path.join(dir, l, f); expect(fs.existsSync(p), `${l}/${f}`).toBe(true); const tr = JSON.parse(fs.readFileSync(p, "utf8")); + expect(Object.keys(tr).sort(), `${l}/${f}`).toEqual(Object.keys(enJ).sort()); for (const k of keys("en", f)) { expect(tr[k], `${l}/${f} ${k}`).toBeTypeOf("string"); expect(vars(tr[k]), `${l}/${f} ${k}`).toBe(vars(enJ[k])); } } }); diff --git a/vendor/AGENTS.md b/vendor/AGENTS.md index 7a3467ce..9d545a69 100644 --- a/vendor/AGENTS.md +++ b/vendor/AGENTS.md @@ -1,8 +1,8 @@ # SDK archives -`README.md` records provenance, hashes and admission limits. The only pair is -`trunk/cortex-sdk-0.4.6.tgz` / `cortex-api-types-0.3.5.tgz` (packed from backend -`integration/backend`), selected by +`README.md` records provenance, hashes and admission limits. The selected pair is +`trunk/cortex-sdk-0.4.9.tgz` / `cortex-api-types-0.3.8.tgz` (byte-identical to backend +`main` `apps/web/vendor`), selected by `../packages/desktop/package.json`, `../packages/app/package.json` and `../bun.lock` with relative `file:../../vendor/...` paths (never absolute checkout paths). Older pairs were removed; git history keeps them. Do not patch archive contents or infer backend DTO completeness from generic diff --git a/vendor/README.md b/vendor/README.md index 36e550a7..e719e28a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,13 +1,27 @@ # Vendored Cortex SDK -## Current pair: SDK 0.4.6 / api-types 0.3.5 (backend trunk) +## Current audit pair: SDK 0.4.9 / api-types 0.3.8 -The only vendored pair, in `trunk/`. Packed with `packages/sdk/scripts/pack.mjs` (umask 077) from a +Copied byte for byte from backend `main` `c76eed8a` (`apps/web/vendor`, backend #525). The +api-types archive was repacked after merging backend #524, so it keeps `turn_error`; the SDK +bytes are unchanged. Includes the pending-image reconnect, live server-error and cross-chunk +resampling fixes. Both consumers select +`trunk/` relative archives; hashes match the backend web archives byte for byte. +Admission covers consumer suites, not deployed-account acceptance. + +| Archive | SHA-256 | +| --- | --- | +| `trunk/cortex-sdk-0.4.9.tgz` | `7cd2f4c23c7e554aba713aad2fe8ca6679acc3573040f33dd0915db0c6045c0c` | +| `trunk/cortex-api-types-0.3.8.tgz` | `e3de9c791017cfcaaf3a019d47b10f561e753bdc65a4718bb8eb15a2defb3636` | + +## Previous pair: SDK 0.4.6 / api-types 0.3.5 (backend trunk) + +The previous vendored pair, retained in `trunk/`. Packed with `packages/sdk/scripts/pack.mjs` (umask 077) from a `git archive` export of backend branch `integration/backend` at `17bb982b`; byte-identical to the backend's `apps/web/vendor` copies. Turn resume cursors are full Redis stream IDs (`-`); the SDK refuses any other cursor before opening a request, and the trunk API answers 422 to one. Selected by -`packages/desktop/package.json`, `packages/app/package.json` and `bun.lock` through relative -`file:../../vendor/trunk/...` paths. Every older pair was removed; the provenance below is history only. +the previous manifests through relative `file:../../vendor/trunk/...` paths. +The audit pair above is now selected; older provenance below is history only. | Archive | SHA-256 | | --- | --- | diff --git a/vendor/trunk/cortex-api-types-0.3.8.tgz b/vendor/trunk/cortex-api-types-0.3.8.tgz new file mode 100644 index 00000000..cadd2f07 Binary files /dev/null and b/vendor/trunk/cortex-api-types-0.3.8.tgz differ diff --git a/vendor/trunk/cortex-sdk-0.4.9.tgz b/vendor/trunk/cortex-sdk-0.4.9.tgz new file mode 100644 index 00000000..f17c341b Binary files /dev/null and b/vendor/trunk/cortex-sdk-0.4.9.tgz differ