From 37b3a7f9b6f653bc54e5d57a3e9c81d03acaabd8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:41:39 +0900 Subject: [PATCH 01/11] docs: plan Aside profile controls and ownership boundaries --- .../260906_aside_profiles/000_research.md | 21 +++++++++++++++ .../010_profiles_backend_cli.md | 27 +++++++++++++++++++ .../260906_aside_profiles/020_profiles_gui.md | 15 +++++++++++ 3 files changed, 63 insertions(+) create mode 100644 devlog/_plan/260906_aside_profiles/000_research.md create mode 100644 devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md create mode 100644 devlog/_plan/260906_aside_profiles/020_profiles_gui.md diff --git a/devlog/_plan/260906_aside_profiles/000_research.md b/devlog/_plan/260906_aside_profiles/000_research.md new file mode 100644 index 0000000000..b1e32b995f --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/000_research.md @@ -0,0 +1,21 @@ +# Aside profile synchronization roadmap + +User scope extension: synchronize all Aside profiles and expose independent profile switches in GUI and CLI. Continue the original Grok catalog/Responses stabilization stack; no local test suites or local typecheck, no release/service deployment. Existing push --no-verify and admin-merge authorization applies to these scoped layers. + +Observed installed contract: accounts.json has currentAccountId plus accounts[] with numeric id and name; profileAccountBindings maps browser profiles to accountId. This machine has three account-backed profiles (one cloud, two local), with a models.json only in the current account. Every model catalog lives under the configured Aside root/u//models.json. Browser profilePath is metadata, never a write destination. Multiple bindings sharing one account share one model catalog and therefore one control row. Keep only id/name/current metadata; never serialize sessions, tokens, user IDs, email or subscription metadata from the manifest. + +Current owners: config-export.ts asideCurrentAccountId resolves only currentAccountId. registry.ts aside.resolvePaths freezes that current path pair. writer.ts synchronous input supports resolvedPaths but its async freeze recomputes current paths; state.ts has no frozen-pair input. The ownership store is keyed by clientId, so a single root can retain only one Aside account. FileIntegrationPage already owns safe toggle/overwrite/history/restore, and all its resource keys currently use only client ID. CLI is a thin management caller with no profile flag. + +Decision: enumerate account-backed profiles; derive paths strictly from numeric IDs under Aside root, not profilePath. Partition each new profile's ownership and journal into /aside-profiles/; retain exactly one stable writable legacy root owner; all sibling writes use independent child stores. Older mixed legacy history remains readable by exact profile path and can be imported into the correct child store only for explicit restore. Freeze the chosen profile's paths for status/write/restore. Do not move user files, change currentAccountId, or copy credentials. + +Desired state: add asideProfileSync:{allProfiles?:boolean,profiles?:Record,legacyProfileId?:number|null} to OcxConfig. Absent defaults to whether a legacy Aside ownership record establishes prior connection. That legacy connection enables all discovered profiles by default, satisfying the user's all-profile request. A per-profile override persists independently. Before modifying a per-profile override, materialize the prior global default so disabling the legacy profile does not flip siblings. Explicit actions persist desired policy before any file writes; a save failure aborts with no file mutation. Bulk intent sets allProfiles and clears overrides, while actual per-profile applied states and refusals remain separate. A failed file mutation leaves visible pending intent, never an all-applied claim. Restore reconciles only its target profile policy with validated prior ownership so Undo cannot be silently reversed by the next sync. Per-profile-only enable when previously disconnected leaves other profiles off. Implicit sync refreshes owned enabled profiles and may safely apply an absent block in an explicitly/legacy-enabled unowned profile; it never overwrites foreign blocks or recreates a manually removed previously-owned block. + +Cycle map: docs-only roadmap; 010 backend/profile ownership/API/CLI (foundation and API can be separate dependent PRs within this single implementation unit); 020 GUI controls/QA and final full-stack landing. Every original exact-head CI and merge-ancestry criterion remains open until terminal delivery. + +Design Read: a repeated-use integration settings page using the existing monochrome dashboard: --bg white/#212121, --surface white/#262626, --accent #0d0d0d/#ececec, existing --font-ui and ClientMark. Compact profile rows show name/current marker, state, and switch; a global switch and enabled/total count summarize all profiles. Details reuse the existing FileIntegrationPage scoped to a selected profile so history/restore stays available. No new visual framework, assets or motion. DESIGN_VARIANCE2, MOTION_INTENSITY1, densityD5. Loading/error/empty/partial/busy states are explicit; the current browser account never changes when an integration switch changes. + +Resource bounds inherited: six-hour window from original goal, no requested token budget, original at-most24 live synthetic provider requests. Profile probes use temporary roots with three profiles; bulk production discovery is bounded to128 account entries. Existing local/GitHub credentials only for authorized repo work. Actual user profile files remain read-only during development. Runtime file writes are tested only in isolated fixtures. C4 ownership/path review is required before production merge; security working notes stay ignored scratch. + +## Baseline + +`bun .tmp/aside-profiles/baseline.ts` runs only synthetic temp files: manifest has0/1/2, legacy owned0, current model-selection route runs refresh, and configuredAsideProfiles remains1. This reproduces the user report without editing any real profile. Browser profile bindings resolve to three distinct account IDs in the current install. diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md new file mode 100644 index 0000000000..7a434dc7f9 --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -0,0 +1,27 @@ +# 010 Profile data, ownership, API and CLI + +Class C4 for controlled multi-file writes; spec-satisfaction repair. Goal: all account-backed Aside profiles receive the selected catalog and can be independently enabled/disabled. Non-goals: login/account switching, browser profile data, credential changes, unowned overwrite without the existing explicit flag, other clients redesign. + +NEW src/clients/aside-profiles.ts: typed AsideProfile {id:number,name?:string,current:boolean,configPath:string,detectDir:string}; read configured asideHomeDir accounts.json, validate bounded account array, dedupe safe nonnegative integer IDs, fall back to current-only legacy manifest when accounts is absent, fail on malformed identities. Map only safe metadata; derive root/u/id paths. A numeric query selector must refer to this enumeration. No path from browser profile bindings reaches writes. +MODIFY src/types/config.ts + src/config.ts: asideProfileSync optional object with allProfiles boolean, numeric-key boolean overrides, and optional nullable safe-integer legacyProfileId provenance. Per-field validity must not erase unrelated configuration; preserve unknown future policy fields where existing conventions require. Full field chain: creation in Aside mutation service; persistence via saveConfigPreservingClaudeCode; deserialization in config schema; consumers profile status, explicit toggles and implicit sync; serialization GUI/CLI receives effective enabled per row, not raw credentials. +MODIFY src/integrations/state.ts: optional resolvedPaths in IntegrationStateInput, use it instead of resolving current profile. MODIFY writer.ts freezeIntegrationInput to clone a supplied internal resolved pair, preserving existing resolution otherwise. This is an internal seam only; routes never accept caller-provided paths. +NEW src/integrations/aside-profiles.ts: resolve profile-specific store/path input. Exactly one profile may use the writable legacy root: a matching current legacy ownership record wins; only if no record exists may the newest legacy Aside operation choose it. An unrecognized existing record makes the root unassigned. Persist the resolved legacyProfileId (number or null) before the first explicit mutation, so disabling/reloading cannot reassign it. All other profiles use isolated child stores. Read statuses with same classifier. Model load memoized across profiles. Compute effective default and per-profile override. Explicit enable/disable/overwrite uses existing coordinated writer and mutation-flight exclusivity, serializes profiles, returns per-profile results; persist desired preferences through the caller save seam before file mutation, under the same exclusive operation. On save failure restore the in-memory prior policy and abort before filesystem changes. Report desired enabled separately from actual state and per-profile refusals; do not fabricate all-applied success. Missing/foreign/unsafe/drifted profile remains untouched with explicit refusal. A manual deletion with a surviving ownership record stays absent on implicit refresh. First safe creation in enabled unowned profile uses apply without overwrite. Never switch the active account. +MODIFY src/integrations/owned-refresh.ts optional internal resolvedPaths; MODIFY catalog-refresh.ts Aside fan-out to profile service and preserve per-profile outcome IDs; update CLI explicit sync logs/type projection to identify profiles. + +NEW src/server/management/aside-profile-routes.ts: GET /api/client-integrations/aside/profiles returns {profiles:[{profileId,name?,current,enabled,...IntegrationStatus}],allEnabled,enabledCount,total}; GET /aside without profile returns aggregate IntegrationStatus+profiles, PUT /aside without profile acts on all discovered profiles. Existing /aside?profile= handles one explicit profile with same mutation/refusal semantics. Numeric profile parsing is strict, membership checked, non-Aside use rejected. Reuse existing jsonResponse/body parsing/CSRF outer boundary. Return partial failures visibly; do not turn mixed outcomes into a successful all-applied status. +MODIFY integration-routes.ts: route Aside list/status/toggle to profile service; collection projects Aside aggregate while other clients remain unchanged. Bind optional profile scope for journal/delete/restore query paths to the same selected store and frozen paths; no snapshot can restore into another profile. Existing no-profile legacy history remains accessible. Existing test hooks (root store/env/home/io/lock seams) must propagate. New prefs writes use deps.saveConfigPreservingClaudeCode, never bypass fixture isolation. +MODIFY src/cli/integrations.ts: --profile for Aside status/show/list, enable/disable, history/journal and restore/delete equivalents that exist; reject on other clients and malformed IDs. No --profile on Aside enable/disable means all. Route flag through query profile; status prints all per-profile rows, JSON preserves metadata; mixed failure exits nonzero with structured result retained. Update usage/capability source if help registry owns it, and operating docs. + +Tests: new profile enumeration/store/writer domain tests registered in both layout manifests; management and CLI tests cover current0+local1+local2, all-enable, individual-off persists through sync, legacy-default all, explicit one-only enable, active-account changes do not retarget a pinned write, unowned/drifted/removed/symlink/missing profile refusals, malformed selectors, unknown ID, partial outcome, per-profile journal/restore isolation and old legacy history. Actual temporary fixtures and original writer/management calls; no live user config mutation. + +Verification: standalone temp-root production probe establishes three distinct file outputs and one-off persistence across refresh; remote Bun focused regressions/typecheck/privacy gates; independent ownership/API review. Final exact-head hosted CI and all PR ancestry are terminal obligations, not satisfied by queueing. Candidate new paths source-checked before B. Escalation only for a concrete unresolvable external constraint, not routine design choices. + +## Audit-locked operational contracts + +- One outer Aside mutation flight owns the complete action, including policy persistence and every coordinated writer call. Its key includes root fingerprint, sorted selected profile IDs, operation/overwrite/restore semantics and a unique operation nonce. Overlap returns busy; no profile ever joins another result. Do not nest refreshOwnedIntegration inside that flight; call coordinated refresh/apply directly after the service's ownership checks. Different profile roots cannot coalesce either. +- Profile status and every writer use the concrete filesystem validation/guard contract recorded in ignored .tmp/aside-profiles/security-scope.md. Frozen path pairs alone are not the boundary. The guard is rechecked immediately before file mutation and is shared with status. +- Restore resolves the operation's exact profile independently of currentAccountId. Before policy persistence validate operation/snapshot availability, target identity and ordinary drift preflight. Desired state after Undo is true only when priorRecord describes the exact snapshot bytes as owned; absent/foreign/conflicted snapshots set a target false override. Global defaults and sibling overrides remain unchanged. Persist that target intent first; writer refuses or restores under the same flight. Cover enable->undo->sync and disable->undo->sync after reload. A later filesystem refusal remains visible as desired/actual mismatch, not success. +- NEW src/integrations/aside-profile-journal.ts (if separation needed): path-filtered profile history combines its writable store and matching legacy operations, deduping operation IDs. Snapshot reads use each operation's source store. A restore of an older sibling legacy operation imports only that immutable operation and its available snapshot into the target child store (same opId, exact priorRecord/configPath, no original deletion), then uses the existing coordinated restore there; it never changes the legacy owner's record. Expired snapshots stay expired. Profile history deletion checks the newest operation within that profile and retires duplicate imported/source copies together so a deleted row cannot reappear. Generic history/restore paths resolve Aside operation scope by exact configPath when no profile query is supplied, and reject an operation whose profile is no longer registered instead of retargeting it. +- Add profileId to journal/API rows, and treat (clientId,profileId/configPath) as history ownership for latest/undo/delete checks. Existing non-Aside behavior stays unchanged. + +C4 audit findings and concrete filesystem guard details are kept in ignored scratch; the public roadmap records feature contracts only. diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md new file mode 100644 index 0000000000..7dda350656 --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md @@ -0,0 +1,15 @@ +# 020 Aside GUI profile controls and terminal delivery + +Depends on 010 verified API and CLI. Class C3 UI with C4 backend unchanged. Goal: all discovered profiles are visible, bulk and individual switches operate on exact profiles, and existing history/restore is still usable. + +NEW gui/src/pages/integrations/AsideProfilesPage.tsx: useDataSurface GET profiles endpoint, existing Notice/Switch/ClientMark/IntegrationStateBadge. Global switch sets desired sync for all; rows show profile name or translated numeric fallback, current marker, actual state, independent switch, and details action. Single pending target serializes interactions consistently with backend. Switches read desired enabled; badges and applied/total count read actual file state. Show pending mismatch and per-profile refusal after partial failure, never optimistic applied success for siblings. A retry repeats the same desired action. Empty profile list prompts opening Aside; errors offer existing refresh action; inactive tabs do not fetch. A selected profile opens the existing FileIntegrationPage with profileId plus name and a back action; do not duplicate its rollback machinery. +MODIFY gui/src/pages/Integrations.tsx: Aside renders new page; remaining file clients stay on existing page. +NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId appended to state/toggle/history/restore/delete query URLs. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. +MODIFY FileIntegrationPage.tsx: optional profileId/profileLabel, read optional desired enabled on scoped status, include profile in every resource/cache/dependency key and every state/history/mutation call. MODIFY RestoreDialog.tsx if needed to pass profile scope through; rollback/delete remain on selected profile. +MODIFY styles-integrations.css: compact row layout using existing tokens; responsive wrapping for long labels/paths. No new color system or decorative assets. +MODIFY every gui/src/i18n locale module: profile list/title, sync-all, enabled count, current profile, details/back, empty, per-profile switch labels and partial failure copy. All visible text uses t/useT; names and numeric IDs are API metadata. +UPDATE guides/integrations.md and operating CLI docs with all-profile default, --profile examples, active-profile independence, per-profile exclusions and restart behavior. Translations must not contradict new all-profile behavior. + +Verification: remote focused GUI/API tests, GUI lint/i18n/build, root typecheck and required CI. Browser QA on local dev UI against three synthetic profiles, never real user profile mutation: initial mixed state, global enable, one profile disable, return to list after details, correct request selector, reload retains off state, failed profile does not imply sibling success, keyboard switches and narrow viewport. Capture actual screenshot for PR body using existing browser plugin, view it, and fix layout if needed. Screenshot contains synthetic labels only. A PR mentioning GUI includes screenshot. No local test suite or local typecheck; local dev server/browser probes are permitted. + +Terminal: verify every PR current head and all applicable hosted checks; native stack registration, owner-authorized admin merge, async merge completion, fetch dev and prove every merge SHA ancestry. Resolve CI or reviews rather than bypass evidence. No release or live service deployment. All original Grok/Pi/Codex and added Aside-profile criteria must be met before host goal completion. From 52d8d5ca844d9be09d4c6c4fbb2d92ff0b903555 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:05:50 +0900 Subject: [PATCH 02/11] feat(aside): synchronize profiles with independent CLI controls --- .../010_profiles_backend_cli.md | 16 + .../src/content/docs/guides/integrations.md | 31 +- scripts/test-layout/layout.json | 3 + skills/ocx/references/03_recipes.md | 18 +- src/cli/dispatch.ts | 5 +- src/cli/integrations.ts | 48 ++- src/clients/aside-profiles.ts | 224 +++++++++++ src/config.ts | 11 + src/integrations/aside-profile-context.ts | 270 +++++++++++++ src/integrations/aside-profile-journal.ts | 215 ++++++++++ src/integrations/aside-profiles.ts | 166 ++++++++ src/integrations/catalog-refresh.ts | 5 + src/integrations/owned-refresh.ts | 3 + src/integrations/state.ts | 4 +- src/integrations/writer.ts | 4 +- src/server/management/aside-profile-routes.ts | 166 ++++++++ src/server/management/config-routes.ts | 1 + src/server/management/integration-routes.ts | 58 ++- src/types/config.ts | 7 + structure/09_client-integrations.md | 13 + tests/cli/cli-headless-parity.test.ts | 39 ++ tests/clients/aside-profile-paths.test.ts | 267 +++++++++++++ tests/clients/aside-profiles.test.ts | 373 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 + tests/server/aside-profiles-routes.test.ts | 129 ++++++ 25 files changed, 2054 insertions(+), 25 deletions(-) create mode 100644 src/clients/aside-profiles.ts create mode 100644 src/integrations/aside-profile-context.ts create mode 100644 src/integrations/aside-profile-journal.ts create mode 100644 src/integrations/aside-profiles.ts create mode 100644 src/server/management/aside-profile-routes.ts create mode 100644 tests/clients/aside-profile-paths.test.ts create mode 100644 tests/clients/aside-profiles.test.ts create mode 100644 tests/server/aside-profiles-routes.test.ts diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md index 7a434dc7f9..36be9363ae 100644 --- a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -25,3 +25,19 @@ Verification: standalone temp-root production probe establishes three distinct f - Add profileId to journal/API rows, and treat (clientId,profileId/configPath) as history ownership for latest/undo/delete checks. Existing non-Aside behavior stays unchanged. C4 audit findings and concrete filesystem guard details are kept in ignored scratch; the public roadmap records feature contracts only. + +## P implementation interfaces at37b3a7f9b + +Delegation is within this one010 cycle with disjoint write sets. Path worker owns clients/aside-profiles.ts and tests/clients/aside-profile-paths.test.ts. Engine worker owns integrations/aside-profile-context.ts, aside-profiles.ts, aside-profile-journal.ts and tests/clients/aside-profiles.test.ts. Main owns type/config schemas, resolved-path seams in state/writer, management routes, CLI, implicit fan-out wiring and route/CLI tests. No worker commits, orchestration, local suites or real profile mutation. + +Path module exports AsideProfile {id,name?,current,root,configPath,detectDir}; listAsideProfiles(env?,home?) and guardAsideProfileIO(profile,io,profiles?) plus assertAsideProfileBoundary(profile,profiles?,mutation?). Invalid manifest/selector/path raises ClientPathError with safe text. Engine module exports AsideProfilesInput (config, models array/lazy, port, env/home/store/io, persistConfig?, lockSeams?), AsideProfileState (IntegrationStatus plus profileId/name/current/enabled and optional safe error), AsideProfileList (clientId,profiles,allEnabled,enabledCount,appliedCount,total plus aggregate state fields), listAsideProfileStates, getAsideProfileState(input,id), mutateAsideProfiles(input,{enabled,profileId?,overwriteConflict?}), refreshAsideProfiles. Mutations return {ok,clientId,changed,state,message,results:[WriteOutcome+profileId]}; singleton result stays accessible for the existing refusal serializer. + +Journal module exports listAsideOperations(input,profileId?) -> [{profileId,entry,store}], findAsideOperation(input,opId,profileId?) -> row|null, restoreAsideProfile(input,{opId,profileId?,confirmDrift?}) -> WriteOutcome+profileId and deleteAsideOperation(input,{opId,profileId?,principal?}). Main serializes journal metadata using each source store; profile-scoped newest protection and duplicate retirement live in the journal service. Journal discovery can return null for unrecognized non-Aside operations so the existing route handles them. + +The context owner centralizes exact scope/store resolution, desired policy, guarded IO and outer flight; engine/journal import it without circular imports. Scope includes a safe ownership-store root as well as the client file target. No writable legacy root may be shared across profiles. Domain errors carry safe code/status for route mapping; no manifest/session payload reaches diagnostics. + +## Implementation evidence and review scope + +`bun .tmp/aside-profiles/api-cli-probe.ts` passed against an isolated live HTTP management handler and actual CLI: three-profile bulk enable, individual-off after persisted reload/model selection, Undo followed by sync, unrelated settings and metadata privacy. Default unconfigured/disabled Aside now skips implicit fan-out before manifest/catalog discovery. + +This C4 backend layer is larger than the default review-size guideline because the new filesystem scope, one-owner store model, reversible desired state, and API/CLI consumers must be assessed as one complete contract; these are new cohesive modules with focused fixtures, not unrelated cleanup. UI implementation remains a separate dependent PR/cycle, and the original Grok work is already four separate reviewed PRs. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index a6977f4066..d12c038862 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -52,12 +52,10 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. -Aside is per-account: its state lives under `~/.aside/u//` and opencodex -writes the catalog of whichever account Aside's own `accounts.json` names as -current. If that manifest is missing or unreadable the integration refuses rather -than guessing an account, because a guess on a multi-account machine would write -into a different account's catalog. Its managed block owns only -`providers.opencodex`, so your other Aside providers stay untouched. +Aside keeps a separate model catalog for each account-backed browser profile. OpenCodex lists +all registered profiles, including local profiles, and can synchronize them together or control +one profile at a time. Switching an integration never changes Aside's active account. A prior +Aside connection enables all profiles by default; individual exclusions survive later syncs. One caveat specific to Aside: the running app rewrites `models.json` itself, so fully quit and reopen Aside after applying, the same way Claude Desktop needs a @@ -248,3 +246,24 @@ decision to make. Client details were verified against each project's own configuration format; see the research notes in `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` for what was checked and when. + +## Aside profile controls + +```bash +ocx integration client status --client aside --json +ocx integration client enable --client aside +ocx integration client disable --client aside --profile 1 +ocx integration client history --client aside --profile 1 +ocx integration client restore --client aside --profile 1 --op +``` + +The profile number is the account ID shown by the status command. Omitting `--profile` on an +Aside toggle applies the desired state to every registered profile. A per-profile change leaves +siblings unchanged. Desired sync settings are saved before file changes; actual state and any +refusal are reported for each profile. A partial bulk result is not an all-applied success and +the CLI exits nonzero. Undo restores the selected profile's synchronization intent as well as +its file, so a later sync does not silently reverse Undo. + +Each profile has separate ownership and history. Existing user edits, unsafe paths and linked +catalogs are refused; the existing explicit overwrite and drift-confirmation controls remain +available. Fully quit and reopen Aside to load changed model files. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 999863fbc0..4e57ed3311 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -232,6 +232,9 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 4f48d2bfd7..d482261932 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -1,6 +1,7 @@ # Recipes -Each sequence below was run against a live proxy. Every command named here exists; where the +The original sequences below were run against a live proxy; the Aside profile sequence was +verified through an isolated live management handler and the production CLI. Every command named here exists; where the obvious-sounding command does *not* exist, that is called out rather than left as a trap. Preflight for all of them: @@ -205,3 +206,18 @@ Two absences are also expected and are not defects: provider sets `liveModels: false` deliberately — its authenticated roster includes image and voice models this Responses-agent provider cannot drive — so the absence of a live probe is a design decision, not a broken connection. + +## Aside profiles + +```bash +ocx integration client status --client aside --json +ocx integration client enable --client aside +ocx integration client disable --client aside --profile 1 +ocx integration client history --client aside --profile 1 +ocx integration client restore --client aside --profile 1 --op +``` + +Read `profiles[]` to find numeric profile IDs. No profile selector means a bulk toggle; an +explicit selector affects only that account-backed profile. Sync intent and actual file state +are distinct, so inspect each result after a partial bulk operation. The CLI returns nonzero +for a partial refusal. Never use the overwrite or drift flags merely to suppress a refusal. diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6fef425ad5..131ab7b1f6 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -402,8 +402,9 @@ const commandRunners: Record = { port: live.port, }, ["mcode", "pi", "aside"]); for (const result of results) { - if (result.changed) console.log(`${result.client} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${result.client} integration was not refreshed: ${result.reason}`); + const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; + if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); } } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 3b417632ff..e151a3c836 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -1,5 +1,6 @@ import { CliUsageError, + RuntimeApiError, csv, printData, rejectArgs, @@ -28,10 +29,20 @@ const GROK_USAGE = `Usage: ocx grok apply [--json]`; const CLIENT_USAGE = `Usage: - ocx integration client [status] [--client ] [--json] - ocx integration client --client [--overwrite-conflict] [--json] - ocx integration client history [--client ] [--json] - ocx integration client restore --op [--confirm-drift] [--json]`; + ocx integration client [status] [--client ] [--profile ] [--json] + ocx integration client --client [--profile ] [--overwrite-conflict] [--json] + ocx integration client history [--client ] [--profile ] [--json] + ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json] + --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`; + +function asideProfileQuery(profile: string | undefined, client: string | undefined): string { + if (profile === undefined) return ""; + if (client !== "aside") throw new CliUsageError("--profile requires --client aside", CLIENT_USAGE); + if (!/^(0|[1-9][0-9]*)$/.test(profile) || !Number.isSafeInteger(Number(profile))) { + throw new CliUsageError("--profile must be a nonnegative integer account ID", CLIENT_USAGE); + } + return `profile=${encodeURIComponent(profile)}`; +} function parseMap(raw: string): Record { if (raw === "-") return {}; @@ -163,16 +174,21 @@ export async function handleClientIntegrationCommand( const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); + const profile = takeOption(args, "--profile"); if (action === "status" || action === "show" || action === "list") { const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); rejectArgs(args, CLIENT_USAGE); const path = client - ? `/api/client-integrations/${encodeURIComponent(client)}` + ? `/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}` : "/api/client-integrations"; const result = await runtimeRequest(path, {}, deps); const rows = (result as { clients?: Array> }).clients; - printData(result, wantsJson, rows + const profiles = (result as { profiles?: Array> }).profiles; + printData(result, wantsJson, profiles + ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) : summaryLines(result)); return; @@ -180,8 +196,9 @@ export async function handleClientIntegrationCommand( if (action === "history" || action === "journal") { const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); rejectArgs(args, CLIENT_USAGE); - const query = client ? `?client=${encodeURIComponent(client)}` : ""; + const query = client ? `?client=${encodeURIComponent(client)}${profileQuery ? `&${profileQuery}` : ""}` : ""; const result = await runtimeRequest(`/api/client-integrations/journal${query}`, {}, deps); const operations = (result as { operations?: Array> }).operations ?? []; printData(result, wantsJson, operations.length === 0 @@ -190,7 +207,8 @@ export async function handleClientIntegrationCommand( // `snapshot` is resolved against the disk by the route, so "expired" // here means the bytes are genuinely gone, not merely old. const backup = row.snapshot === "expired" ? "backup expired" : `op ${String(row.opId)}`; - return `${String(row.at)} ${String(row.clientId)} ${String(row.kind)} (${backup})`; + const owner = row.profileId === undefined ? String(row.clientId) : `${String(row.clientId)}:${String(row.profileId)}`; + return `${String(row.at)} ${owner} ${String(row.kind)} (${backup})`; })); return; } @@ -198,9 +216,12 @@ export async function handleClientIntegrationCommand( if (action === "restore") { const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id"); const confirmDrift = takeFlag(args, "--confirm-drift"); + const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); + if (client !== undefined && !profileQuery) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); - const result = await runtimeRequest("/api/client-integrations/restore", { + const result = await runtimeRequest(`/api/client-integrations/restore${profileQuery ? `?client=aside&${profileQuery}` : ""}`, { method: "POST", body: JSON.stringify({ opId, confirmDrift }), }, deps); @@ -212,6 +233,7 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); /* * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the * refusal is the default and the waiver has to be typed. @@ -232,7 +254,7 @@ export async function handleClientIntegrationCommand( if (overwriteConflict && action === "disable") { throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); } - const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, { + const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}`, { method: "PUT", // Sent only when asked for, so a proxy on an older build sees the request it // has always seen rather than an unknown field. @@ -240,7 +262,11 @@ export async function handleClientIntegrationCommand( ? { enabled: true, overwriteConflict: true } : { enabled: action === "enable" }), }, deps); - printData(result, wantsJson, [String((result as Record).message ?? `${client} ${action}d.`)]); + const batch = result as { ok?: boolean; message?: string; results?: Array> }; + printData(result, wantsJson, batch.results + ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}`) + : [String(batch.message ?? `${client} ${action}d.`)]); + if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); }); } diff --git a/src/clients/aside-profiles.ts b/src/clients/aside-profiles.ts new file mode 100644 index 0000000000..31f13d9b76 --- /dev/null +++ b/src/clients/aside-profiles.ts @@ -0,0 +1,224 @@ +import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { IntegrationIO } from "../integrations/config-io"; +import { asideHomeDir, ClientPathError } from "./config-export"; + +export interface AsideProfile { + id: number; + name?: string; + current: boolean; + root: string; + configPath: string; + detectDir: string; +} + +const MAX_PROFILES = 128; +const MAX_MANIFEST_BYTES = 4 * 1024 * 1024; +const MAX_LEAF_LINKS = 40; + +function refuse(message: string): never { + // Never include manifest contents or underlying filesystem error messages. + throw new ClientPathError(`Aside profile: ${message}`); +} + +function isId(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0); +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function inspect(path: string, follow = false): Stats | null { + try { + return follow ? statSync(path) : lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + return refuse("a filesystem boundary could not be inspected."); + } +} + +function canonical(path: string): string { + try { return realpathSync.native(path); } catch { + return refuse("a filesystem boundary could not be resolved."); + } +} + +/** Resolve a peer's leaf link even when its final model file does not exist yet. */ +function leafDestination(path: string): string | null { + const visited = new Set(); + while (inspect(path)?.isSymbolicLink()) { + if (visited.has(path) || visited.size >= MAX_LEAF_LINKS) refuse("an account catalog has a cyclic or excessive link chain."); + visited.add(path); + try { path = resolve(dirname(path), readlinkSync(path)); } catch { + return refuse("an account catalog link could not be inspected."); + } + } + if (!inspect(dirname(path), true)?.isDirectory()) return null; + return join(canonical(dirname(path)), basename(path)); +} + +function readProfiles(root: string): AsideProfile[] { + const rootStat = inspect(root); + if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + refuse("the configured root is missing or is not a safe directory."); + } + const manifest = join(root, "accounts.json"); + const manifestStat = inspect(manifest); + if (!manifestStat || manifestStat.isSymbolicLink() || !manifestStat.isFile() + || manifestStat.size > MAX_MANIFEST_BYTES) { + refuse("the account manifest is missing, unreadable or unsafe. Launch Aside to create it."); + } + let parsed: unknown; + try { parsed = JSON.parse(readFileSync(manifest, "utf8")); } catch { + return refuse("the account manifest is not readable JSON."); + } + if (!object(parsed) || !isId(parsed.currentAccountId)) { + refuse("the account manifest has no valid current account ID."); + } + const currentId = parsed.currentAccountId; + const accounts: unknown = Object.hasOwn(parsed, "accounts") ? parsed.accounts : [{ id: currentId }]; + if (!Array.isArray(accounts) || accounts.length === 0 || accounts.length > MAX_PROFILES) { + refuse("the account manifest must contain between 1 and 128 accounts."); + } + const ids = new Set(); + const profiles = accounts.map((account: unknown): AsideProfile => { + if (!object(account) || !isId(account.id) || ids.has(account.id)) { + return refuse("the account manifest contains an invalid or duplicate account ID."); + } + const current = account.id === currentId; + if (Object.hasOwn(account, "current") && account.current !== current) { + refuse("the account manifest has inconsistent current account metadata."); + } + ids.add(account.id); + const detectDir = join(root, "u", String(account.id)); + return { + id: account.id, + ...(typeof account.name === "string" ? { name: account.name } : {}), + current, root, detectDir, configPath: join(detectDir, "models.json"), + }; + }); + if (!ids.has(currentId)) refuse("the current account is not registered in the account manifest."); + return profiles; +} + +/** Enumerate account catalogs; browser bindings and session data are never projected. */ +export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): AsideProfile[] { + const root = asideHomeDir(env, home); + if (!isAbsolute(root)) refuse("the configured root must be absolute."); + return readProfiles(root); +} + +type DirectoryIdentity = { path: string; dev: number; ino: number }; +type Boundary = Array; + +function sameIdentity(a: Pick, b: Pick): boolean { + return a.dev === b.dev && a.ino === b.ino; +} + +function validatePaths(profile: AsideProfile): void { + if (!isId(profile.id) || !isAbsolute(profile.root) || resolve(profile.root) !== profile.root + || profile.detectDir !== join(profile.root, "u", String(profile.id)) + || profile.configPath !== join(profile.detectDir, "models.json")) { + refuse("the selected account paths are invalid."); + } +} + +function registeredProfiles(profile: AsideProfile, profiles?: AsideProfile[]): AsideProfile[] { + validatePaths(profile); + const registered = profiles ?? readProfiles(profile.root); + if (registered.length === 0 || registered.length > MAX_PROFILES) refuse("the account list is invalid."); + const ids = new Set(); + for (const peer of registered) { + validatePaths(peer); + if (peer.root !== profile.root || ids.has(peer.id)) refuse("the account list has conflicting paths."); + ids.add(peer.id); + } + if (!ids.has(profile.id)) refuse("the selected account is not registered."); + return registered; +} + +function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boolean): Boundary { + const directories = [profile.root, join(profile.root, "u"), profile.detectDir]; + const identities: Boundary = []; + let parent: string | undefined; + let absent = false; + for (const [index, directory] of directories.entries()) { + const stats = absent ? null : inspect(directory); + if (!stats) { + if (index === 0 || mutation) refuse("the account directory is not installed; it will not be created."); + absent = true; + identities.push(null); + continue; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) refuse("an account directory is a link or is unsafe."); + const path = canonical(directory); + // Aliases ABOVE the chosen root (notably macOS /var) are valid. + const child = index === 1 ? "u" : String(profile.id); + if (parent && path !== join(parent, child)) refuse("an account directory resolves outside its boundary."); + identities.push({ path, dev: stats.dev, ino: stats.ino }); + parent = path; + } + if (absent) return identities; + const leaf = inspect(profile.configPath); + if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) { + refuse("the model catalog is a link, shared file or non-regular file."); + } + if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) { + refuse("the model catalog resolves outside its account directory."); + } + const account = identities[2]!; + for (const peer of profiles) { + if (peer.id === profile.id) continue; + // Follow peers only for identity comparison, never for content or writes. + // This also detects a sibling symlink pointing BACK at this safe target. + const peerDirectory = inspect(peer.detectDir, true); + if (peerDirectory && sameIdentity(account, peerDirectory)) refuse("account directories share a target."); + if (!peerDirectory?.isDirectory()) continue; + if (inspect(peer.configPath)?.isSymbolicLink() + && leafDestination(peer.configPath) === join(parent!, "models.json")) { + refuse("account catalogs share a target."); + } + const peerLeaf = inspect(peer.configPath, true); + if (leaf && peerLeaf && sameIdentity(leaf, peerLeaf)) refuse("account catalogs share a target."); + } + return identities; +} + +/** Missing account directories are readable as not installed, but never writable. */ +export function assertAsideProfileBoundary(profile: AsideProfile, profiles?: AsideProfile[], mutation = false): void { + boundary(profile, registeredProfiles(profile, profiles), mutation); +} + +/** + * Pin directories for one status/write operation, retaining the caller's IO and store. + * Rechecks complement atomic writes; they do not defeat a hostile same-user process + * racing every filesystem syscall. Leaf inodes may change during our atomic writes. + */ +export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, profiles?: AsideProfile[]): IntegrationIO { + const selected = { ...profile }; + const registered = registeredProfiles(selected, profiles).map(peer => ({ ...peer })); + const captured = boundary(selected, registered, false); + function check(path: string, directory: boolean, mutation: boolean): void { + if (path !== (directory ? selected.detectDir : selected.configPath)) { + refuse("IO attempted to access a different account path."); + } + const current = boundary(selected, registered, mutation); + if (current.some((item, index) => { + const prior = captured[index]; + return item === null || prior == null ? item !== prior : item.path !== prior.path || !sameIdentity(item, prior); + })) refuse("the account directory changed after the operation began."); + } + return { + readText: path => { check(path, false, false); return io.readText(path); }, + statKind: path => { check(path, path === selected.detectDir, false); return io.statKind(path); }, + writeText: (path, text) => { check(path, false, true); io.writeText(path, text); }, + removeFile: path => { check(path, false, true); io.removeFile(path); }, + mkdirp: path => { check(path, true, true); io.mkdirp(path); }, + now: () => io.now(), + appendJournal: entry => io.appendJournal(entry), + putRecord: record => io.putRecord(record), + dropRecord: clientId => io.dropRecord(clientId), + }; +} diff --git a/src/config.ts b/src/config.ts index 5d67275dce..a5eb9a0a70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -932,6 +932,15 @@ const clientIntegrationsSchema = z.object({ "claude-desktop": z.boolean().optional().catch(undefined), }).passthrough(); +const asideProfileSyncSchema = z.object({ + allProfiles: z.boolean().optional(), + profiles: z.record( + z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))), + z.boolean(), + ).optional(), + legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(), +}).passthrough(); + const agentTaskRecoverySchema = z.object({ enabled: z.boolean().optional(), model: z.string().trim().min(1).optional(), @@ -1134,6 +1143,8 @@ const configSchema = z.object({ subagentModelsVersion: z.number().int().positive().optional().catch(undefined), subagentModels: z.array(z.string().min(1)).optional().catch(undefined), clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), + // A malformed profile policy must not fall back to legacy all-profile activation. + asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts new file mode 100644 index 0000000000..d0bd9536cb --- /dev/null +++ b/src/integrations/aside-profile-context.ts @@ -0,0 +1,270 @@ +import { lstatSync, readdirSync, realpathSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { ClientPathError, type ExportModel } from "../clients/config-export"; +import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles"; +import type { OcxConfig } from "../types"; +import { type IntegrationIO } from "./config-io"; +import type { JournalEntry } from "./journal"; +import { IntegrationMutationBusyError, runIntegrationMutationFlight } from "./mutation-flight"; +import { fingerprint } from "./ownership"; +import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import type { IntegrationWriteInput, WriteOutcome } from "./writer"; +import type { IntegrationWriterLockSeams } from "./writer-lock"; + +export interface AsideProfilesInput { + config: OcxConfig; + models: readonly ExportModel[] | (() => Promise); + port: number; + env?: NodeJS.ProcessEnv; + home?: string; + store?: IntegrationStateStore; + io?: IntegrationIO; + persistConfig?: (config: OcxConfig) => void | Promise; + lockSeams?: IntegrationWriterLockSeams; +} + +export class AsideProfileError extends Error { + constructor(readonly code: string, readonly status: number, message: string) { + super(message); + this.name = "AsideProfileError"; + } +} + +export type AsideProfilePolicy = NonNullable; +export type AsideProfileWriteOutcome = WriteOutcome & { profileId: number }; +export interface AsideProfileScope { + profile: AsideProfile; + store: IntegrationStateStore; + io: IntegrationIO; + assertBoundary: () => void; +} +export interface AsideProfileContext { + input: AsideProfilesInput; + profiles: AsideProfile[]; + rootStore: IntegrationStateStore; + legacyProfileId: number | null; + defaultEnabled: boolean; + models: () => Promise; + scopes: Map; +} + +function storeUnsafe(): never { + throw new AsideProfileError("aside_profile_store_unsafe", 409, "Aside profile ownership storage cannot be accessed safely"); +} + +/** Allow aliases above the trusted anchor, never at or below it. */ +function storeGuard(anchor: string, target: string): () => void { + const base = resolve(anchor); + const root = resolve(target); + const rel = relative(base, root); + if (rel.startsWith(`..${sep}`) || rel === ".." || resolve(base, rel) !== root) storeUnsafe(); + const identities = new Map(); + const inspect = (path: string, directory: boolean): boolean => { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe(); + if (directory) { + const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`; + if (identities.has(path) && identities.get(path) !== identity) storeUnsafe(); + identities.set(path, identity); + } + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" && !identities.has(path)) return false; + storeUnsafe(); + } + }; + return () => { + let path = base; + if (!inspect(path, true)) return; + for (const part of rel ? rel.split(sep) : []) { + path = join(path, part); + if (!inspect(path, true)) return; + } + for (const name of ["records.json", "journal.jsonl", "maintenance.json"]) inspect(join(root, name), false); + const snapshots = join(root, "snapshots"); + if (!inspect(snapshots, true)) return; + const aside = join(snapshots, "aside"); + if (inspect(aside, true)) for (const name of readdirSync(aside)) inspect(join(aside, name), false); + }; +} + +/** Store methods close over their original root; IO bookkeeping must bind to the guarded facade. */ +function guardedStore(store: IntegrationStateStore, anchor: string): IntegrationStateStore { + const guard = storeGuard(anchor, store.root); + guard(); + return new Proxy(store, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + guard(); + if (property === "readSnapshot") assertAsideSnapshotEntry(args[0] as JournalEntry); + // Maintenance in this service is Aside-scoped, including a legacy shared store. + if (property === "retryPendingPrunes") { + if (store.readMaintenance().pruneFailures.aside) { + if (store.pruneSnapshots("aside").ok) store.clearPruneFailure("aside"); + } + return; + } + return Reflect.apply(value, target, args); + }; + }, + }); +} + +export function asideRootStore(input: AsideProfilesInput): IntegrationStateStore { + const raw = input.store ?? createIntegrationStateStore(); + return guardedStore(raw, raw.root); +} + +export function assertAsideSnapshotEntry(entry: JournalEntry): void { + if (!entry || entry.clientId !== "aside" || typeof entry.opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(entry.opId) || !entry.snapshot + || !["none", "stored", "expired"].includes(entry.snapshot.kind) + || (entry.snapshot.kind === "stored" && entry.snapshot.relPath !== join("snapshots", "aside", entry.opId))) { + throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation snapshot metadata is invalid"); + } +} + +export function createAsideProfileContext(input: AsideProfilesInput): AsideProfileContext { + let profiles: AsideProfile[]; + try { profiles = listAsideProfiles(input.env, input.home); } + catch (error) { + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profiles_unavailable", 409, error.message); + throw new AsideProfileError("aside_profiles_unavailable", 409, "Aside profiles cannot be read"); + } + const rootStore = asideRootStore(input); + const record = rootStore.readRecords().aside; + const matched = record?.clientId === "aside" ? profiles.find(profile => profile.configPath === record.configPath) : undefined; + const pinned = input.config.asideProfileSync?.legacyProfileId; + const newest = !record && pinned === undefined ? rootStore.listOperations("aside", 1)[0] : undefined; + const legacyProfileId = pinned !== undefined ? pinned : record + ? matched?.id ?? null + : profiles.find(profile => profile.configPath === newest?.configPath)?.id ?? null; + let loaded: Promise | undefined; + return { + input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(), + defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched), + models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models), + }; +} + +export function selectAsideProfiles(ctx: AsideProfileContext, profileId?: number): AsideProfile[] { + if (profileId === undefined) return ctx.profiles; + if (!Number.isSafeInteger(profileId) || profileId < 0 || Object.is(profileId, -0)) { + throw new AsideProfileError("invalid_aside_profile", 400, "Aside profile must be a nonnegative safe integer"); + } + const profile = ctx.profiles.find(candidate => candidate.id === profileId); + if (!profile) throw new AsideProfileError("aside_profile_not_found", 404, "Aside profile is not registered"); + return [profile]; +} + +export function asideProfileEnabled(ctx: AsideProfileContext, id: number): boolean { + return ctx.input.config.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; +} + +export function asideProfileScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { + try { return resolveScope(ctx, profile); } + catch (error) { + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message); + throw error; + } +} + +function resolveScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { + const cached = ctx.scopes.get(profile.id); + if (cached) { cached.assertBoundary(); return cached; } + assertAsideProfileBoundary(profile, ctx.profiles); + const store = profile.id === ctx.legacyProfileId ? ctx.rootStore + : guardedStore(createIntegrationStateStore(join(ctx.rootStore.root, "aside-profiles", String(profile.id))), ctx.rootStore.root); + const assertBoundary = () => { + assertAsideProfileBoundary(profile, ctx.profiles); + const record = store.readRecords().aside; + if (record && (record.clientId !== "aside" || record.configPath !== profile.configPath)) { + throw new AsideProfileError("aside_profile_owner_mismatch", 409, "Aside ownership belongs to a different profile"); + } + }; + assertBoundary(); + const baseIO = ctx.input.io ?? store.io(); + const guarded = guardAsideProfileIO(profile, { + ...baseIO, + appendJournal: entry => store.appendJournal(entry), + putRecord: record => store.putRecord(record), + dropRecord: clientId => store.dropRecord(clientId), + }, ctx.profiles); + const io: IntegrationIO = { + ...guarded, + writeText: (path, text) => { assertBoundary(); guarded.writeText(path, text); }, + removeFile: path => { assertBoundary(); guarded.removeFile(path); }, + mkdirp: path => { assertBoundary(); guarded.mkdirp(path); }, + }; + const scope = { profile, store, io, assertBoundary }; + ctx.scopes.set(profile.id, scope); + return scope; +} + +export async function asideWriteInput(ctx: AsideProfileContext, scope: AsideProfileScope): Promise { + const models = await ctx.models(); + scope.assertBoundary(); + return { + clientId: "aside", config: ctx.input.config, models, port: ctx.input.port, + env: ctx.input.env, home: ctx.input.home, store: scope.store, io: scope.io, + resolvedPaths: { configPath: scope.profile.configPath, detectDir: scope.profile.detectDir }, + }; +} + +/** Save intent first; an unsuccessful save must not leave even in-memory intent changed. */ +export async function persistAsidePolicy(ctx: AsideProfileContext, change?: { enabled: boolean; profileId?: number }): Promise { + const { config, persistConfig } = ctx.input; + if (!persistConfig) throw new AsideProfileError("aside_profile_persistence_required", 500, "Aside profile changes require configuration persistence"); + const previous = config.asideProfileSync; + const policy: AsideProfilePolicy = { + ...previous, allProfiles: ctx.defaultEnabled, legacyProfileId: ctx.legacyProfileId, + profiles: { ...previous?.profiles }, + }; + if (change && change.profileId === undefined) { + policy.allProfiles = change.enabled; + policy.profiles = {}; + } else if (change) policy.profiles![String(change.profileId)] = change.enabled; + config.asideProfileSync = policy; + try { await persistConfig(config); } + catch { + if (previous === undefined) delete config.asideProfileSync; + else config.asideProfileSync = previous; + throw new AsideProfileError("aside_profile_persist_failed", 500, "Aside profile preferences could not be saved; no profile files were changed"); + } + ctx.defaultEnabled = policy.allProfiles!; +} + +export async function runAsideProfileAction( + input: AsideProfilesInput, + profileId: number | undefined, + semantics: string, + action: (ctx: AsideProfileContext, profiles: AsideProfile[]) => Promise, +): Promise { + const ctx = createAsideProfileContext(input); + const profiles = selectAsideProfiles(ctx, profileId); + const key = `aside:${fingerprint(`${ctx.rootStore.root}:${profiles.map(p => p.root).join(",")}`)}:${profiles.map(p => p.id).sort((a, b) => a - b).join(",")}:${semantics}:${crypto.randomUUID()}`; + try { + return await runIntegrationMutationFlight("aside", key, input.io?.now ?? Date.now, async () => { + // Publish the flight before invoking user-supplied persistence callbacks. + await Promise.resolve(); + return action(ctx, profiles); + }); + } catch (error) { + if (error instanceof IntegrationMutationBusyError) { + throw new AsideProfileError("integration_mutation_busy", 409, "An Aside profile operation is already running"); + } + if (error instanceof AsideProfileError) throw error; + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message); + throw new AsideProfileError("aside_profile_operation_failed", 500, "Aside profile operation could not be completed"); + } +} + +export function asideProfileFailure(profileId: number, error: unknown): AsideProfileWriteOutcome { + return { + clientId: "aside", profileId, ok: false, reason: "unsafe", state: "unsafe", + message: error instanceof AsideProfileError || error instanceof ClientPathError + ? error.message : "Aside profile could not be updated safely", + }; +} diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts new file mode 100644 index 0000000000..5c31024332 --- /dev/null +++ b/src/integrations/aside-profile-journal.ts @@ -0,0 +1,215 @@ +import { EXPORT_CLIENTS } from "../clients/config-export"; +import { loadTarget, parseConfig } from "./config-io"; +import { matchesOperationResult, type JournalEntry } from "./journal"; +import { fingerprint, type OwnershipRecord } from "./ownership"; +import { classifyIntegration, exportContextOf } from "./state"; +import type { IntegrationStateStore } from "./store"; +import { restoreIntegrationCoordinated, type IntegrationWriteInput } from "./writer"; +import { + AsideProfileError, asideProfileFailure, asideProfileScope, asideRootStore, asideWriteInput, assertAsideSnapshotEntry, + createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, + type AsideProfileContext, type AsideProfilesInput, type AsideProfileScope, type AsideProfileWriteOutcome, +} from "./aside-profile-context"; + +export interface AsideOperation { + profileId: number; + entry: JournalEntry; + store: IntegrationStateStore; +} + +function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] { + const rows: AsideOperation[] = []; + for (const profile of selectAsideProfiles(ctx, profileId)) { + const scope = asideProfileScope(ctx, profile); + const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore]; + for (const store of stores) { + for (const entry of store.listOperations("aside", Number.MAX_SAFE_INTEGER)) { + if (entry.clientId === "aside" && entry.configPath === profile.configPath) { + assertAsideSnapshotEntry(entry); + if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid"); + rows.push({ profileId: profile.id, entry, store }); + } + } + } + } + // A copied entry retains its original timestamp; import time cannot make it newest. + return rows.sort((a, b) => b.entry.at.localeCompare(a.entry.at)); +} + +function uniqueOperations(rows: AsideOperation[]): AsideOperation[] { + const seen = new Map(); + for (const row of rows) { + const previous = seen.get(row.entry.opId); + if (previous && (previous.profileId !== row.profileId || JSON.stringify(previous.entry) !== JSON.stringify(row.entry))) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation identifies multiple profiles"); + } + if (!previous) seen.set(row.entry.opId, row); + } + return [...seen.values()]; +} + +export function listAsideOperations(input: AsideProfilesInput, profileId?: number): AsideOperation[] { + try { return uniqueOperations(operationRows(createAsideProfileContext(input), profileId)); } + catch (error) { + if (profileId === undefined && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return []; + throw error; + } +} + +function findOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation | null { + if (typeof opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(opId)) { + throw new AsideProfileError("invalid_op_id", 400, "Aside operation ID is invalid"); + } + const rows = uniqueOperations(operationRows(ctx, profileId)); + const found = rows.find(row => row.entry.opId === opId); + if (found) return found; + const legacy = ctx.rootStore.findOperation(opId); + if (legacy?.clientId === "aside") { + if (!ctx.profiles.some(profile => profile.configPath === legacy.configPath)) { + throw new AsideProfileError("aside_profile_not_found", 404, "The operation's Aside profile is no longer registered"); + } + if (profileId !== undefined) throw new AsideProfileError("aside_operation_profile_mismatch", 409, "Aside operation belongs to a different profile"); + } + return null; +} + +export function findAsideOperation(input: AsideProfilesInput, opId: string, profileId?: number): AsideOperation | null { + const legacy = asideRootStore(input).findOperation(opId); + if (profileId === undefined && legacy && legacy.clientId !== "aside") return null; + try { return findOperation(createAsideProfileContext(input), opId, profileId); } + catch (error) { + if (profileId === undefined && !legacy && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return null; + throw error; + } +} + +/** Guarded history projection: never expose profile bytes to API serializers. */ +export function asideOperationMatchesCurrent(input: AsideProfilesInput, row: AsideOperation): boolean { + try { + const ctx = createAsideProfileContext(input); + const verified = findOperation(ctx, row.entry.opId, row.profileId); + if (!verified || verified.entry.configPath !== row.entry.configPath) return false; + const profile = selectAsideProfiles(ctx, row.profileId)[0]!; + const scope = asideProfileScope(ctx, profile); + const target = loadTarget(scope.io, profile.configPath); + return target.ok && scope.io.statKind(profile.detectDir) === "dir" && matchesOperationResult(verified.entry, target.before); + } catch { return false; } +} + +function requiredOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation { + const row = findOperation(ctx, opId, profileId); + if (!row) throw new AsideProfileError("integration_operation_not_found", 404, "Aside operation not found"); + return row; +} + +function validatePriorRecord(record: OwnershipRecord | null, configPath: string): void { + if (record === null) return; + if (!record || record.clientId !== "aside" || record.configPath !== configPath + || typeof record.fileFingerprint !== "string" || typeof record.blockFingerprint !== "string" + || typeof record.opId !== "string" || typeof record.appliedAt !== "string" + || !Array.isArray(record.fragmentPaths) || record.fragmentPaths.length !== 1 + || record.fragmentPaths[0]?.length !== 2 || record.fragmentPaths[0][0] !== "providers" + || record.fragmentPaths[0][1] !== "opencodex" + || (record.createdContainers !== undefined && (!Array.isArray(record.createdContainers) + || !record.createdContainers.every(path => path === "providers")))) { + throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation ownership metadata is invalid"); + } +} + +function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: IntegrationWriteInput): boolean { + const record = entry.priorRecord; + if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false; + const state = classifyIntegration({ + fileText: text, fileIsRegular: true, parsed: parseConfig(text, "json"), record, + contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)), + configPath: entry.configPath, clientId: "aside", + }).state; + return state === "current" || state === "stale"; +} + +/** Import only an immutable historical row and its bytes, never the legacy ownership record. */ +function importOperation(row: AsideOperation, scope: AsideProfileScope): void { + if (row.store.root === scope.store.root) return; + const existing = scope.store.findOperation(row.entry.opId); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(row.entry)) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); + } + return; + } + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind === "expired") throw new AsideProfileError("integration_snapshot_expired", 410, "That backup has expired"); + scope.io.statKind(scope.profile.detectDir); + scope.assertBoundary(); + if (snapshot.kind === "stored") { + const present = scope.store.readSnapshot(row.entry); + if (present.kind === "stored" && present.text !== snapshot.text) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside snapshot conflicts with existing profile history"); + } + if (present.kind !== "stored") scope.store.captureSnapshot("aside", row.entry.opId, snapshot.text); + } + scope.store.appendJournal(structuredClone(row.entry)); +} + +export function restoreAsideProfile( + input: AsideProfilesInput, + request: { opId: string; profileId?: number; confirmDrift?: boolean }, +): Promise { + return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { + const row = requiredOperation(ctx, request.opId, request.profileId); + const profile = selectAsideProfiles(ctx, row.profileId)[0]!; + const scope = asideProfileScope(ctx, profile); + assertAsideSnapshotEntry(row.entry); + validatePriorRecord(row.entry.priorRecord, profile.configPath); + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind === "expired") { + return { clientId: "aside", profileId: profile.id, ok: false, reason: "snapshot_expired", state: "absent", message: "That backup has expired" }; + } + const bound = await asideWriteInput(ctx, scope); + const target = loadTarget(scope.io, profile.configPath); + if (!target.ok || scope.io.statKind(profile.detectDir) !== "dir") { + return asideProfileFailure(profile.id, new AsideProfileError("aside_profile_unsafe", 409, "Aside profile cannot be restored safely")); + } + if (!request.confirmDrift && !matchesOperationResult(row.entry, target.before)) { + return { clientId: "aside", profileId: profile.id, ok: false, reason: "drift_requires_confirm", state: "conflict", message: "This profile changed after that operation; confirm to replace it" }; + } + const restoredText = snapshot.kind === "stored" ? snapshot.text : null; + await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) }); + try { + scope.assertBoundary(); + const currentSnapshot = row.store.readSnapshot(row.entry); + if (JSON.stringify(row.store.findOperation(row.entry.opId)) !== JSON.stringify(row.entry) + || currentSnapshot.kind !== snapshot.kind + || (currentSnapshot.kind === "stored" && currentSnapshot.text !== restoredText)) { + throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); + } + importOperation(row, scope); + return { ...await restoreIntegrationCoordinated({ ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, { lockSeams: input.lockSeams }), profileId: profile.id }; + } catch (error) { return asideProfileFailure(profile.id, error); } + }); +} + +export function deleteAsideOperation( + input: AsideProfilesInput, + request: { opId: string; profileId?: number; principal?: string }, +): Promise<{ ok: true; clientId: "aside"; profileId: number; opId: string; snapshotRemoved: boolean }> { + return runAsideProfileAction(input, request.profileId, `delete:${request.opId}`, async ctx => { + const row = requiredOperation(ctx, request.opId, request.profileId); + const rows = operationRows(ctx, row.profileId); + if (rows[0]?.entry.opId === request.opId) { + throw new AsideProfileError("integration_journal_newest_protected", 409, "The newest operation for an Aside profile cannot be deleted"); + } + await persistAsidePolicy(ctx); + const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store])); + const tombstone = { tombstone: request.opId, at: new Date().toISOString(), by: request.principal ?? "management" }; + // Retire every copy before pruning any snapshot; deduped history must not resurrect a source row. + for (const store of stores.values()) store.retireOperation(tombstone); + let snapshotRemoved = true; + for (const store of stores.values()) { + const pruned = store.pruneSnapshots("aside"); + if (pruned.ok) store.clearPruneFailure("aside"); + else { snapshotRemoved = false; store.markPruneFailure("aside", pruned.error); } + } + return { ok: true, clientId: "aside", profileId: row.profileId, opId: request.opId, snapshotRemoved }; + }); +} diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts new file mode 100644 index 0000000000..bdea5a5cde --- /dev/null +++ b/src/integrations/aside-profiles.ts @@ -0,0 +1,166 @@ +import type { AsideProfile } from "../clients/aside-profiles"; +import { asideHomeDir } from "../clients/config-export"; +import { join } from "node:path"; +import type { OwnedIntegrationRefreshOutcome } from "./owned-refresh"; +import { readIntegrationState, type IntegrationState, type IntegrationStatus } from "./state"; +import { + applyIntegrationCoordinated, disableIntegrationCoordinated, + overwriteIntegrationCoordinated, refreshIntegrationCoordinated, +} from "./writer"; +import { + asideProfileEnabled, asideProfileFailure, asideProfileScope, asideWriteInput, + asideRootStore, createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, + type AsideProfileContext, type AsideProfilesInput, type AsideProfileWriteOutcome, +} from "./aside-profile-context"; + +export { AsideProfileError } from "./aside-profile-context"; +export type { AsideProfilesInput, AsideProfileWriteOutcome } from "./aside-profile-context"; + +export interface AsideProfileState extends IntegrationStatus { + profileId: number; + name?: string; + current: boolean; + enabled: boolean; + error?: string; +} + +export interface AsideProfileList extends IntegrationStatus { + profiles: AsideProfileState[]; + allEnabled: boolean; + enabledCount: number; + appliedCount: number; + total: number; + error?: string; +} + +export interface AsideProfileMutationResult { + ok: boolean; + clientId: "aside"; + changed: boolean; + state: IntegrationState; + message: string; + results: AsideProfileWriteOutcome[]; + /** Preserve the ordinary refusal serializer for a single selected profile. */ + result?: AsideProfileWriteOutcome; +} + +function aggregateState(states: readonly IntegrationState[]): IntegrationState { + if (states.includes("unsafe")) return "unsafe"; + if (states.includes("conflict")) return "conflict"; + if (states.every(state => state === "absent")) return "absent"; + return states.every(state => state === "current") ? "current" : "stale"; +} + +async function profileState(ctx: AsideProfileContext, profile: AsideProfile): Promise { + const metadata = { + profileId: profile.id, ...(profile.name !== undefined ? { name: profile.name } : {}), + current: profile.current, enabled: asideProfileEnabled(ctx, profile.id), + }; + try { + const scope = asideProfileScope(ctx, profile); + const input = await asideWriteInput(ctx, scope); + return { ...readIntegrationState(input), ...metadata }; + } catch (error) { + return { + clientId: "aside", ...metadata, state: "unsafe", installed: false, + configPath: profile.configPath, reason: "unresolvable-path", snapshotCount: -1, + retentionDegraded: true, error: asideProfileFailure(profile.id, error).message, + }; + } +} + +export async function listAsideProfileStates(input: AsideProfilesInput): Promise { + let ctx: AsideProfileContext; + try { ctx = createAsideProfileContext(input); } + catch (error) { + return { + clientId: "aside", profiles: [], total: 0, enabledCount: 0, appliedCount: 0, allEnabled: false, + state: "unsafe", installed: false, configPath: join(asideHomeDir(input.env, input.home), "u"), + snapshotCount: -1, retentionDegraded: true, reason: "unresolvable-path", + error: asideProfileFailure(0, error).message, + }; + } + const profiles: AsideProfileState[] = []; + for (const profile of ctx.profiles) profiles.push(await profileState(ctx, profile)); + const enabledCount = profiles.filter(profile => profile.enabled).length; + const snapshotCount = profiles.some(profile => profile.snapshotCount < 0) ? -1 + : profiles.reduce((sum, profile) => sum + profile.snapshotCount, 0); + return { + clientId: "aside", profiles, total: profiles.length, enabledCount, + allEnabled: profiles.length > 0 && enabledCount === profiles.length, + appliedCount: profiles.filter(profile => profile.state === "current" || profile.state === "stale").length, + state: aggregateState(profiles.map(profile => profile.state)), + installed: profiles.some(profile => profile.installed), + configPath: profiles.find(profile => profile.current)?.configPath ?? profiles[0]?.configPath ?? "", + snapshotCount, retentionDegraded: profiles.some(profile => profile.retentionDegraded), + }; +} + +export async function getAsideProfileState(input: AsideProfilesInput, id: number): Promise { + const ctx = createAsideProfileContext(input); + return profileState(ctx, selectAsideProfiles(ctx, id)[0]!); +} + +export function mutateAsideProfiles( + input: AsideProfilesInput, + change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, +): Promise { + return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { + const refused = new Map(); + for (const profile of profiles) { + try { asideProfileScope(ctx, profile); } + catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + } + // This await precedes model loading, writer preflight, snapshots and all client writes. + await persistAsidePolicy(ctx, change); + const results: AsideProfileWriteOutcome[] = []; + for (const profile of profiles) { + const refusal = refused.get(profile.id); + if (refusal) { results.push(refusal); continue; } + try { + const scope = asideProfileScope(ctx, profile); + const bound = await asideWriteInput(ctx, scope); + const operation = !change.enabled ? disableIntegrationCoordinated + : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; + results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); + } catch (error) { results.push(asideProfileFailure(profile.id, error)); } + } + const ok = results.every(result => result.ok); + return { + ok, clientId: "aside", changed: results.some(result => result.ok && result.changed), + state: aggregateState(results.map(result => result.state)), + message: ok ? "Aside profile preferences applied" : "Aside preferences saved; some profiles could not be updated", + results, ...(results.length === 1 ? { result: results[0] } : {}), + }; + }); +} + +export function refreshAsideProfiles(input: AsideProfilesInput): Promise> { + const policy = input.config.asideProfileSync; + const selected = Object.values(policy?.profiles ?? {}).some(enabled => enabled === true); + if (!selected && (policy?.allProfiles === false + || (policy?.allProfiles !== true && !asideRootStore(input).readRecords().aside))) return Promise.resolve([]); + return runAsideProfileAction(input, undefined, "refresh", async (ctx, profiles) => { + const outcomes: Array = []; + for (const profile of profiles) { + if (!asideProfileEnabled(ctx, profile.id)) continue; + try { + const scope = asideProfileScope(ctx, profile); + const owned = scope.store.readRecords().aside !== undefined; + const bound = await asideWriteInput(ctx, scope); + // A surviving ownership record means a removed block stays removed. + // A newly discovered, enabled profile may receive its first safe apply. + const operation = owned ? refreshIntegrationCoordinated : applyIntegrationCoordinated; + const result = await operation(bound, { lockSeams: input.lockSeams }); + outcomes.push({ + client: "aside", profileId: profile.id, ok: result.ok, + ...(result.ok ? { changed: result.changed } : {}), + ...(!result.ok || result.state === "absent" ? { reason: result.message } : {}), + }); + } catch (error) { + outcomes.push({ client: "aside", profileId: profile.id, ok: false, reason: asideProfileFailure(profile.id, error).message }); + } + } + return outcomes; + }); +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 6ace895e00..0b83af9654 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -18,6 +18,11 @@ export async function refreshOwnedCatalogIntegrations( const outcomes: OwnedIntegrationRefreshOutcome[] = []; for (const clientId of clientIds) { try { + if (clientId === "aside") { + const { refreshAsideProfiles } = await import("./aside-profiles"); + outcomes.push(...await refreshAsideProfiles({ ...input, models: loadModels })); + continue; + } const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); if (result) outcomes.push(result); } catch (error) { diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts index 6167c1aa35..8d8f04581a 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -27,6 +27,8 @@ export interface OwnedIntegrationRefreshInput { home?: string; store?: IntegrationStateStore; io?: IntegrationIO; + /** Internal profile target selected before entering the coordinated writer. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export interface OwnedIntegrationRefreshOutcome { @@ -34,6 +36,7 @@ export interface OwnedIntegrationRefreshOutcome { readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; + readonly profileId?: number; } /** diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 71dd93a71d..008f46fbf1 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -352,6 +352,8 @@ export interface IntegrationStateInput { /** The whole integration state store, bound to one root. */ store?: IntegrationStateStore; io?: IntegrationIO; + /** Internal explicit profile target; never accepted as a caller-provided path. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export function exportContextOf(input: { @@ -431,7 +433,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS try { // One resolution for both, so a client whose paths come from mutable state // cannot report one account's install beside another account's config path. - const paths = resolveIntegrationPaths(input.clientId, input.env, input.home); + const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home); configPath = paths.configPath; installed = io.statKind(paths.detectDir) === "dir"; } catch (error) { diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 4aa0944c80..23b3eaaad4 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -711,7 +711,9 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration * its manifest, so two independent calls could verify one account's install * and then write another account's catalog if a switch landed between them. */ - const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home); + const resolvedPaths = input.resolvedPaths + ? { ...input.resolvedPaths } + : resolveIntegrationPaths(input.clientId, env, home); return { ...input, env, home, store, io, resolvedPaths }; } diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts new file mode 100644 index 0000000000..6e36bbe52b --- /dev/null +++ b/src/server/management/aside-profile-routes.ts @@ -0,0 +1,166 @@ +import { redactSecretString } from "../../lib/redact"; +import { ClientPathError } from "../../clients/config-export"; +import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; +import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; +import { + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, + type AsideProfilesInput, +} from "../../integrations/aside-profiles"; +import { + listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation, + asideOperationMatchesCurrent, +} from "../../integrations/aside-profile-journal"; +import type { WriteRefused } from "../../integrations/writer"; +import type { ManagementContext } from "./context"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { jsonResponse } from "../auth-cors"; + +export interface AsideProfileRouteOptions { + input: () => AsideProfilesInput; + failure: (result: WriteRefused) => Response; +} + +class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } + +function profileId(ctx: ManagementContext): number | undefined { + const raw = ctx.url.searchParams.get("profile"); + if (raw === null) return undefined; + if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) { + throw new ProfileQueryError("profile must be a nonnegative integer account ID"); + } + return Number(raw); +} + +function errorResponse(error: unknown, ctx: ManagementContext): Response { + rethrowManagementBodyTooLarge(error); + const detail = error as { status?: unknown; code?: unknown } | null; + const busy = error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError; + const status = busy ? 409 : typeof detail?.status === "number" && [400,404,409,410,500].includes(detail.status) + ? detail.status : error instanceof ClientPathError ? 409 : 500; + const code = busy ? "integration_mutation_busy" + : typeof detail?.code === "string" ? detail.code : "aside_profile_error"; + return jsonResponse({ + error: redactSecretString(error instanceof Error ? error.message : "Aside profile operation failed"), + code, clientId: "aside", + }, status, ctx.req, ctx.config); +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Own only Aside status/toggle paths; other clients keep the existing adapter. */ +export async function handleAsideProfileRoutes( + ctx: ManagementContext, options: AsideProfileRouteOptions, +): Promise { + const { req, url } = ctx; + if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; + if (req.method !== "GET" && req.method !== "PUT") return null; + try { + const id = profileId(ctx); + if (url.pathname.endsWith("/profiles")) { + if (req.method !== "GET" || id !== undefined) throw new ProfileQueryError("The profile collection supports GET without a profile selector"); + return jsonResponse(await listAsideProfileStates(options.input()), 200, req, ctx.config); + } + if (req.method === "GET") { + const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id); + return jsonResponse(state, 200, req, ctx.config); + } + let body: unknown; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } + if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); + if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); + if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); + const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }); + if (id !== undefined) { + const result = batch.results[0]; + if (!result) throw new Error("Aside profile mutation returned no result"); + return result.ok ? jsonResponse(result, 200, req, ctx.config) : options.failure(result); + } + return jsonResponse(batch, batch.ok ? 200 : 207, req, ctx.config); + } catch (error) { return errorResponse(error, ctx); } +} + +/** Profile-qualified history, including source-store provenance for imported legacy entries. */ +export async function asideJournalResponse( + ctx: ManagementContext, requestedClient: string | null, options: AsideProfileRouteOptions, +): Promise { + if (requestedClient === null && !ctx.url.searchParams.has("profile")) return null; + if (requestedClient !== null && requestedClient !== "aside") { + return ctx.url.searchParams.has("profile") ? errorResponse(new ProfileQueryError("profile applies only to Aside"), ctx) : null; + } + try { + const id = profileId(ctx); + if (id !== undefined && requestedClient !== "aside") throw new ProfileQueryError("profile requires client=aside"); + const input = options.input(); + const aside = await listAsideOperations(input, id); + const rows = [...aside].sort((a, b) => b.entry.at.localeCompare(a.entry.at)); + const newest = new Map(); + const ownerKey = (row: typeof rows[number]) => `${row.entry.clientId}:${row.profileId ?? row.entry.configPath}`; + for (const row of rows) if (!newest.has(ownerKey(row))) newest.set(ownerKey(row), row.entry.opId); + const operations = rows.map(row => { + const { entry, store } = row; + const snapshot = store.readSnapshot(entry).kind; + const latest = newest.get(ownerKey(row)) === entry.opId; + return { + opId: entry.opId, clientId: entry.clientId, kind: entry.kind, at: entry.at, + configPath: entry.configPath, snapshot, + ...(row.profileId !== undefined ? { profileId: row.profileId } : {}), + undoable: snapshot !== "expired" && latest && row.profileId !== undefined && asideOperationMatchesCurrent(input, row), + deletable: !latest, + }; + }); + return jsonResponse({ operations }, 200, ctx.req, ctx.config); + } catch (error) { + return requestedClient === null && !ctx.url.searchParams.has("profile") ? null : errorResponse(error, ctx); + } +} + +export async function asideRestoreResponse( + ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, +): Promise { + try { + const id = profileId(ctx); + const input = options.input(); + const rootEntry = input.store?.findOperation(body.opId); + if (rootEntry && rootEntry.clientId !== "aside") { + if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + return null; + } + const operation = await findAsideOperation(input, body.opId, id); + if (!operation) { + if (id === undefined) return null; + return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config); + } + const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId }); + return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); + } catch (error) { + if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") + && options.input().store?.findOperation(body.opId)?.clientId !== "aside") return null; + return errorResponse(error, ctx); + } +} + +export async function asideJournalDeleteResponse( + ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions, +): Promise { + try { + const id = profileId(ctx); + const input = options.input(); + const rootEntry = input.store?.findOperation(opId); + if (rootEntry && rootEntry.clientId !== "aside") { + if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + return null; + } + const operation = await findAsideOperation(input, opId, id); + if (!operation) { + if (id === undefined) return null; + return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config); + } + return jsonResponse(await deleteAsideOperation(input, { opId, profileId: operation.profileId, principal: ctx.principal ?? "admin-token" }), 200, ctx.req, ctx.config); + } catch (error) { + if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") + && options.input().store?.findOperation(opId)?.clientId !== "aside") return null; + return errorResponse(error, ctx); + } +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index cb226314ea..8103db0188 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -156,6 +156,7 @@ interface ClientIntegrationSyncOutcome { readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; + readonly profileId?: number; } /** diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 0d8a5c1e55..28b4c4cadd 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -9,6 +9,12 @@ * Design of record: devlog/_fin/260802_client_toggle_api/040_wp4_management_api.md. */ import { readFileSync } from "node:fs"; +import { saveConfigPreservingClaudeCode } from "../../config"; +import { listAsideProfileStates, type AsideProfilesInput } from "../../integrations/aside-profiles"; +import { + handleAsideProfileRoutes, asideJournalResponse, asideRestoreResponse, + asideJournalDeleteResponse, type AsideProfileRouteOptions, +} from "./aside-profile-routes"; import type { IntegrationIO } from "../../integrations/config-io"; import { matchesOperationResult } from "../../integrations/journal"; import { @@ -85,6 +91,7 @@ export interface IntegrationJournalRow { * is what a user reaches for right after the mistake. */ deletable: boolean; + profileId?: number; } export interface IntegrationToggleBody { @@ -181,6 +188,23 @@ function integrationStore(): IntegrationStateStore { return integrationMutationTestHooks?.store ?? createIntegrationStateStore(); } +function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions { + let input: AsideProfilesInput | undefined; + return { + input: () => input ??= { + config: ctx.config, + port: Number(ctx.url.port) || ctx.config.port, + models: () => loadExportModels(ctx.config), + store: integrationStore(), + ...pathOverrides(), + io: integrationMutationTestHooks?.io, + lockSeams: integrationMutationTestHooks?.lockSeams, + persistConfig: ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode, + }, + failure: result => writerFailureResponse("aside", result, ctx), + }; +} + async function buildIntegrationWriteInput( clientId: IntegrationClientId, ctx: ManagementContext, @@ -331,6 +355,8 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { code: "invalid_op_id", }, 400, req, ctx.config); } + const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx)); + if (aside) return aside; try { const store = integrationStore(); const operation = store.findOperation(opId); @@ -392,6 +418,15 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url } = ctx; + const profileOptions = asideOptions(ctx); + const aside = await handleAsideProfileRoutes(ctx, profileOptions); + if (aside) return aside; + if (url.searchParams.has("profile") + && (url.pathname === "/api/client-integrations" || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX)) + && url.pathname !== "/api/client-integrations/journal" + && url.pathname !== "/api/client-integrations/restore") { + return jsonResponse({ error: "profile applies only to Aside", code: "invalid_aside_profile" }, 400, req, ctx.config); + } if (url.pathname === "/api/client-integrations" && req.method === "GET") { try { @@ -408,8 +443,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise - readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() })); + const clients = await Promise.all(INTEGRATION_CLIENT_IDS.map(async clientId => { + if (clientId === "aside") { + try { return await listAsideProfileStates({ ...profileOptions.input(), models }); } + catch { /* Existing status projection retains a safe unresolved-path diagnostic. */ } + } + return readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() }); + })); return jsonResponse({ clients } satisfies IntegrationStateListEnvelope, 200, req, ctx.config); } catch (error) { return internalErrorResponse(error, ctx); @@ -426,6 +466,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + let operations: IntegrationJournalRow[] = storedOperations.map(operation => { /* * Resolved against the DISK, not read off the row. * @@ -481,6 +523,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise row.clientId !== "aside"), ...body.operations] + .sort((a, b) => b.at.localeCompare(a.at)); + } + } return jsonResponse({ operations } satisfies IntegrationJournalEnvelope, 200, req, ctx.config); } catch (error) { return internalErrorResponse(error, ctx); @@ -506,6 +556,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise; + /** Stable provenance for the one legacy root ownership record, or no root owner. */ + legacyProfileId?: number | null; + }; /** * Up to 5 Codex-facing catalog ids to feature first. Values may be bare catalog ids, * exact account-qualified "/" ids, or routed diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 8b5086b32f..00dcdd82d8 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -148,3 +148,16 @@ fingerprint-only tests are supplementary; they cannot prove the status and write ## Remote connection lifecycle Remote clients journal and restore native integrations locally while model traffic travels directly to the hub. Catalog writes occur only after protocol negotiation and full remote schema validation. The management relay is launcher-scoped and fixed to the connection's management origin. Claude/Codex launch behavior remains integration-scoped. Key rotation uses `pendingOperation` plus `.prev`; disconnect restores locally without hub-side revocation or usage mirroring. + +## Aside profile ownership + +Aside discovery projects only registered numeric account IDs, labels and current status. Catalog +paths derive from the configured root/u/id, never from browser profilePath. Guarded filesystem +identity and IO apply to status and writes; internal resolved path pairs survive async freezing. + +`asideProfileSync` owns desired all-profile defaults and per-profile overrides. The legacy +connection defaults all profiles on; explicit per-profile changes materialize that default and +pin one legacy root owner before changing it. Sibling stores remain independent. Policy saves +precede coordinated writes under one scoped flight, and actual file state/refusals remain +separate. Restore reconciles target intent from validated snapshot ownership without changing +sibling policy. Profile journal views retain source-store provenance for older legacy entries. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index a494c8b2e1..d0eff35077 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -920,3 +920,42 @@ describe("#2566 per-account quota in ocx account list", () => { expect(formatAccountTable([row({ quotaUnavailable: true })] as never, true)).toContain("unavailable"); }); }); + +describe("Aside profile integration CLI", () => { + test("scopes status, toggle, history and restore without changing other client routes", async () => { + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(["status", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["disable", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["history", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["restore", "--client", "aside", "--profile", "2", "--op", "op-profile", "--json"], runtime.deps)).toBe(0); + expect(runtime.requests.map(row => row.path)).toEqual([ + "/api/client-integrations/aside?profile=2", + "/api/client-integrations/aside?profile=2", + "/api/client-integrations/journal?client=aside&profile=2", + "/api/client-integrations/restore?client=aside&profile=2", + ]); + expect(runtime.requests[1]!.body).toEqual({ enabled: false }); + expect(runtime.requests[3]!.body).toEqual({ opId: "op-profile", confirmDrift: false }); + }); + + test.each([ + ["enable", "--client", "pi", "--profile", "0"], + ["status", "--profile", "0"], + ["enable", "--client", "aside", "--profile", "../0"], + ["disable", "--client", "aside", "--profile", "01"], + ["restore", "--client", "aside", "--op", "op-profile"], + ].map(args => ({ args })))("rejects unsupported or ambiguous profile selectors before a request: $args", async ({ args }) => { + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(args, runtime.deps)).toBe(2); + expect(runtime.requests).toHaveLength(0); + }); + + test("unqualified Aside enable remains bulk and reports partial failure as nonzero", async () => { + const runtime = fakeRuntime(() => ({ + ok: false, clientId: "aside", message: "one profile refused", + results: [{ profileId: 0, ok: true, message: "updated" }, { profileId: 1, ok: false, message: "conflict" }], + })); + expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--json"], runtime.deps)).toBe(1); + expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside", method: "PUT", body: { enabled: true } }); + }); +}); diff --git a/tests/clients/aside-profile-paths.test.ts b/tests/clients/aside-profile-paths.test.ts new file mode 100644 index 0000000000..2856189f34 --- /dev/null +++ b/tests/clients/aside-profile-paths.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test } from "bun:test"; +import { + existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, + rmSync, symlinkSync, writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, +} from "../../src/clients/aside-profiles"; +import { ClientPathError } from "../../src/clients/config-export"; +import { defaultIntegrationIO } from "../../src/integrations/config-io"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accounts = [{ id: 0, name: "Cloud" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }]; + +function fixture(run: (home: string, root: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-aside-paths-")); + const root = join(home, ".aside"); + try { + for (const { id } of accounts) mkdirSync(join(root, "u", String(id)), { recursive: true }); + manifest(root, { currentAccountId: 0, accounts }); + run(home, root); + } finally { removeTreeWithRetry(home); } +} + +function manifest(root: string, value: unknown): void { + writeFileSync(join(root, "accounts.json"), JSON.stringify(value)); +} + +function ioFor(home: string) { + const store = createIntegrationStateStore(join(home, "integration-store")); + return { store, io: defaultIntegrationIO(store) }; +} + +function directoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir"); +} + +describe("Aside profile manifest", () => { + test("projects only safe metadata for cloud and local accounts", () => fixture((home, root) => { + manifest(root, { + currentAccountId: 1, + accounts: accounts.map(account => ({ + ...account, session: { token: "fixture-private-value" }, email: "fixture@example.invalid", userId: "private-user", + })), + profileAccountBindings: [{ accountId: 0, profilePath: join(home, "not-a-target") }], + }); + const profiles = listAsideProfiles({}, home); + expect(profiles).toEqual(accounts.map(account => ({ + ...account, current: account.id === 1, root, + detectDir: join(root, "u", String(account.id)), + configPath: join(root, "u", String(account.id), "models.json"), + }))); + expect(JSON.stringify(profiles)).not.toMatch(/session|token|email|userId|private-value|profilePath/); + })); + + test("supports currentAccountId-only legacy manifests without guessing zero", () => fixture((home, root) => { + manifest(root, { currentAccountId: 2 }); + expect(listAsideProfiles({}, home)).toEqual([{ + id: 2, current: true, root, detectDir: join(root, "u", "2"), configPath: join(root, "u", "2", "models.json"), + }]); + })); + + test("refuses a missing or malformed manifest with safe errors", () => fixture((home, root) => { + rmSync(join(root, "accounts.json")); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + writeFileSync(join(root, "accounts.json"), '{"session":"fixture-private-value",broken'); + try { listAsideProfiles({}, home); throw new Error("expected refusal"); } catch (error) { + expect(error).toBeInstanceOf(ClientPathError); + expect((error as Error).message).not.toContain("fixture-private-value"); + } + })); + + test("rejects malformed identities, duplicates and inconsistent current metadata", () => fixture((home, root) => { + const invalid = [ + null, [], {}, { currentAccountId: "0" }, { currentAccountId: -1 }, + { currentAccountId: 0, accounts: null }, { currentAccountId: 0, accounts: [] }, + { currentAccountId: 0, accounts: [{ id: 0 }, { id: 0 }] }, + { currentAccountId: 3, accounts }, + { currentAccountId: 0, accounts: [{ id: 0, current: false }] }, + { currentAccountId: 0, accounts: [{ id: 0 }, { id: 1, current: true }] }, + ...["1", "../2", -1, 0.5, Number.MAX_SAFE_INTEGER + 1, null].map(id => ({ + currentAccountId: 0, accounts: [{ id: 0 }, { id }], + })), + ]; + for (const value of invalid) { + manifest(root, value); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + } + writeFileSync(join(root, "accounts.json"), '{"currentAccountId":-0}'); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); + + test("accepts safe integer IDs and exactly 128 accounts, but never truncates overflow", () => fixture((home, root) => { + manifest(root, { currentAccountId: Number.MAX_SAFE_INTEGER }); + expect(listAsideProfiles({}, home)[0]!.id).toBe(Number.MAX_SAFE_INTEGER); + const bounded = Array.from({ length: 128 }, (_, id) => ({ id })); + manifest(root, { currentAccountId: 0, accounts: bounded }); + expect(listAsideProfiles({}, home)).toHaveLength(128); + manifest(root, { currentAccountId: 0, accounts: [...bounded, { id: 128 }] }); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); +}); + +describe("Aside profile filesystem boundary", () => { + test("missing account directories report not installed and cannot be recreated", () => fixture((home, root) => { + rmSync(join(root, "u", "1"), { recursive: true }); + const profiles = listAsideProfiles({}, home); + const profile = profiles[1]!; + assertAsideProfileBoundary(profile, profiles); + const guarded = guardAsideProfileIO(profile, ioFor(home).io, profiles); + expect(guarded.statKind(profile.detectDir)).toBe("missing"); + expect(guarded.readText(profile.configPath)).toEqual({ kind: "missing" }); + expect(() => assertAsideProfileBoundary(profile, profiles, true)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(profile.detectDir)).toThrow(ClientPathError); + expect(() => guarded.writeText(profile.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.removeFile(profile.configPath)).toThrow(ClientPathError); + expect(existsSync(profile.detectDir)).toBe(false); + })); + + test("allows an OS alias before the configured root", () => fixture((home, root) => { + const alias = join(home, "home-alias"); + const actual = join(home, "actual-home"); + mkdirSync(actual); + renameSync(root, join(actual, ".aside")); + directoryLink(actual, alias); + const profiles = listAsideProfiles({}, alias); + const selected = profiles[0]!; + assertAsideProfileBoundary(selected, profiles, true); + guardAsideProfileIO(selected, ioFor(home).io, profiles).writeText(selected.configPath, "{}"); + expect(readFileSync(join(actual, ".aside", "u", "0", "models.json"), "utf8")).toBe("{}"); + })); + + for (const component of ["root", "u", "account"] as const) { + test(`rejects a linked ${component} directory`, () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const path = component === "root" ? root : component === "u" ? join(root, "u") : selected.detectDir; + const moved = join(home, `moved-${component}`); + renameSync(path, moved); + directoryLink(moved, path); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + })); + } + + test("rejects leaf links, including dangling links", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const target = join(root, "u", "1", "models.json"); + symlinkSync(target, selected.configPath, "file"); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[1]!, profiles)).toThrow(ClientPathError); + writeFileSync(target, "{}"); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[1]!, profiles)).toThrow(ClientPathError); + })); + + test("detects sibling directory aliases even when the selected path is safe", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + rmSync(profiles[1]!.detectDir, { recursive: true }); + directoryLink(profiles[0]!.detectDir, profiles[1]!.detectDir); + expect(() => assertAsideProfileBoundary(profiles[0]!, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[0]!)).toThrow(ClientPathError); + expect(existsSync(join(root, "u", "0", "models.json"))).toBe(false); + })); + + test("rejects shared leaf inodes independently of ownership stores", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const a = profiles[0]!; + const b = profiles[1]!; + writeFileSync(a.configPath, "{}"); + linkSync(a.configPath, b.configPath); + expect(() => assertAsideProfileBoundary(a, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(b, [b])).toThrow(ClientPathError); + })); + + test("does not permit caller-supplied or sibling IO paths", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + expect(() => assertAsideProfileBoundary({ ...selected, configPath: profiles[1]!.configPath }, profiles)) + .toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(selected, profiles.slice(1))).toThrow(ClientPathError); + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + expect(() => guarded.writeText(profiles[1]!.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.readText(join(selected.detectDir, "settings.json"))).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.root)).toThrow(ClientPathError); + })); + + for (const component of ["root", "u", "account"] as const) { + test(`rejects a ${component} inode replacement after guard capture`, () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + const path = component === "root" ? root : component === "u" ? join(root, "u") : selected.detectDir; + renameSync(path, join(home, `old-${component}`)); + mkdirSync(selected.detectDir, { recursive: true }); + expect(() => guarded.statKind(selected.detectDir)).toThrow(ClientPathError); + expect(() => guarded.readText(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.detectDir)).toThrow(ClientPathError); + expect(() => guarded.writeText(selected.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(existsSync(selected.configPath)).toBe(false); + })); + } + + test("rechecks leaf collisions immediately before all config mutations", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const sibling = profiles[1]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + writeFileSync(selected.configPath, "original"); + symlinkSync(selected.configPath, sibling.configPath, "file"); + expect(() => guarded.writeText(selected.configPath, "changed")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.detectDir)).toThrow(ClientPathError); + expect(readFileSync(selected.configPath, "utf8")).toBe("original"); + })); + + test("rejects a selected leaf replaced by a link after guard capture", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + writeFileSync(profiles[1]!.configPath, "sibling"); + symlinkSync(profiles[1]!.configPath, selected.configPath, "file"); + expect(() => guarded.readText(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.writeText(selected.configPath, "changed")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(readFileSync(profiles[1]!.configPath, "utf8")).toBe("sibling"); + })); + + test("pins the chosen account across current-account switches and preserves bound IO", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[1]!; + const { store, io } = ioFor(home); + const receiverIO = { + ...io, + now() { expect(this).toBe(receiverIO); return 123; }, + writeText(path: string, text: string) { expect(this).toBe(receiverIO); io.writeText(path, text); }, + }; + const guarded = guardAsideProfileIO(selected, receiverIO, profiles); + manifest(root, { currentAccountId: 2, accounts }); + expect(listAsideProfiles({}, home)[2]!.current).toBe(true); + guarded.mkdirp(selected.detectDir); + guarded.writeText(selected.configPath, "first"); + guarded.writeText(selected.configPath, "second"); + expect(guarded.now()).toBe(123); + expect(readFileSync(selected.configPath, "utf8")).toBe("second"); + expect(existsSync(profiles[2]!.configPath)).toBe(false); + guarded.appendJournal({ + opId: "fixture-op", clientId: "aside", kind: "apply", at: new Date(123).toISOString(), + configPath: selected.configPath, snapshot: { kind: "none" }, resultFingerprint: "fixture-hash", + resultAbsent: false, priorRecord: null, + }); + expect(store.listOperations()).toHaveLength(1); + guarded.putRecord({ + clientId: "aside", configPath: selected.configPath, fileFingerprint: "fixture-file", + blockFingerprint: "fixture-block", fragmentPaths: [], appliedAt: new Date(123).toISOString(), opId: "fixture-op", + }); + expect(store.readRecords().aside?.configPath).toBe(selected.configPath); + guarded.dropRecord("aside"); + expect(store.readRecords().aside).toBeUndefined(); + guarded.removeFile(selected.configPath); + expect(existsSync(selected.configPath)).toBe(false); + })); +}); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts new file mode 100644 index 0000000000..11f6303a02 --- /dev/null +++ b/tests/clients/aside-profiles.test.ts @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { AsideProfileError, type AsideProfilesInput } from "../../src/integrations/aside-profile-context"; +import { getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles } from "../../src/integrations/aside-profiles"; +import { asideOperationMatchesCurrent, deleteAsideOperation, findAsideOperation, listAsideOperations, restoreAsideProfile } from "../../src/integrations/aside-profile-journal"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("Aside profile desired state, ownership and history", () => { + const models: ExportModel[] = [ + { namespaced: "mock/alpha", provider: "mock", id: "alpha", contextWindow: 128_000 }, + { namespaced: "mock/beta", provider: "mock", id: "beta", contextWindow: 64_000 }, + ]; + const original = JSON.stringify({ theme: "dark", providers: { personal: { models: [{ id: "mine" }] } } }); + let root: string; + let home: string; + let store: IntegrationStateStore; + let config: OcxConfig; + let saved: OcxConfig | undefined; + let saves: number; + + function manifest(currentAccountId = 0, ids = [0, 1, 2]): void { + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId, accounts: ids.map(id => ({ id, name: `Profile ${id}` })), + })); + } + function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } + function input(extra: Partial = {}): AsideProfilesInput { + return { config, models, port: 10100, env: {}, home, store, + persistConfig: next => { saved = structuredClone(next); saves += 1; }, ...extra }; + } + function reload(): void { expect(saved).toBeDefined(); config = structuredClone(saved!); } + function seedLegacy(id = 0): string { + manifest(id); + const result = applyIntegration({ ...input(), models, clientId: "aside" }); + expect(result.ok).toBe(true); + manifest(); + return store.readRecords().aside!.opId; + } + function modelIds(id: number): string[] { + const doc = JSON.parse(readFileSync(path(id), "utf8")) as { providers?: { opencodex?: { models: Array<{ id: string }> } } }; + return doc.providers?.opencodex?.models.map(model => model.id) ?? []; + } + function bytes(): string[] { return [0, 1, 2].map(id => readFileSync(path(id), "utf8")); } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-profiles-")); + home = join(root, "home"); + for (const id of [0, 1, 2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(path(id), original); + } + manifest(); + store = createIntegrationStateStore(join(root, "state", "integrations")); + config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } } } as OcxConfig; + saved = undefined; + saves = 0; + }); + afterEach(() => removeTreeWithRetry(root)); + + test.each([false, true])("implicit sync stays quiet for unconfigured or disabled Aside (legacy=%s)", async legacy => { + if (legacy) { + seedLegacy(); + config.asideProfileSync = { allProfiles: false }; + } + removeTreeWithRetry(join(home, ".aside")); + let loads = 0; + expect(await refreshAsideProfiles(input({ models: async () => { loads += 1; return models; } }))).toEqual([]); + expect(loads).toBe(0); + expect(saves).toBe(0); + }); + + test("legacy connection defaults all profiles on and refresh shares one catalog load", async () => { + seedLegacy(); + expect((await listAsideProfileStates(input())).enabledCount).toBe(3); + let loads = 0; + const results = await refreshAsideProfiles(input({ models: async () => { loads += 1; return models.slice(0, 1); } })); + expect(results.map(result => [result.profileId, result.ok])).toEqual([[0, true], [1, true], [2, true]]); + expect(loads).toBe(1); + for (const id of [0, 1, 2]) expect(modelIds(id)).toEqual(["mock/alpha"]); + expect(store.readRecords().aside?.configPath).toBe(path(0)); + for (const id of [1, 2]) { + expect(createIntegrationStateStore(join(store.root, "aside-profiles", String(id))).readRecords().aside?.configPath).toBe(path(id)); + expect(JSON.parse(readFileSync(path(id), "utf8"))).toMatchObject(JSON.parse(original)); + } + expect(saves).toBe(0); + }); + + test("disabling legacy profile 0 pins its root and preserves sibling intent after reload", async () => { + seedLegacy(); + expect((await mutateAsideProfiles(input(), { profileId: 0, enabled: false })).ok).toBe(true); + expect(saved?.asideProfileSync).toEqual({ allProfiles: true, profiles: { "0": false }, legacyProfileId: 0 }); + reload(); + const results = await refreshAsideProfiles(input()); + expect(results.map(row => row.profileId)).toEqual([1, 2]); + expect(modelIds(0)).toEqual([]); + expect(modelIds(1)).toEqual(["mock/alpha", "mock/beta"]); + expect(modelIds(2)).toEqual(["mock/alpha", "mock/beta"]); + expect(store.readRecords().aside).toBeUndefined(); + expect((await getAsideProfileState(input(), 0)).enabled).toBe(false); + }); + + test("a disconnected single-profile enable leaves other profiles off", async () => { + expect((await mutateAsideProfiles(input(), { profileId: 1, enabled: true })).ok).toBe(true); + reload(); + expect(config.asideProfileSync).toEqual({ allProfiles: false, profiles: { "1": true }, legacyProfileId: null }); + expect((await refreshAsideProfiles(input())).map(row => row.profileId)).toEqual([1]); + expect(bytes()[0]).toBe(original); + expect(bytes()[2]).toBe(original); + expect((await listAsideProfileStates(input())).enabledCount).toBe(1); + }); + + test("a disconnected implicit refresh loads no catalog and creates no ownership store", async () => { + let loads = 0; + expect(await refreshAsideProfiles(input({ models: async () => { loads += 1; return models; } }))).toEqual([]); + expect(loads).toBe(0); + expect(bytes()).toEqual([original, original, original]); + expect(existsSync(store.root)).toBe(false); + }); + + test("bulk intent clears overrides without conflating desired and actual outcomes", async () => { + await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + await mutateAsideProfiles(input(), { enabled: false }); + expect(saved?.asideProfileSync).toEqual({ allProfiles: false, profiles: {}, legacyProfileId: null }); + expect((await listAsideProfileStates(input())).enabledCount).toBe(0); + expect((await mutateAsideProfiles(input(), { enabled: true })).results).toHaveLength(3); + expect((await listAsideProfileStates(input())).appliedCount).toBe(3); + }); + + test("save failure restores the original in-memory policy before any model or file work", async () => { + seedLegacy(); + const before = bytes(); + const records = store.readRecords(); + const operations = store.listOperations(); + const previous = config.asideProfileSync; + let loads = 0; + await expect(mutateAsideProfiles(input({ + persistConfig: () => { throw new Error("synthetic save failure"); }, + models: async () => { loads += 1; return models; }, + }), { enabled: false })).rejects.toMatchObject({ code: "aside_profile_persist_failed", status: 500 }); + expect(config.asideProfileSync).toBe(previous); + expect(loads).toBe(0); + expect(bytes()).toEqual(before); + expect(store.readRecords()).toEqual(records); + expect(store.listOperations()).toEqual(operations); + expect(existsSync(join(store.root, "aside-profiles"))).toBe(false); + }); + + test("foreign and malformed profiles refuse independently after desired policy is saved", async () => { + const foreign = JSON.stringify({ providers: { opencodex: { models: [{ id: "manual" }] } } }); + writeFileSync(path(1), foreign); + writeFileSync(path(2), "{broken"); + const result = await mutateAsideProfiles(input(), { enabled: true }); + expect(result.ok).toBe(false); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, false]]); + expect(saved?.asideProfileSync?.allProfiles).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(foreign); + expect(readFileSync(path(2), "utf8")).toBe("{broken"); + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + }); + + test("implicit refresh preserves removed owned blocks and foreign edits", async () => { + await mutateAsideProfiles(input(), { enabled: true }); + writeFileSync(path(1), original); + const drifted = readFileSync(path(2), "utf8").replace("http://127.0.0.1:10100/v1", "http://user.invalid/v1"); + writeFileSync(path(2), drifted); + const result = await refreshAsideProfiles(input({ models: models.slice(0, 1) })); + expect(result[0]).toMatchObject({ profileId: 0, ok: true, changed: true }); + expect(result[1]).toMatchObject({ profileId: 1, ok: true, changed: false }); + expect(result[2]).toMatchObject({ profileId: 2, ok: false }); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(drifted); + }); + + test("one profile IO failure does not suppress later writes", async () => { + const io = store.io(); + const result = await mutateAsideProfiles(input({ io: { ...io, writeText: (target, text) => { + if (target === path(1)) throw new Error("synthetic profile write failure"); + io.writeText(target, text); + } } }), { enabled: true }); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, true]]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(modelIds(2)).toEqual(["mock/alpha", "mock/beta"]); + expect(saved?.asideProfileSync?.allProfiles).toBe(true); + }); + + test("missing account directories and aliased child stores are not created or adopted", async () => { + removeTreeWithRetry(join(home, ".aside", "u", "2")); + const children = join(store.root, "aside-profiles"); + mkdirSync(children, { recursive: true }); + const external = join(root, "external-store"); + mkdirSync(external); + symlinkSync(external, join(children, "1"), process.platform === "win32" ? "junction" : "dir"); + const result = await mutateAsideProfiles(input(), { enabled: true }); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, false]]); + expect(existsSync(join(external, "records.json"))).toBe(false); + expect(existsSync(join(home, ".aside", "u", "2"))).toBe(false); + }); + + test("an account switch during persistence does not retarget a selected write", async () => { + const result = await mutateAsideProfiles(input({ persistConfig: next => { + saved = structuredClone(next); manifest(2); + } }), { profileId: 1, enabled: true }); + expect(result.ok).toBe(true); + expect(modelIds(1)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(0), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(original); + }); + + test("enable then Undo stays off through reload and sync", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const enabledResult = enabled.results[0]!; + if (!enabledResult.ok) throw new Error("fixture enable failed"); + const opId = enabledResult.opId!; + const row = findAsideOperation(input(), opId, 1)!; + expect(asideOperationMatchesCurrent(input(), row)).toBe(true); + expect((await restoreAsideProfile(input(), { opId, profileId: 1 })).ok).toBe(true); + reload(); + expect(await refreshAsideProfiles(input())).toEqual([]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); + }); + + test("disable then Undo restores target intent without changing sibling overrides", async () => { + seedLegacy(); + await mutateAsideProfiles(input(), { profileId: 2, enabled: false }); + const disabled = await mutateAsideProfiles(input(), { profileId: 0, enabled: false }); + const result = disabled.results[0]!; + expect(result.ok).toBe(true); + if (!result.ok) return; + expect((await restoreAsideProfile(input(), { opId: result.opId!, profileId: 0 })).ok).toBe(true); + reload(); + expect(config.asideProfileSync).toEqual({ allProfiles: true, legacyProfileId: 0, profiles: { "0": true, "2": false } }); + expect((await refreshAsideProfiles(input())).map(row => row.profileId)).toEqual([0, 1]); + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(2), "utf8")).toBe(original); + }); + + test("Undo of explicit overwrite restores a foreign block and leaves its profile off", async () => { + const foreign = JSON.stringify({ providers: { opencodex: { models: [{ id: "user-owned" }] } } }); + writeFileSync(path(1), foreign); + const overwritten = await mutateAsideProfiles(input(), { profileId: 1, enabled: true, overwriteConflict: true }); + const result = overwritten.results[0]!; + if (!result.ok) throw new Error("fixture overwrite failed"); + expect((await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1 })).ok).toBe(true); + reload(); + expect(await refreshAsideProfiles(input())).toEqual([]); + expect(readFileSync(path(1), "utf8")).toBe(foreign); + expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); + }); + + test("restore preflight refuses drift and expired snapshots before saving intent", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const result = enabled.results[0]!; + if (!result.ok) throw new Error("fixture enable failed"); + const row = findAsideOperation(input(), result.opId!, 1)!; + writeFileSync(path(1), original); + const beforeSaves = saves; + expect(asideOperationMatchesCurrent(input(), row)).toBe(false); + expect(await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1 })) + .toMatchObject({ ok: false, reason: "drift_requires_confirm" }); + expect(saves).toBe(beforeSaves); + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + removeTreeWithRetry(snapshot.path); + expect(await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1, confirmDrift: true })) + .toMatchObject({ ok: false, reason: "snapshot_expired" }); + expect(saves).toBe(beforeSaves); + }); + + test("mixed legacy history imports a sibling snapshot without clobbering the root owner", async () => { + const siblingOp = seedLegacy(1); + const ownerOp = seedLegacy(0); + const owner = store.readRecords().aside; + expect(listAsideOperations(input(), 1).map(row => row.entry.opId)).toEqual([siblingOp]); + expect(listAsideOperations(input(), 0).map(row => row.entry.opId)).toEqual([ownerOp]); + expect((await restoreAsideProfile(input(), { opId: siblingOp })).ok).toBe(true); + expect(store.readRecords().aside).toEqual(owner); + expect(store.findOperation(siblingOp)).not.toBeNull(); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + expect(child.findOperation(siblingOp)).toEqual(store.findOperation(siblingOp)); + expect(listAsideOperations(input(), 1).filter(row => row.entry.opId === siblingOp)).toHaveLength(1); + const latest = listAsideOperations(input(), 1)[0]!; + await expect(deleteAsideOperation(input(), { opId: latest.entry.opId, profileId: 1 })) + .rejects.toMatchObject({ code: "integration_journal_newest_protected", status: 409 }); + expect((await deleteAsideOperation(input(), { opId: siblingOp, profileId: 1 })).ok).toBe(true); + expect(child.findOperation(siblingOp)).toBeNull(); + expect(store.findOperation(siblingOp)).toBeNull(); + expect(store.readRecords().aside).toEqual(owner); + }); + + test("unknown profile selectors and unregistered historical targets are not retargeted", async () => { + await expect(getAsideProfileState(input(), 9)).rejects.toMatchObject({ code: "aside_profile_not_found", status: 404 }); + await expect(mutateAsideProfiles(input(), { profileId: -1, enabled: true })).rejects.toBeInstanceOf(AsideProfileError); + const opId = seedLegacy(1); + manifest(0, [0, 2]); + expect(() => findAsideOperation(input(), opId)).toThrow("no longer registered"); + expect(saves).toBe(0); + }); + + test("default status is safe when discovery fails and non-Aside history stays available", async () => { + const opId = seedLegacy(); + const aside = store.findOperation(opId)!; + store.appendJournal({ ...aside, opId: "mcode-history", clientId: "mcode" }); + writeFileSync(join(home, ".aside", "accounts.json"), "{invalid"); + const state = await listAsideProfileStates(input()); + expect(state).toMatchObject({ clientId: "aside", profiles: [], installed: false, state: "unsafe", total: 0 }); + expect(state.error).toBeDefined(); + expect(findAsideOperation(input(), "mcode-history")).toBeNull(); + expect(listAsideOperations(input())).toEqual([]); + await expect(mutateAsideProfiles(input(), { profileId: 0, enabled: true })) + .rejects.toMatchObject({ code: "aside_profiles_unavailable", status: 409 }); + expect(saves).toBe(0); + }); + + test("restore save failure leaves history, bytes and existing policy unchanged", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const result = enabled.results[0]!; + if (!result.ok) throw new Error("fixture enable failed"); + const previous = config.asideProfileSync; + const before = bytes(); + const rows = listAsideOperations(input(), 1).map(row => row.entry); + await expect(restoreAsideProfile(input({ persistConfig: async () => { throw new Error("save unavailable"); } }), { opId: result.opId! })) + .rejects.toMatchObject({ code: "aside_profile_persist_failed" }); + expect(config.asideProfileSync).toBe(previous); + expect(bytes()).toEqual(before); + expect(listAsideOperations(input(), 1).map(row => row.entry)).toEqual(rows); + }); + + test("Undo never enables policy from a snapshot whose prior owner names another profile", async () => { + seedLegacy(); + const disabled = await mutateAsideProfiles(input(), { profileId: 0, enabled: false }); + const result = disabled.results[0]!; + if (!result.ok) throw new Error("fixture disable failed"); + const entry = store.findOperation(result.opId!)!; + store.appendJournal({ ...entry, opId: "wrong-owner", snapshot: { kind: "none" }, + priorRecord: { ...entry.priorRecord!, configPath: path(1) } }); + const before = bytes(); + const beforeSaves = saves; + await expect(restoreAsideProfile(input(), { opId: "wrong-owner", profileId: 0 })) + .rejects.toMatchObject({ code: "aside_operation_invalid", status: 409 }); + expect(saves).toBe(beforeSaves); + expect(bytes()).toEqual(before); + }); + + test("one flight covers save and writes across different profile scopes", async () => { + let release!: () => void; + let observe!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { observe = resolve; }); + const first = mutateAsideProfiles(input({ persistConfig: async next => { + observe(); await gate; saved = structuredClone(next); + } }), { profileId: 0, enabled: true }); + try { + await started; + await expect(mutateAsideProfiles(input(), { profileId: 1, enabled: true })) + .rejects.toMatchObject({ code: "integration_mutation_busy", status: 409 }); + await expect(refreshAsideProfiles(input())).rejects.toMatchObject({ code: "integration_mutation_busy" }); + await expect(refreshAsideProfiles(input({ store: createIntegrationStateStore(join(root, "other-state")) }))) + .rejects.toMatchObject({ code: "integration_mutation_busy" }); + expect(bytes()).toEqual([original, original, original]); + } finally { release(); await first; } + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect((await mutateAsideProfiles(input(), { profileId: 1, enabled: true })).ok).toBe(true); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cfab75fefb..58a72a350b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -69,6 +69,9 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts new file mode 100644 index 0000000000..33a63fdaea --- /dev/null +++ b/tests/server/aside-profiles-routes.test.ts @@ -0,0 +1,129 @@ +import { loadConfig } from "../../src/config"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let home: string; +let store: IntegrationStateStore; +let config: OcxConfig; +let isolation: IsolatedCodexHome; +let priorOcxHome: string | undefined; +let saved: OcxConfig | undefined; +const env: NodeJS.ProcessEnv = {}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-profile-routes-")); + home = join(root, "home"); + priorOcxHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "config"); + isolation = installIsolatedCodexHome("ocx-aside-profile-codex-"); + store = createIntegrationStateStore(join(root, "store")); + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }], + sessions: { private: { accessToken: "do-not-project" } }, + })); + for (const id of [0,1,2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(path(id), JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + } + config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", fastRows: false, providers: { + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one","two"] }, + } } as OcxConfig; + saved = undefined; + setIntegrationPathTestHooks({ home, env }); + setIntegrationMutationFlightTestHooks({ store }); +}); + +afterEach(() => { + setIntegrationPathTestHooks(null); + setIntegrationMutationFlightTestHooks(null); + isolation.restore(); + if (priorOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorOcxHome; + removeTreeWithRetry(root); +}); + +function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } +async function api(pathname: string, method = "GET", body?: unknown) { + const url = new URL(`http://127.0.0.1:10100${pathname}`); + const response = await handleManagementAPI(new Request(url, { + method, headers: { Host: url.host, "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), url, config, { + saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, + createManagementConvergeCodex: catalogConvergenceFactory(), + refreshOwnedCatalogIntegrations: input => refreshOwnedCatalogIntegrations({ ...input, store, env, home }), + }); + if (!response) throw new Error("route missing"); + return response; +} + +test("legacy connection refreshes all profiles, and an individual off survives selection refresh and reload", async () => { + expect(applyIntegration({ clientId: "aside", config, port: 10100, store, env, home, + models: [{ provider: "fixture", id: "one", namespaced: "fixture/one" }] }).ok).toBe(true); + const initial = await (await api("/api/client-integrations/aside/profiles")).json(); + expect(initial.profiles).toHaveLength(3); + expect(JSON.stringify(initial)).not.toContain("do-not-project"); + expect((await api("/api/selected-models", "PUT", { provider: "fixture", models: ["one"] })).status).toBe(200); + for (const id of [0,1,2]) { + expect(document(id).providers.opencodex.models.filter((m: { id: string }) => m.id.startsWith("fixture/")).map((m: { id: string }) => m.id)).toEqual(["fixture/one"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } + expect((await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: false })).status).toBe(200); + config = structuredClone(saved!); + expect((await api("/api/selected-models", "PUT", { provider: "fixture", models: ["two"] })).status).toBe(200); + expect(document(1).providers.opencodex).toBeUndefined(); + for (const id of [0,2]) expect(document(id).providers.opencodex.models.some((m: { id: string }) => m.id === "fixture/two")).toBe(true); + const state = await (await api("/api/client-integrations/aside?profile=1")).json(); + expect(state).toMatchObject({ profileId: 1, enabled: false, state: "absent" }); +}); + +test("profile history and Undo cannot recreate an undone enable on the next sync", async () => { + const enabled = await (await api("/api/client-integrations/aside?profile=2", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const journal = await (await api("/api/client-integrations/journal?client=aside&profile=2")).json(); + expect(journal.operations[0]).toMatchObject({ profileId: 2, opId: enabled.opId, undoable: true }); + expect((await api("/api/client-integrations/restore?client=aside&profile=2", "POST", { opId: enabled.opId })).status).toBe(200); + config = structuredClone(saved!); + await api("/api/selected-models", "PUT", { provider: "fixture", models: ["one"] }); + expect(document(2).providers.opencodex).toBeUndefined(); + expect(document(0).providers.opencodex).toBeUndefined(); +}); + +test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { + const before = [0,1,2].map(i => readFileSync(path(i), "utf8")); + const response = await api(`/api/client-integrations/aside?profile=${encodeURIComponent(id)}`, "PUT", { enabled: true }); + expect(response.status).toBe(400); + expect([0,1,2].map(i => readFileSync(path(i), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); +}); + +test("a non-Aside client cannot silently consume a profile selector", async () => { + expect((await api("/api/client-integrations/pi?profile=0", "PUT", { enabled: true })).status).toBe(400); + expect(saved).toBeUndefined(); +}); + + +test("invalid persisted profile policy fails closed without resetting the surrounding config", () => { + const configRoot = process.env.OPENCODEX_HOME!; + mkdirSync(configRoot, { recursive: true }); + writeFileSync(join(configRoot, "config.json"), JSON.stringify({ ...config, asideProfileSync: { allProfiles: true, profiles: { "1": "off" } } })); + const loaded = loadConfig(); + expect(loaded.asideProfileSync).toEqual({ allProfiles: false }); + expect(loaded.port).toBe(10100); + expect(loaded.providers.fixture).toBeDefined(); +}); From aa46afd1e878070c21b359bc4e28e98692cd91cc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:07:29 +0900 Subject: [PATCH 03/11] test(aside): align profile refresh outcomes and layout seeds --- scripts/test-layout/layout.json | 4 +++- src/integrations/catalog-refresh.ts | 5 ++++- tests/clients/sync-client-integrations.test.ts | 9 +++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4e57ed3311..d2a8dde5bf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,7 +11,7 @@ "domains": { "providers": { "match": [ - "^(?:aside|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" + "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" ], "children": { "cursor": [ @@ -38,6 +38,7 @@ }, "server": { "match": [ + "^aside-profiles-routes", "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-" ] }, @@ -107,6 +108,7 @@ }, "clients": { "match": [ + "^aside-profile(?!s-routes)", "^(?:desktop|omp|pi|prime|remote|sync)-" ] }, diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 0b83af9654..8efb990002 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -26,10 +26,13 @@ export async function refreshOwnedCatalogIntegrations( const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); if (result) outcomes.push(result); } catch (error) { + const busy = error !== null && typeof error === "object" + && "code" in error && error.code === "integration_mutation_busy"; outcomes.push({ client: clientId, ok: false, - reason: redactSecretString(error instanceof Error ? error.message : String(error)), + reason: busy ? "integration_mutation_busy" + : redactSecretString(error instanceof Error ? error.message : String(error)), }); } } diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index e870f53b1b..b56ef490af 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -365,7 +365,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { loads += 1; return filteredModels; })); - expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true }))); + expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true, ...(client === "aside" ? { profileId: 0 } : {}) }))); expect(loads).toBe(1); for (const client of clients) { expect(document(client)).toMatchObject({ theme: "dark", providers: { personal: sibling } }); @@ -403,6 +403,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { writeFileSync(path, before); expect(await refreshOwnedCatalogIntegrations(input(filteredModels))).toEqual([{ client: clientId, ok: true, changed: false, + ...(clientId === "aside" ? { profileId: 0 } : {}), reason: "managed block is absent; refresh did not reconnect it", }]); expect(readFileSync(path, "utf8")).toBe(before); @@ -456,7 +457,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); expect(outcomes).toEqual([ { client: "pi", ok: false, reason: "synthetic Pi stat failure" }, - { client: "aside", ok: true, changed: true }, + { client: "aside", profileId: 0, ok: true, changed: true }, ]); expect(readFileSync(path, "utf8")).toBe(before); expect(store.readRecords().pi).toEqual(recordBefore); @@ -489,7 +490,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }, [clientId]); await contended; release(); - expect(await first).toEqual([{ client: clientId, ok: true, changed: true }]); + expect(await first).toEqual([{ client: clientId, ok: true, changed: true, ...(clientId === "aside" ? { profileId: 0 } : {}) }]); expect(await second).toEqual([{ client: clientId, ok: false, reason: "integration_mutation_busy" }]); expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "apply"]); @@ -499,7 +500,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { setIntegrationMutationFlightTestHook(null); } expect(await refreshOwnedCatalogIntegrations(input(nextModels), [clientId])) - .toEqual([{ client: clientId, ok: true, changed: true }]); + .toEqual([{ client: clientId, ok: true, changed: true, ...(clientId === "aside" ? { profileId: 0 } : {}) }]); expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/hidden"]); expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "refresh", "apply"]); }); From 3c2eb3fe3e1e8db03abd5bb8434145c8aca1dbf5 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:25:37 +0900 Subject: [PATCH 04/11] fix(aside): centralize profile mutations and preserve backup sources --- .../010_profiles_backend_cli.md | 4 + .../260906_aside_profiles/020_profiles_gui.md | 2 +- scripts/test-layout/layout.json | 1 + src/cli/aside-profiles.ts | 17 ++ src/cli/dispatch.ts | 8 +- src/cli/integrations.ts | 32 ++- src/integrations/aside-profile-journal.ts | 22 +- src/server/management/aside-profile-routes.ts | 84 +++++- src/server/management/integration-routes.ts | 5 + tests/cli/cli-headless-parity.test.ts | 10 +- .../clients/aside-profile-sync-owner.test.ts | 266 ++++++++++++++++++ tests/clients/aside-profiles.test.ts | 54 ++++ .../clients/sync-client-integrations.test.ts | 3 +- tests/fixtures/test-layout-expected.json | 1 + tests/server/aside-profiles-routes.test.ts | 31 ++ 15 files changed, 501 insertions(+), 39 deletions(-) create mode 100644 src/cli/aside-profiles.ts create mode 100644 tests/clients/aside-profile-sync-owner.test.ts diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md index 36be9363ae..570ec2a3ea 100644 --- a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -41,3 +41,7 @@ The context owner centralizes exact scope/store resolution, desired policy, guar `bun .tmp/aside-profiles/api-cli-probe.ts` passed against an isolated live HTTP management handler and actual CLI: three-profile bulk enable, individual-off after persisted reload/model selection, Undo followed by sync, unrelated settings and metadata privacy. Default unconfigured/disabled Aside now skips implicit fan-out before manifest/catalog discovery. This C4 backend layer is larger than the default review-size guideline because the new filesystem scope, one-owner store model, reversible desired state, and API/CLI consumers must be assessed as one complete contract; these are new cohesive modules with focused fixtures, not unrelated cleanup. UI implementation remains a separate dependent PR/cycle, and the original Grok work is already four separate reviewed PRs. + +## Coordinated client interface amendment + +CLI Aside refresh runs through POST /api/client-integrations/aside/sync on the live server, never through the local file writer; MCode/Pi keep their existing paths. Add a deterministic two-process CLI/server coordination regression. Dedicated primary profile paths are /aside/profiles (GET list, PUT bulk), /aside/profiles/ (GET/PUT one), /aside/profiles//journal (GET/DELETE) and /aside/profiles//restore (POST). CLI and new UI use these paths so unsupported old servers refuse rather than ignore a profile query. The new server may retain validated query compatibility, but Aside can never fall through to a legacy generic writer. Journal source availability and request selector consistency are part of the final regression matrix; detailed review synthesis stays ignored scratch. diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md index 7dda350656..16e5798e04 100644 --- a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md +++ b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md @@ -4,7 +4,7 @@ Depends on 010 verified API and CLI. Class C3 UI with C4 backend unchanged. Goal NEW gui/src/pages/integrations/AsideProfilesPage.tsx: useDataSurface GET profiles endpoint, existing Notice/Switch/ClientMark/IntegrationStateBadge. Global switch sets desired sync for all; rows show profile name or translated numeric fallback, current marker, actual state, independent switch, and details action. Single pending target serializes interactions consistently with backend. Switches read desired enabled; badges and applied/total count read actual file state. Show pending mismatch and per-profile refusal after partial failure, never optimistic applied success for siblings. A retry repeats the same desired action. Empty profile list prompts opening Aside; errors offer existing refresh action; inactive tabs do not fetch. A selected profile opens the existing FileIntegrationPage with profileId plus name and a back action; do not duplicate its rollback machinery. MODIFY gui/src/pages/Integrations.tsx: Aside renders new page; remaining file clients stay on existing page. -NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId appended to state/toggle/history/restore/delete query URLs. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. +NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId selects dedicated nested profile paths for state/toggle/history/restore/delete; old servers must refuse unsupported scoped mutations. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. MODIFY FileIntegrationPage.tsx: optional profileId/profileLabel, read optional desired enabled on scoped status, include profile in every resource/cache/dependency key and every state/history/mutation call. MODIFY RestoreDialog.tsx if needed to pass profile scope through; rollback/delete remain on selected profile. MODIFY styles-integrations.css: compact row layout using existing tokens; responsive wrapping for long labels/paths. No new color system or decorative assets. MODIFY every gui/src/i18n locale module: profile list/title, sync-all, enabled count, current profile, details/back, empty, per-profile switch labels and partial failure copy. All visible text uses t/useT; names and numeric IDs are API metadata. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d2a8dde5bf..1fcf0b33dd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -237,6 +237,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/src/cli/aside-profiles.ts b/src/cli/aside-profiles.ts new file mode 100644 index 0000000000..5c16061c00 --- /dev/null +++ b/src/cli/aside-profiles.ts @@ -0,0 +1,17 @@ +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; +import { runtimeRequest, RuntimeApiError, type RuntimeApiDeps } from "./runtime-api"; + +/** Aside policy and file writes share the running server's mutation owner. Never fall back locally. */ +export async function refreshAsideProfilesThroughServer( + deps: RuntimeApiDeps = {}, +): Promise { + const result = await runtimeRequest<{ results?: OwnedIntegrationRefreshOutcome[] }>( + "/api/client-integrations/aside/sync", + { method: "POST", body: "{}" }, + deps, + ); + if (!Array.isArray(result.results)) { + throw new RuntimeApiError("The running proxy does not support Aside profile synchronization", 502, result); + } + return result.results; +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 131ab7b1f6..3b690f420c 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -400,7 +400,13 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "aside"]); + }, ["mcode", "pi"]); + try { + const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); + results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); + } catch (error) { + console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } for (const result of results) { const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index e151a3c836..921326d1a3 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -35,13 +35,18 @@ const CLIENT_USAGE = `Usage: ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json] --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`; -function asideProfileQuery(profile: string | undefined, client: string | undefined): string { - if (profile === undefined) return ""; +function validateAsideProfile(profile: string | undefined, client: string | undefined): void { + if (profile === undefined) return; if (client !== "aside") throw new CliUsageError("--profile requires --client aside", CLIENT_USAGE); if (!/^(0|[1-9][0-9]*)$/.test(profile) || !Number.isSafeInteger(Number(profile))) { throw new CliUsageError("--profile must be a nonnegative integer account ID", CLIENT_USAGE); } - return `profile=${encodeURIComponent(profile)}`; + +} + +function clientIntegrationPath(client: string, profile?: string): string { + const base = `/api/client-integrations/${encodeURIComponent(client)}`; + return client === "aside" ? `${base}/profiles${profile === undefined ? "" : `/${encodeURIComponent(profile)}`}` : base; } function parseMap(raw: string): Record { @@ -178,10 +183,10 @@ export async function handleClientIntegrationCommand( if (action === "status" || action === "show" || action === "list") { const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); + validateAsideProfile(profile, client); rejectArgs(args, CLIENT_USAGE); const path = client - ? `/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}` + ? clientIntegrationPath(client, profile) : "/api/client-integrations"; const result = await runtimeRequest(path, {}, deps); const rows = (result as { clients?: Array> }).clients; @@ -196,10 +201,11 @@ export async function handleClientIntegrationCommand( if (action === "history" || action === "journal") { const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); + validateAsideProfile(profile, client); rejectArgs(args, CLIENT_USAGE); - const query = client ? `?client=${encodeURIComponent(client)}${profileQuery ? `&${profileQuery}` : ""}` : ""; - const result = await runtimeRequest(`/api/client-integrations/journal${query}`, {}, deps); + const path = client === "aside" ? `${clientIntegrationPath(client, profile)}/journal` + : `/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`; + const result = await runtimeRequest(path, {}, deps); const operations = (result as { operations?: Array> }).operations ?? []; printData(result, wantsJson, operations.length === 0 ? ["No integration operations recorded yet."] @@ -217,11 +223,11 @@ export async function handleClientIntegrationCommand( const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id"); const confirmDrift = takeFlag(args, "--confirm-drift"); const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); - if (client !== undefined && !profileQuery) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); + validateAsideProfile(profile, client); + if (client !== undefined && profile === undefined) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); - const result = await runtimeRequest(`/api/client-integrations/restore${profileQuery ? `?client=aside&${profileQuery}` : ""}`, { + const result = await runtimeRequest(profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`, { method: "POST", body: JSON.stringify({ opId, confirmDrift }), }, deps); @@ -233,7 +239,7 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); + validateAsideProfile(profile, client); /* * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the * refusal is the default and the waiver has to be typed. @@ -254,7 +260,7 @@ export async function handleClientIntegrationCommand( if (overwriteConflict && action === "disable") { throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); } - const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}`, { + const result = await runtimeRequest(clientIntegrationPath(client, profile), { method: "PUT", // Sent only when asked for, so a proxy on an older build sees the request it // has always seen rather than an unknown field. diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index 5c31024332..f5cd228c90 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -43,7 +43,18 @@ function uniqueOperations(rows: AsideOperation[]): AsideOperation[] { if (previous && (previous.profileId !== row.profileId || JSON.stringify(previous.entry) !== JSON.stringify(row.entry))) { throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation identifies multiple profiles"); } - if (!previous) seen.set(row.entry.opId, row); + if (!previous) { + seen.set(row.entry.opId, row); + continue; + } + // Identical journal rows can outlive different snapshot-retention windows. + const previousSnapshot = previous.store.readSnapshot(previous.entry); + const candidateSnapshot = row.store.readSnapshot(row.entry); + if (previousSnapshot.kind === "stored" && candidateSnapshot.kind === "stored" + && previousSnapshot.text !== candidateSnapshot.text) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation has conflicting snapshot copies"); + } + if (previousSnapshot.kind === "expired" && candidateSnapshot.kind === "stored") seen.set(row.entry.opId, row); } return [...seen.values()]; } @@ -131,11 +142,8 @@ function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: Integ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { if (row.store.root === scope.store.root) return; const existing = scope.store.findOperation(row.entry.opId); - if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(row.entry)) { - throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); - } - return; + if (existing && JSON.stringify(existing) !== JSON.stringify(row.entry)) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); } const snapshot = row.store.readSnapshot(row.entry); if (snapshot.kind === "expired") throw new AsideProfileError("integration_snapshot_expired", 410, "That backup has expired"); @@ -148,7 +156,7 @@ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { } if (present.kind !== "stored") scope.store.captureSnapshot("aside", row.entry.opId, snapshot.text); } - scope.store.appendJournal(structuredClone(row.entry)); + if (!existing) scope.store.appendJournal(structuredClone(row.entry)); } export function restoreAsideProfile( diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 6e36bbe52b..a33b613130 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -3,7 +3,7 @@ import { ClientPathError } from "../../clients/config-export"; import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; import { - getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles, type AsideProfilesInput, } from "../../integrations/aside-profiles"; import { @@ -49,25 +49,85 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +async function readProfileBody(req: Request): Promise { + try { return await readManagementJsonBody(req); } + catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } +} + +function validateClientSelector(ctx: ManagementContext): void { + const client = ctx.url.searchParams.get("client"); + if (client !== null && client !== "aside") throw new ProfileQueryError("client/profile selectors must identify Aside"); +} + +/** Dedicated scoped paths fail closed even when a newer client reaches an older server. */ +function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; action?: string } { + const prefix = "/api/client-integrations/aside/profiles/"; + if (!ctx.url.pathname.startsWith(prefix)) return { ctx }; + validateClientSelector(ctx); + const parts = ctx.url.pathname.slice(prefix.length).split("/"); + const url = new URL(ctx.url); + if (parts.length === 1 && parts[0] === "journal") { + if (url.searchParams.has("profile")) throw new ProfileQueryError("Use a profile-specific journal path"); + url.pathname = "/api/client-integrations/journal"; + url.searchParams.set("client", "aside"); + return { ctx: { ...ctx, url }, action: "journal" }; + } + if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) { + throw new ProfileQueryError("Invalid Aside profile path"); + } + const prior = url.searchParams.get("profile"); + if (prior !== null && prior !== parts[0]) throw new ProfileQueryError("Conflicting Aside profile selectors"); + url.searchParams.set("profile", parts[0]); + url.searchParams.set("client", "aside"); + url.pathname = parts[1] ? `/api/client-integrations/${parts[1]}` : "/api/client-integrations/aside"; + return { ctx: { ...ctx, url }, action: parts[1] }; +} + /** Own only Aside status/toggle paths; other clients keep the existing adapter. */ export async function handleAsideProfileRoutes( ctx: ManagementContext, options: AsideProfileRouteOptions, ): Promise { - const { req, url } = ctx; - if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; - if (req.method !== "GET" && req.method !== "PUT") return null; + if (ctx.url.pathname !== "/api/client-integrations/aside" + && !ctx.url.pathname.startsWith("/api/client-integrations/aside/")) return null; try { + const normalized = nestedProfileContext(ctx); + ctx = normalized.ctx; + const { req, url } = ctx; + validateClientSelector(ctx); const id = profileId(ctx); - if (url.pathname.endsWith("/profiles")) { - if (req.method !== "GET" || id !== undefined) throw new ProfileQueryError("The profile collection supports GET without a profile selector"); - return jsonResponse(await listAsideProfileStates(options.input()), 200, req, ctx.config); + if (normalized.action === "journal") { + if (req.method === "GET") return asideJournalResponse(ctx, "aside", options); + if (req.method === "DELETE") { + const opId = url.searchParams.get("opId")?.trim(); + if (!opId) throw new ProfileQueryError("opId is required"); + return asideJournalDeleteResponse(ctx, opId, options); + } + return null; + } + if (normalized.action === "restore") { + if (req.method !== "POST") return null; + const body = await readProfileBody(req); + if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim() + || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request"); + return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options); + } + if (url.pathname === "/api/client-integrations/aside/sync") { + if (req.method !== "POST") return null; + if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles"); + const body = await readProfileBody(req); + if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object"); + const results = await refreshAsideProfiles(options.input()); + const ok = results.every(result => result.ok); + return jsonResponse({ ok, clientId: "aside", results }, ok ? 200 : 207, req, ctx.config); } + if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; + if (req.method !== "GET" && req.method !== "PUT") return null; + if (url.pathname.endsWith("/profiles") && id !== undefined) throw new ProfileQueryError("Use a profile-specific path"); if (req.method === "GET") { const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id); return jsonResponse(state, 200, req, ctx.config); } - let body: unknown; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } + const body = await readProfileBody(req); if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); @@ -120,11 +180,12 @@ export async function asideRestoreResponse( ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, ): Promise { try { + validateClientSelector(ctx); const id = profileId(ctx); const input = options.input(); const rootEntry = input.store?.findOperation(body.opId); if (rootEntry && rootEntry.clientId !== "aside") { - if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation"); return null; } const operation = await findAsideOperation(input, body.opId, id); @@ -145,11 +206,12 @@ export async function asideJournalDeleteResponse( ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions, ): Promise { try { + validateClientSelector(ctx); const id = profileId(ctx); const input = options.input(); const rootEntry = input.store?.findOperation(opId); if (rootEntry && rootEntry.clientId !== "aside") { - if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation"); return null; } const operation = await findAsideOperation(input, opId, id); diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 28b4c4cadd..83a3c3d464 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -622,6 +622,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { expect(await handleClientIntegrationCommand(["history", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); expect(await handleClientIntegrationCommand(["restore", "--client", "aside", "--profile", "2", "--op", "op-profile", "--json"], runtime.deps)).toBe(0); expect(runtime.requests.map(row => row.path)).toEqual([ - "/api/client-integrations/aside?profile=2", - "/api/client-integrations/aside?profile=2", - "/api/client-integrations/journal?client=aside&profile=2", - "/api/client-integrations/restore?client=aside&profile=2", + "/api/client-integrations/aside/profiles/2", + "/api/client-integrations/aside/profiles/2", + "/api/client-integrations/aside/profiles/2/journal", + "/api/client-integrations/aside/profiles/2/restore", ]); expect(runtime.requests[1]!.body).toEqual({ enabled: false }); expect(runtime.requests[3]!.body).toEqual({ opId: "op-profile", confirmDrift: false }); @@ -956,6 +956,6 @@ describe("Aside profile integration CLI", () => { results: [{ profileId: 0, ok: true, message: "updated" }, { profileId: 1, ok: false, message: "conflict" }], })); expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--json"], runtime.deps)).toBe(1); - expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside", method: "PUT", body: { enabled: true } }); + expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true } }); }); }); diff --git a/tests/clients/aside-profile-sync-owner.test.ts b/tests/clients/aside-profile-sync-owner.test.ts new file mode 100644 index 0000000000..093f25dbdf --- /dev/null +++ b/tests/clients/aside-profile-sync-owner.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; +import type { OwnedIntegrationRefreshOutcome } from "../../src/integrations/owned-refresh"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +const SYNC_PATH = "/api/client-integrations/aside/sync"; +const CHILD_BUDGET_MS = 20_000; +let root: string; +let home: string; +let configHome: string; +let config: OcxConfig; +let isolation: IsolatedCodexHome; +let priorConfigHome: string | undefined; +let server: ReturnType | undefined; +let baseUrl: string; +let mode: "live" | "missing-route" | "old-response"; +let writes: number[]; +let syncRequests: Array<{ method: string; body: string }>; +const children: Array> = []; + +function bounded(promise: Promise, label: string, ms = CHILD_BUDGET_MS): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +interface ChildMessage { + phase: "ready" | "refreshed" | "refused"; + pid: number; + staleEnabled: boolean; + results?: OwnedIntegrationRefreshOutcome[]; + name?: string; + status?: number; +} + +/** An actual CLI module in a second process, with its own already-loaded config. */ +function startCli() { + const helperUrl = pathToFileURL(repoPath("src", "cli", "aside-profiles.ts")).href; + const configUrl = pathToFileURL(repoPath("src", "config.ts")).href; + const source = ` + import { once } from "node:events"; + import { createInterface } from "node:readline"; + import { loadConfig } from ${JSON.stringify(configUrl)}; + import { refreshAsideProfilesThroughServer } from ${JSON.stringify(helperUrl)}; + const deadline = setTimeout(() => process.exit(124), ${CHILD_BUDGET_MS}); + const stale = loadConfig(); + const enabled = () => stale.asideProfileSync?.profiles?.["1"] ?? stale.asideProfileSync?.allProfiles ?? false; + const emit = value => console.log("ASIDE_SYNC_MESSAGE " + JSON.stringify({ pid: process.pid, staleEnabled: enabled(), ...value })); + const lines = createInterface({ input: process.stdin }); + const gate = once(lines, "line"); + emit({ phase: "ready" }); + const [release] = await gate; + lines.close(); + process.stdin.pause(); + if (release !== "refresh") throw new Error("unexpected parent gate message"); + try { + const results = await refreshAsideProfilesThroughServer({ baseUrl: process.env.ASIDE_SYNC_FIXTURE_URL }); + emit({ phase: "refreshed", results }); + } catch (error) { + emit({ phase: "refused", name: error.name, status: error.status }); + } finally { clearTimeout(deadline); } + `; + const child = spawn(process.execPath, ["--eval", source], { + cwd: repoRoot(), stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, HOME: home, USERPROFILE: home, OPENCODEX_HOME: configHome, + CODEX_HOME: isolation.path, XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_ADMIN_AUTH_TOKEN: "", ASIDE_SYNC_FIXTURE_URL: baseUrl, + }, + }); + let stderr = ""; + child.stderr.on("data", chunk => { stderr = (stderr + String(chunk)).slice(-16_384); }); + child.on("error", error => { stderr += error.message; }); + const exited = new Promise(resolve => child.once("close", resolve)); + const lines = createInterface({ input: child.stdout }); + const iterator = lines[Symbol.asyncIterator](); + const cli = { + async next(): Promise { + return bounded((async () => { + for (;;) { + const line = await iterator.next(); + if (line.done) throw new Error(`CLI exited before its next gate message: ${stderr}`); + if (line.value.startsWith("ASIDE_SYNC_MESSAGE ")) { + return JSON.parse(line.value.slice("ASIDE_SYNC_MESSAGE ".length)) as ChildMessage; + } + } + })(), "CLI gate"); + }, + release() { child.stdin.end("refresh\n"); }, + async finish() { + const code = await bounded(exited, "CLI exit"); + if (code !== 0) throw new Error(`CLI exited with ${code}: ${stderr}`); + }, + async dispose() { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + try { await bounded(exited, "CLI cleanup", 5_000); } finally { lines.close(); } + }, + }; + children.push(cli); + return cli; +} + +function profilePath(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function profileFiles() { + return [0, 1, 2].map(id => { + const path = profilePath(id); + const stat = lstatSync(path, { bigint: true }); + return { text: readFileSync(path, "utf8"), ino: stat.ino.toString(), mtime: stat.mtimeNs.toString() }; + }); +} +function catalog(id: number): string[] { + const doc = JSON.parse(readFileSync(profilePath(id), "utf8")); + return (doc.providers?.opencodex?.models ?? []).map((model: { id: string }) => model.id) + .filter((id: string) => id.startsWith("fixture/")); +} +function persist(value: OcxConfig = config): void { + writeFileSync(join(configHome, "config.json"), JSON.stringify(value)); +} +async function api(path: string, method = "GET", body?: unknown): Promise { + return fetch(`${baseUrl}${path}`, { + method, headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(5_000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-sync-owner-")); + home = join(root, "home"); + configHome = join(root, "opencodex"); + mkdirSync(configHome, { recursive: true }); + priorConfigHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configHome; + isolation = installIsolatedCodexHome("ocx-aside-sync-owner-codex-"); + for (const id of [0, 1, 2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(profilePath(id), JSON.stringify({ theme: "keep", providers: {} })); + } + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Cloud" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }], + })); + config = { + port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", fastRows: false, + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one"] } }, + } as OcxConfig; + // Match the child's default ownership-store location: a local fallback must + // encounter real owned targets, rather than vacuously skip an empty store. + const store = createIntegrationStateStore(join(configHome, "integrations")); + const io = store.io(); + writes = []; + syncRequests = []; + mode = "live"; + setIntegrationPathTestHooks({ home, env: {} }); + setIntegrationMutationFlightTestHooks({ store, io: { + ...io, writeText(path, text) { + const id = [0, 1, 2].find(candidate => profilePath(candidate) === path); + if (id !== undefined) writes.push(id); + io.writeText(path, text); + }, + } }); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + const url = new URL(req.url); + if (url.pathname === SYNC_PATH) { + syncRequests.push({ method: req.method, body: await req.clone().text() }); + if (mode === "missing-route") return Response.json({ error: "endpoint not found" }, { status: 404 }); + if (mode === "old-response") return Response.json({ ok: true }); + } + return await handleManagementAPI(req, url, config, { + saveConfigPreservingClaudeCode: persist, createManagementConvergeCodex: catalogConvergenceFactory(), + }) ?? new Response("Not found", { status: 404 }); + } }); + config.port = server.port!; + baseUrl = `http://127.0.0.1:${server.port}`; + persist(); +}); + +afterEach(async () => { + try { await Promise.all(children.splice(0).map(child => child.dispose())); } + finally { + await server?.stop(true); + server = undefined; + setIntegrationMutationFlightTestHooks(null); + setIntegrationPathTestHooks(null); + isolation.restore(); + if (priorConfigHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorConfigHome; + removeTreeWithRetry(root); + } +}); + +async function enableAll(): Promise { + const response = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(catalog(id)).toEqual(["fixture/one"]); +} + +test("a stale CLI process refreshes only the profiles still enabled by the live server", async () => { + await enableAll(); + const cli = startCli(); + const ready = await cli.next(); + expect(ready).toMatchObject({ phase: "ready", staleEnabled: true }); + expect(ready.pid).not.toBe(process.pid); + const disabled = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: false }); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ ok: true }); + expect(JSON.parse(readFileSync(join(configHome, "config.json"), "utf8")).asideProfileSync.profiles["1"]).toBe(false); + expect(catalog(1)).toEqual([]); + const disabledFile = profileFiles()[1]; + // Change only the server's catalog fixture: no selection endpoint may refresh + // it before the released child reaches the production sync owner. + config.providers.fixture!.models = ["two"]; + persist(); + writes.length = 0; + cli.release(); + const result = await cli.next(); + await cli.finish(); + expect(result).toMatchObject({ phase: "refreshed", pid: ready.pid, staleEnabled: true }); + expect(result.results).toEqual([ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ]); + expect(syncRequests).toEqual([{ method: "POST", body: "{}" }]); + expect(writes).toEqual([0, 2]); + for (const id of [0, 2]) expect(catalog(id)).toEqual(["fixture/two"]); + expect(profileFiles()[1]).toEqual(disabledFile); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ profileId: 1, enabled: false, state: "absent" }); +}, 45_000); + +test.each(["missing-route", "old-response", "offline"] as const)( + "CLI refuses %s without falling back to local profile writes", async failure => { + await enableAll(); + const before = profileFiles(); + config.providers.fixture!.models = ["two"]; + persist(); + const configBefore = readFileSync(join(configHome, "config.json"), "utf8"); + if (failure === "offline") { await server!.stop(true); server = undefined; } + else mode = failure; + writes.length = 0; + const cli = startCli(); + expect(await cli.next()).toMatchObject({ phase: "ready", staleEnabled: true }); + cli.release(); + expect(await cli.next()).toMatchObject({ + phase: "refused", name: "RuntimeApiError", status: failure === "offline" ? 503 : failure === "missing-route" ? 404 : 502, + }); + await cli.finish(); + expect(profileFiles()).toEqual(before); + expect(readFileSync(join(configHome, "config.json"), "utf8")).toBe(configBefore); + expect(writes).toEqual([]); + expect(syncRequests).toEqual(failure === "offline" ? [] : [{ method: "POST", body: "{}" }]); + }, 45_000, +); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index 11f6303a02..35a60426d8 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -295,6 +295,60 @@ describe("Aside profile desired state, ownership and history", () => { expect(store.readRecords().aside).toEqual(owner); }); + test.each(["child", "legacy"] as const)("history restores from the remaining copy when %s retention expires", async expired => { + const opId = seedLegacy(1); + seedLegacy(0); + const rootOwner = store.readRecords().aside; + const applied = readFileSync(path(1), "utf8"); + expect((await restoreAsideProfile(input(), { opId })).ok).toBe(true); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = store.findOperation(opId)!; + expect(child.findOperation(opId)).toEqual(entry); + const expiredStore = expired === "child" ? child : store; + const remainingStore = expired === "child" ? store : child; + const snapshot = expiredStore.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + removeTreeWithRetry(snapshot.path); + expect(expiredStore.readSnapshot(entry).kind).toBe("expired"); + expect(remainingStore.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + const selected = findAsideOperation(input(), opId, 1)!; + expect(selected.store.root).toBe(remainingStore.root); + expect(listAsideOperations(input(), 1).filter(row => row.entry.opId === opId)).toHaveLength(1); + // Recreate the operation's result so ordinary Undo needs no drift override. + writeFileSync(path(1), applied); + expect(asideOperationMatchesCurrent(input(), selected)).toBe(true); + expect((await restoreAsideProfile(input(), { opId, profileId: 1 })).ok).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(child.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + expect(child.listOperations("aside").filter(row => row.opId === opId)).toHaveLength(1); + expect(store.listOperations("aside").filter(row => row.opId === opId)).toHaveLength(1); + expect(store.readRecords().aside).toEqual(rootOwner); + if (expired === "legacy") expect(store.readSnapshot(entry).kind).toBe("expired"); + }); + + test("conflicting available snapshot copies refuse lookup and restore before saving or writing", async () => { + const opId = seedLegacy(1); + seedLegacy(0); + expect((await restoreAsideProfile(input(), { opId })).ok).toBe(true); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = store.findOperation(opId)!; + const snapshot = child.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + writeFileSync(snapshot.path, JSON.stringify({ theme: "conflicting-copy" })); + expect(child.findOperation(opId)).toEqual(entry); + const before = bytes(); + const beforeSaves = saves; + const beforeHistory = child.listOperations("aside"); + expect(() => findAsideOperation(input(), opId, 1)).toThrow("conflicting snapshot copies"); + expect(() => listAsideOperations(input(), 1)).toThrow("conflicting snapshot copies"); + await expect(restoreAsideProfile(input(), { opId, profileId: 1, confirmDrift: true })) + .rejects.toMatchObject({ code: "aside_operation_ambiguous", status: 409 }); + expect(saves).toBe(beforeSaves); + expect(bytes()).toEqual(before); + expect(child.listOperations("aside")).toEqual(beforeHistory); + expect(store.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + }); + test("unknown profile selectors and unregistered historical targets are not retargeted", async () => { await expect(getAsideProfileState(input(), 9)).rejects.toMatchObject({ code: "aside_profile_not_found", status: 404 }); await expect(mutateAsideProfiles(input(), { profileId: -1, enabled: true })).rejects.toBeInstanceOf(AsideProfileError); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index b56ef490af..bf78bd65c6 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -511,7 +511,8 @@ test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relyi const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi", "aside"]'); + expect(command).toContain('["mcode", "pi"]'); + expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 58a72a350b..1b8d917abc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -72,6 +72,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 33a63fdaea..b129e8f701 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -127,3 +127,34 @@ test("invalid persisted profile policy fails closed without resetting the surrou expect(loaded.port).toBe(10100); expect(loaded.providers.fixture).toBeDefined(); }); + +test.each(["%61side", "as%69de"])("alternate Aside spelling %s cannot reach the legacy writer", async spelling => { + const before = [0,1,2].map(id => readFileSync(path(id), "utf8")); + expect((await api(`/api/client-integrations/${spelling}`, "PUT", { enabled: true })).status).toBe(400); + expect(saved).toBeUndefined(); + expect([0,1,2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("conflicting client selectors cannot restore Aside or delete its history", async () => { + const on = await (await api("/api/client-integrations/aside/profiles/0", "PUT", { enabled: true })).json(); + await api("/api/client-integrations/aside/profiles/0", "PUT", { enabled: false }); + const before = readFileSync(path(0), "utf8"); + const policy = structuredClone(config.asideProfileSync); + expect((await api("/api/client-integrations/restore?client=pi&profile=0", "POST", { opId: on.opId })).status).toBe(400); + expect((await api(`/api/client-integrations/journal?client=pi&profile=0&opId=${on.opId}`, "DELETE")).status).toBe(400); + expect(readFileSync(path(0), "utf8")).toBe(before); + expect(config.asideProfileSync).toEqual(policy); + const history = await (await api("/api/client-integrations/aside/profiles/0/journal")).json(); + expect(history.operations.some((row: { opId: string }) => row.opId === on.opId)).toBe(true); +}); + +test("dedicated nested paths retain profile scope for status, history and restore", async () => { + const on = await (await api("/api/client-integrations/aside/profiles/2", "PUT", { enabled: true })).json(); + expect(on).toMatchObject({ ok: true, profileId: 2 }); + expect(await (await api("/api/client-integrations/aside/profiles/2")).json()).toMatchObject({ profileId: 2, enabled: true }); + expect((await api("/api/client-integrations/aside/profiles/2?profile=1", "PUT", { enabled: false })).status).toBe(400); + expect((await api("/api/client-integrations/aside/profiles/2/restore", "POST", { opId: on.opId })).status).toBe(200); + expect(document(2).providers.opencodex).toBeUndefined(); + expect(document(0).providers.opencodex).toBeUndefined(); +}); From 1d4da9f9b754c48c522720515ef5b2a90753a0e4 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:30:44 +0900 Subject: [PATCH 05/11] fix(cli): report empty Aside profile diagnostics --- src/cli/integrations.ts | 4 +++- tests/cli/cli-headless-parity.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 921326d1a3..be957d56cf 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -192,7 +192,9 @@ export async function handleClientIntegrationCommand( const rows = (result as { clients?: Array> }).clients; const profiles = (result as { profiles?: Array> }).profiles; printData(result, wantsJson, profiles - ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + ? profiles.length > 0 + ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) : summaryLines(result)); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index ff81916653..5fc2b88da3 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -959,3 +959,12 @@ describe("Aside profile integration CLI", () => { expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true } }); }); }); + +test("Aside status prints the empty-profile diagnostic for humans", async () => { + const runtime = fakeRuntime(() => ({ profiles: [], error: "Open Aside to create a profile" })); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand(["status", "--client", "aside"], runtime.deps)).toBe(0); + expect(log.mock.calls.flat().join("\n")).toContain("Open Aside to create a profile"); + } finally { log.mockRestore(); } +}); From ee47f170ddded3cc641277143ee97adb5947e8f0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:20:49 +0900 Subject: [PATCH 06/11] test(aside): align aggregate status and privacy-safe fixtures --- tests/clients/aside-profile-paths.test.ts | 2 +- tests/server/management-integration-routes.test.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/clients/aside-profile-paths.test.ts b/tests/clients/aside-profile-paths.test.ts index 2856189f34..355a7de50a 100644 --- a/tests/clients/aside-profile-paths.test.ts +++ b/tests/clients/aside-profile-paths.test.ts @@ -43,7 +43,7 @@ describe("Aside profile manifest", () => { manifest(root, { currentAccountId: 1, accounts: accounts.map(account => ({ - ...account, session: { token: "fixture-private-value" }, email: "fixture@example.invalid", userId: "private-user", + ...account, session: { token: "fixture-private-value" }, email: "fixture@example.test", userId: "private-user", })), profileAccountBindings: [{ accountId: 0, profilePath: join(home, "not-a-target") }], }); diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 70d4ff3993..1f8cba92a2 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -245,10 +245,17 @@ describe("GET /api/client-integrations", () => { // The route must read through the SAME store the caller bound, or a test // that isolates writes still reads the developer's real snapshots. const models = await exportModels(); - expect(body.clients).toEqual(INTEGRATION_CLIENT_IDS.map(clientId => + expect(body.clients.filter(client => client.clientId !== "aside")).toEqual(INTEGRATION_CLIENT_IDS.filter(clientId => clientId !== "aside").map(clientId => JSON.parse(JSON.stringify(readIntegrationState({ clientId, models, config, port: 10100, store, env: routeEnv, home, }))))); + // Aside now returns an aggregate even when this fixture has no account manifest. + expect(body.clients.find(client => client.clientId === "aside")).toEqual({ + clientId: "aside", configPath: join(home, ".aside", "u"), + profiles: [], total: 0, enabledCount: 0, appliedCount: 0, allEnabled: false, + state: "unsafe", installed: false, reason: "unresolvable-path", + snapshotCount: -1, retentionDegraded: true, error: expect.any(String), + }); expect(text).not.toContain(REAL_LOOKING_KEY); }); From fb144284b173e307fd9a116196208bf3cf0d45d0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:21:25 +0900 Subject: [PATCH 07/11] fix(aside): register profile routes and CLI surface metadata --- .../ocx/references/01_management_surface.md | 44 ++++++++++++++++++- src/cli/capabilities.ts | 36 +++++++++++++++ src/server/management/aside-profile-routes.ts | 9 ++-- src/server/management/integration-routes.ts | 7 +-- src/server/management/route-registry.ts | 19 +++++++- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 754b323533..b88570a427 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -583,6 +583,46 @@ JSON mode: `payload`. - The list renders per-client state, installed, and desired columns; a blocked disable is named rather than left silent. - Each client has its own route because a toggle rewrites that client's own config file. +### `ocx integration client` + +Inspect and toggle Aside profile catalogs, read their history, and restore a selected profile operation. + +| Method | Route | +|---|---| +| GET | `/api/client-integrations/aside/profiles` | +| PUT | `/api/client-integrations/aside/profiles` | +| GET | `/api/client-integrations/aside/profiles/{profileId}` | +| PUT | `/api/client-integrations/aside/profiles/{profileId}` | +| GET | `/api/client-integrations/aside/profiles/journal` | +| GET | `/api/client-integrations/aside/profiles/{profileId}/journal` | +| POST | `/api/client-integrations/aside/profiles/{profileId}/restore` | + +| Flag | Value | Meaning | +|---|---|---| +| `--client` | string | Select the file integration; use aside for profile controls. | +| `--profile` | number | Select one registered Aside account; omitted toggles affect all profiles. | +| `--op` | string | Operation ID for restore. | +| `--confirm-drift` | boolean | Explicitly allow restore to replace subsequent edits. | +| `--overwrite-conflict` | boolean | Explicitly allow enable to replace a conflicting provider block. | +| `--json` | boolean | Emit the profile state, history, or mutation result as JSON. | + +JSON mode: `payload`. + +- Use status/show/list, history/journal, enable/disable, or restore after integration client. +- These declarations cover the dedicated Aside profile paths; existing generic client routes retain their separate parity inventory. + +### `ocx sync` + +Synchronize client catalogs, including Aside profiles through the running server's mutation owner. + +| Method | Route | +|---|---| +| POST | `/api/client-integrations/aside/sync` | + +JSON mode: `none`. + +- The Aside refresh uses the live server; other catalog synchronization also performs local work. + ### `ocx agent request-user-input` Show or set whether default mode may ask the operator a question mid-task. @@ -602,6 +642,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 33 -- of those, state-changing: 13 +- declared capabilities: 35 +- of those, state-changing: 15 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 91f4bbe368..1bb2b83352 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -526,6 +526,42 @@ export const CAPABILITIES: readonly Capability[] = [ "Each client has its own route because a toggle rewrites that client's own config file.", ], }, + { + command: ["integration", "client"], + summary: "Inspect and toggle Aside profile catalogs, read their history, and restore a selected profile operation.", + routes: [ + { method: "GET", path: "/api/client-integrations/aside/profiles" }, + { method: "PUT", path: "/api/client-integrations/aside/profiles" }, + { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}" }, + { method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}" }, + { method: "GET", path: "/api/client-integrations/aside/profiles/journal" }, + { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal" }, + { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore" }, + ], + flags: [ + { name: "--client", value: "string", summary: "Select the file integration; use aside for profile controls." }, + { name: "--profile", value: "number", summary: "Select one registered Aside account; omitted toggles affect all profiles." }, + { name: "--op", value: "string", summary: "Operation ID for restore." }, + { name: "--confirm-drift", value: "boolean", summary: "Explicitly allow restore to replace subsequent edits." }, + { name: "--overwrite-conflict", value: "boolean", summary: "Explicitly allow enable to replace a conflicting provider block." }, + { name: "--json", value: "boolean", summary: "Emit the profile state, history, or mutation result as JSON." }, + ], + mutates: true, + json: "payload", + details: [ + "Use status/show/list, history/journal, enable/disable, or restore after integration client.", + "These declarations cover the dedicated Aside profile paths; existing generic client routes retain their separate parity inventory.", + ], + }, + { + command: ["sync"], + summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", + routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], + flags: [], + mutates: true, + json: "none", + details: ["The Aside refresh uses the live server; other catalog synchronization also performs local work."], + }, { command: ["agent", "request-user-input"], summary: "Show or set whether default mode may ask the operator a question mid-task.", diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index a33b613130..941c37bbb2 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -22,6 +22,9 @@ export interface AsideProfileRouteOptions { class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } +const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside"; +const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles"; + function profileId(ctx: ManagementContext): number | undefined { const raw = ctx.url.searchParams.get("profile"); if (raw === null) return undefined; @@ -87,8 +90,8 @@ function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; export async function handleAsideProfileRoutes( ctx: ManagementContext, options: AsideProfileRouteOptions, ): Promise { - if (ctx.url.pathname !== "/api/client-integrations/aside" - && !ctx.url.pathname.startsWith("/api/client-integrations/aside/")) return null; + if (ctx.url.pathname !== ASIDE_INTEGRATION_PATH + && !ctx.url.pathname.startsWith(`${ASIDE_INTEGRATION_PATH}/`)) return null; try { const normalized = nestedProfileContext(ctx); ctx = normalized.ctx; @@ -120,7 +123,7 @@ export async function handleAsideProfileRoutes( const ok = results.every(result => result.ok); return jsonResponse({ ok, clientId: "aside", results }, ok ? 200 : 207, req, ctx.config); } - if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; + if (url.pathname !== ASIDE_INTEGRATION_PATH && url.pathname !== ASIDE_PROFILES_PATH) return null; if (req.method !== "GET" && req.method !== "PUT") return null; if (url.pathname.endsWith("/profiles") && id !== undefined) throw new ProfileQueryError("Use a profile-specific path"); if (req.method === "GET") { diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 83a3c3d464..b332718e07 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -47,6 +47,8 @@ import { loadExportModels } from "./model-rows"; const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/"; +const INTEGRATION_COLLECTION_PATH = "/api/client-integrations"; +const INTEGRATION_HISTORY_PATHS = ["/api/client-integrations/journal", "/api/client-integrations/restore"]; export { INTEGRATION_MUTATION_TERMINAL_MS }; type IntegrationStateRecord = Awaited>; @@ -422,9 +424,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise Date: Sun, 6 Sep 2026 05:27:43 +0900 Subject: [PATCH 08/11] docs(aside): explain server-owned synchronization requirements --- docs-site/src/content/docs/guides/integrations.md | 10 ++++++++++ skills/ocx/references/03_recipes.md | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index d12c038862..026c4f6d4d 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -222,6 +222,7 @@ provider selection, or presets also refresh connected Pi and Aside catalogs. Mis foreign-edited, unsafe, and never-owned blocks stay untouched; reconnect them explicitly. A refused or overlapping refresh is reported separately for each client. Start a new Pi session or fully quit and reopen Aside to load the updated file. +Aside refresh requires a [compatible running proxy](#aside-profile-controls). The separate MiniMax platform CLI (`mmx`) is not a file-toggle integration. Its text commands use MiniMax's Anthropic-compatible endpoint, so OpenCodex provides a @@ -249,6 +250,15 @@ for what was checked and when. ## Aside profile controls +Aside profile controls and the Aside refresh performed by `ocx sync` require a running +ocx proxy that supports the Aside profile APIs. Updating the CLI alone does not update an +already-running proxy. If the proxy is unavailable or too old, the Aside operation cannot +complete; the CLI never falls back to writing Aside profile files locally. + +Upgrade the ocx installation used by the proxy, then restart the proxy (or start it if it +is stopped). Retry `ocx sync` or the profile command. After the profile files update +successfully, fully quit and reopen Aside so it loads the new catalogs. + ```bash ocx integration client status --client aside --json ocx integration client enable --client aside diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index d482261932..065422d4fc 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -209,6 +209,11 @@ decision, not a broken connection. ## Aside profiles +These commands and the Aside refresh in `ocx sync` require a compatible running ocx proxy. +There is no local profile-file fallback when the server is unavailable or too old. Follow +the [proxy upgrade, restart, and retry sequence](https://opencodex.me/guides/integrations/#aside-profile-controls), +then fully quit and reopen Aside after its profile files update successfully. + ```bash ocx integration client status --client aside --json ocx integration client enable --client aside From 4778f4815fcf0f2e5c390e328b15d0e4bec49248 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:41:39 +0900 Subject: [PATCH 09/11] fix(cli): report Aside sync when the proxy is unavailable --- src/cli/dispatch.ts | 48 +++++++++++---------- src/cli/runtime-api.ts | 2 +- tests/cli/cli-dispatch.test.ts | 77 +++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 3b690f420c..e222339c24 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import type { CliHead } from "./root"; import type { ReadyArgs } from "./ready"; import type { LivenessIo, LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state"; import { syncModelsToCodex } from "../codex/sync"; @@ -389,31 +390,36 @@ const commandRunners: Record = { // `ocx sync` is a direct CLI path; it does not call the management // `/api/sync` route. Refresh already-connected file integrations here too, // after Codex has published the catalog that supplies its capabilities. - if (synced.status !== "refused" && live) { - try { - const config = deps.loadConfig(); - const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); - const results = await refreshOwnedCatalogIntegrations({ - models: async () => { - const { loadExportModels } = await import("../server/management/model-rows"); - return loadExportModels(config); - }, - config, - port: live.port, - }, ["mcode", "pi"]); + if (synced.status !== "refused") { + const results: OwnedIntegrationRefreshOutcome[] = []; + if (live) { try { - const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); - results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); + const config = deps.loadConfig(); + const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); + results.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("../server/management/model-rows"); + return loadExportModels(config); + }, + config, + port: live.port, + }, ["mcode", "pi"])); } catch (error) { - console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); - } - for (const result of results) { - const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; - if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); + console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } + } + // Even without a live proxy, report why Aside could not sync. Its server + // owner is never bypassed, and another client's failure cannot hide it. + try { + const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); + results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); } catch (error) { - console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } + for (const result of results) { + const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; + if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); } } return code; diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index f6d7353280..da919db765 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -44,7 +44,7 @@ export class RuntimeApiError extends Error { export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise { if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, ""); - const live = await findLiveProxy(); + const live = await (deps.findLiveProxy ?? findLiveProxy)(); if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null); return `http://${probeHostname(live.hostname)}:${live.port}`; } diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 88ef3ed85f..f3d102d1b9 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -3,13 +3,14 @@ import { CLI_COMMANDS } from "../../src/cli/registry"; import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideStartWithLiveOwner } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { runGuiCommand } from "../../src/cli/gui"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigDir } from "../../src/config"; import { getAccountSet, removeCredential, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import type { OcxConfig } from "../../src/types"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -94,6 +95,80 @@ describe("dispatchCommand exit codes", () => { } }); + test.each(["applied", "catalog-only", "refused"] as const)( + "sync with no live proxy reports Aside unavailability after Codex %s without local fallback", async status => { + const home = mkdtempSync(join(tmpdir(), "ocx-dispatch-aside-offline-")); + const previous = { OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const syncModule = await import("../../src/codex/sync"); + const catalogModule = await import("../../src/integrations/catalog-refresh"); + const livenessModule = await import("../../src/server/proxy-liveness"); + const warnings: string[] = []; + const logs: string[] = []; + const sync = spyOn(syncModule, "syncModelsToCodex").mockResolvedValue({ + status, ok: status !== "refused", added: 0, catalogPath: null, catalogExists: false, + catalogWritten: false, cacheSynced: false, message: "fixture Codex sync result", + }); + // The real Aside helper/runtime client must run. Fence the independent local + // writer and unscoped discovery so this regression cannot reach user files + // or a developer's real proxy if either dispatch boundary regresses. + const localRefresh = spyOn(catalogModule, "refreshOwnedCatalogIntegrations").mockResolvedValue([]); + // A globally discoverable proxy must not override the injected null result. + const unscopedDiscovery = spyOn(livenessModule, "findLiveProxy").mockResolvedValue({ + pid: null, port: 65534, hostname: "127.0.0.1", source: "config", + }); + const http = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected runtime HTTP request")); + const warn = spyOn(console, "warn").mockImplementation((...args) => { warnings.push(args.map(String).join(" ")); }); + const log = spyOn(console, "log").mockImplementation((...args) => { logs.push(args.map(String).join(" ")); }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + const config = { + port: 10100, providers: {}, defaultProvider: "openai", + asideProfileSync: { allProfiles: true, profiles: {} }, + } as OcxConfig; + const configPath = join(home, "config.json"); + const before = JSON.stringify(config); + writeFileSync(configPath, before); + let discoveries = 0; + const args = ["sync"]; + const deps = { + ...fakeDeps, args, loadConfig: () => config, + findLiveProxy: async () => { discoveries += 1; return null; }, + }; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, deps); + // An Aside warning does not change a successful Codex sync's exit code. + expect(code).toBe(status === "refused" ? 1 : 0); + expect(discoveries).toBe(1); + expect(sync).toHaveBeenCalledTimes(1); + expect(unscopedDiscovery).not.toHaveBeenCalled(); + expect(http).not.toHaveBeenCalled(); + expect(localRefresh).not.toHaveBeenCalled(); + if (status === "refused") { + expect(warnings).toEqual([]); + } else { + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Aside profiles were not refreshed:"); + expect(warnings[0]).toContain("Proxy is not running"); + expect(warnings[0]).toContain("ocx start"); + } + expect(logs.join("\n")).not.toContain("integration refreshed"); + expect(readFileSync(configPath, "utf8")).toBe(before); + expect(readdirSync(home).sort()).toEqual(["codex", "config.json"]); + expect(readdirSync(join(home, "codex"))).toEqual([]); + } finally { + sync.mockRestore(); localRefresh.mockRestore(); unscopedDiscovery.mockRestore(); http.mockRestore(); + warn.mockRestore(); log.mockRestore(); error.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }, + ); + test("returns 0 for help forms", async () => { expect(await dispatchCommand({ kind: "help", command: "help", args: ["help"] }, fakeDeps)).toBe(0); expect(await dispatchCommand({ kind: "help", command: "--help", args: ["--help"] }, fakeDeps)).toBe(0); From cea9d510095605f9d07e503667fd94e80b50dfb4 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:59:02 +0900 Subject: [PATCH 10/11] fix(aside): tighten profile sync and history contracts --- .../src/content/docs/guides/integrations.md | 2 +- .../ocx/references/01_management_surface.md | 5 + skills/ocx/references/03_recipes.md | 2 +- src/cli/capabilities.ts | 5 +- src/integrations/aside-profile-journal.ts | 12 ++- src/server/management/aside-profile-routes.ts | 8 +- tests/server/aside-profiles-routes.test.ts | 94 ++++++++++++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 026c4f6d4d..c3b47efe21 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -52,7 +52,7 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. -Aside keeps a separate model catalog for each account-backed browser profile. OpenCodex lists +Aside keeps a separate model catalog for each registered profile, including local profiles. OpenCodex lists all registered profiles, including local profiles, and can synchronize them together or control one profile at a time. Switching an integration never changes Aside's active account. A prior Aside connection enables all profiles by default; individual exclusions survive later syncs. diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index b88570a427..d5711f3cac 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -619,6 +619,11 @@ Synchronize client catalogs, including Aside profiles through the running server |---|---| | POST | `/api/client-integrations/aside/sync` | +| Flag | Value | Meaning | +|---|---|---| +| `--restart-codex` | boolean | Restart Codex app-servers after a catalog or cache write. | +| `--restart-desktop-app` | boolean | Restart the Codex desktop app after a catalog or cache write. | + JSON mode: `none`. - The Aside refresh uses the live server; other catalog synchronization also performs local work. diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 065422d4fc..85b734a9ce 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -223,6 +223,6 @@ ocx integration client restore --client aside --profile 1 --op ``` Read `profiles[]` to find numeric profile IDs. No profile selector means a bulk toggle; an -explicit selector affects only that account-backed profile. Sync intent and actual file state +explicit selector affects only that registered profile. Sync intent and actual file state are distinct, so inspect each result after a partial bulk operation. The CLI returns nonzero for a partial refusal. Never use the overwrite or drift flags merely to suppress a refusal. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 1bb2b83352..1b5cfd6283 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -557,7 +557,10 @@ export const CAPABILITIES: readonly Capability[] = [ command: ["sync"], summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], - flags: [], + flags: [ + { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." }, + { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." }, + ], mutates: true, json: "none", details: ["The Aside refresh uses the live server; other catalog synchronization also performs local work."], diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index f5cd228c90..0fd7f47183 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -19,11 +19,17 @@ export interface AsideOperation { function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] { const rows: AsideOperation[] = []; + const entriesByRoot = new Map(); for (const profile of selectAsideProfiles(ctx, profileId)) { const scope = asideProfileScope(ctx, profile); const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore]; for (const store of stores) { - for (const entry of store.listOperations("aside", Number.MAX_SAFE_INTEGER)) { + let entries = entriesByRoot.get(store.root); + if (entries === undefined) { + entries = store.listOperations("aside", Number.MAX_SAFE_INTEGER); + entriesByRoot.set(store.root, entries); + } + for (const entry of entries) { if (entry.clientId === "aside" && entry.configPath === profile.configPath) { assertAsideSnapshotEntry(entry); if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid"); @@ -131,7 +137,7 @@ function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: Integ const record = entry.priorRecord; if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false; const state = classifyIntegration({ - fileText: text, fileIsRegular: true, parsed: parseConfig(text, "json"), record, + fileText: text, fileIsRegular: true, parsed: parseConfig(text, EXPORT_CLIENTS.aside.format), record, contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)), configPath: entry.configPath, clientId: "aside", }).state; @@ -209,7 +215,7 @@ export function deleteAsideOperation( } await persistAsidePolicy(ctx); const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store])); - const tombstone = { tombstone: request.opId, at: new Date().toISOString(), by: request.principal ?? "management" }; + const tombstone = { tombstone: request.opId, at: new Date(input.io?.now() ?? Date.now()).toISOString(), by: request.principal ?? "management" }; // Retire every copy before pruning any snapshot; deduped history must not resurrect a source row. for (const store of stores.values()) store.retireOperation(tombstone); let snapshotRemoved = true; diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 941c37bbb2..8047e6c7a8 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -12,7 +12,7 @@ import { } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; import type { ManagementContext } from "./context"; -import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; export interface AsideProfileRouteOptions { @@ -52,8 +52,8 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -async function readProfileBody(req: Request): Promise { - try { return await readManagementJsonBody(req); } +async function readProfileBody(req: Request, optional = false): Promise { + try { return await (optional ? readOptionalManagementJsonBody(req) : readManagementJsonBody(req)); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } } @@ -117,7 +117,7 @@ export async function handleAsideProfileRoutes( if (url.pathname === "/api/client-integrations/aside/sync") { if (req.method !== "POST") return null; if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles"); - const body = await readProfileBody(req); + const body = await readProfileBody(req, true); if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object"); const results = await refreshAsideProfiles(options.input()); const ok = results.every(result => result.ok); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index b129e8f701..a21f0168b6 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; +import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegration } from "../../src/integrations/writer"; @@ -58,10 +59,13 @@ afterEach(() => { function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } async function api(pathname: string, method = "GET", body?: unknown) { + return rawApi(pathname, method, body === undefined ? undefined : JSON.stringify(body)); +} +async function rawApi(pathname: string, method: string, body?: string) { const url = new URL(`http://127.0.0.1:10100${pathname}`); const response = await handleManagementAPI(new Request(url, { method, headers: { Host: url.host, "content-type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + ...(body === undefined ? {} : { body }), }), url, config, { saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, createManagementConvergeCodex: catalogConvergenceFactory(), @@ -71,6 +75,94 @@ async function api(pathname: string, method = "GET", body?: unknown) { return response; } +async function prepareAsideSync(): Promise { + config.providers.fixture!.selectedModels = ["one"]; + const enabled = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/one"]); + // Change the runtime selection without triggering a different endpoint's sync. + config.providers.fixture!.selectedModels = ["two"]; +} + +function fixtureModelIds(id: number): string[] { + return document(id).providers.opencodex.models + .filter((model: { id: string }) => model.id.startsWith("fixture/")) + .map((model: { id: string }) => model.id); +} + +test.each([undefined, "{}"])("Aside sync accepts body %j and refreshes every enabled profile with HTTP 200", async body => { + await prepareAsideSync(); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, clientId: "aside", + results: [0, 1, 2].map(profileId => ({ client: "aside", profileId, ok: true, changed: true })), + }); + for (const id of [0, 1, 2]) { + expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } +}); + +test("bodyless Aside sync returns HTTP 207 for one conflict while refreshing its siblings", async () => { + await prepareAsideSync(); + const edited = document(1); + edited.providers.opencodex.baseUrl = "https://user-edit.example.test/v1"; + const editedBytes = JSON.stringify(edited); + writeFileSync(path(1), editedBytes); + const response = await api("/api/client-integrations/aside/sync", "POST"); + expect(response.status).toBe(207); + expect(await response.json()).toMatchObject({ + ok: false, clientId: "aside", results: [ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 1, ok: false, state: "conflict", refusalReason: "conflict" }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ], + }); + expect(readFileSync(path(1), "utf8")).toBe(editedBytes); + for (const id of [0, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ enabled: true, state: "conflict" }); +}); + +test.each(['{"enabled":true}', '{"profile":1}', '{"overwriteConflict":true}', "[]", "null", "true", "{"])( + "Aside sync rejects nonempty options or invalid JSON %s before mutation", async body => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "invalid_aside_profile", clientId: "aside" }); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); + }, +); + +test.each(["?profile=0", "?profile=invalid", "?client=pi"])("bodyless Aside sync rejects selector %s", async selector => { + expect((await api(`/api/client-integrations/aside/sync${selector}`, "POST")).status).toBe(400); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("Aside sync retains the JSON body size limit before accepting an empty-body fallback", async () => { + const response = await rawApi("/api/client-integrations/aside/sync", "POST", " ".repeat(MANAGEMENT_JSON_BODY_MAX_BYTES + 1)); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ error: "request body too large" }); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test.each(["/api/client-integrations/aside/profiles", "/api/client-integrations/aside/profiles/1"])( + "Aside PUT still requires its enabled body at %s", async pathname => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + expect((await api(pathname, "PUT")).status).toBe(400); + expect((await api(pathname, "PUT", {})).status).toBe(400); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + }, +); + test("legacy connection refreshes all profiles, and an individual off survives selection refresh and reload", async () => { expect(applyIntegration({ clientId: "aside", config, port: 10100, store, env, home, models: [{ provider: "fixture", id: "one", namespaced: "fixture/one" }] }).ok).toBe(true); From e60c1ff8468f0fe66bc7902a9399440ffe41932a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 06:13:02 +0900 Subject: [PATCH 11/11] fix(aside): keep recovery diagnostics in the backend stack layer --- src/cli/dispatch.ts | 2 +- src/cli/integrations.ts | 2 +- src/cli/runtime-api.ts | 7 +- src/integrations/aside-profiles.ts | 12 ++- src/integrations/owned-refresh.ts | 9 +- tests/cli/cli-headless-parity.test.ts | 82 +++++++++++++++++++ tests/clients/aside-profiles.test.ts | 20 +++++ .../clients/sync-client-integrations.test.ts | 17 ++++ 8 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index e222339c24..6d018536c7 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -419,7 +419,7 @@ const commandRunners: Record = { for (const result of results) { const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`); } } return code; diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index be957d56cf..bcb87d5d18 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -272,7 +272,7 @@ export async function handleClientIntegrationCommand( }, deps); const batch = result as { ok?: boolean; message?: string; results?: Array> }; printData(result, wantsJson, batch.results - ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}`) + ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}${row.residual === true ? " Recovery did not finish." : ""}${typeof row.snapshotPath === "string" ? ` Backup: ${row.snapshotPath}` : ""}`) : [String(batch.message ?? `${client} ${action}d.`)]); if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); }); diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index da919db765..7b05d56b9f 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -79,7 +79,12 @@ function responseMessage(body: unknown, status: number): string { if (reason && reason !== primary) parts.push(`reason: ${reason}`); const hint = stringField(record, "hint"); if (hint && hint !== primary) parts.push(`hint: ${hint}`); - return parts.join("\n").slice(0, 1200); + const snapshotPath = stringField(record, "snapshotPath"); + const recovery = [ + ...(snapshotPath ? [`Backup: ${snapshotPath.slice(0, 32768)}`] : []), + ...(record.residual === true ? ["Automatic recovery did not finish; check the client configuration before retrying."] : []), + ]; + return [parts.join("\n").slice(0, 1200), ...recovery].join("\n"); } export async function runtimeRequest( diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index bdea5a5cde..94514f253a 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -156,9 +156,19 @@ export function refreshAsideProfiles(input: AsideProfilesInput): Promise expect(log.mock.calls.flat().join("\n")).toContain("Open Aside to create a profile"); } finally { log.mockRestore(); } }); + +describe("Aside CLI recovery metadata", () => { + test.each([ + { name: "a long POSIX backup path", snapshotPath: `/tmp/aside-recovery/${"profile-2-snapshot/".repeat(80)}models.json.bak` }, + { name: "a Windows backup path with spaces and Unicode", snapshotPath: String.raw`C:\Aside Recovery\프로필 2\models.json.before-write` }, + { name: "no backup path", snapshotPath: undefined }, + ])("a refused restore preserves recovery guidance after a long message: $name", async ({ snapshotPath }) => { + const message = `Restore failed: ${"the profile file could not be replaced; ".repeat(80)}`; + const runtime = fakeRuntime(() => Response.json({ + ok: false, clientId: "aside", profileId: 2, state: "absent", + message, reason: "write_failed", residual: true, + ...(snapshotPath === undefined ? {} : { snapshotPath }), + }, { status: 500 })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand([ + "restore", "--client", "aside", "--profile", "2", "--op", "op-recovery", + ], runtime.deps)).toBe(1); + const stderr = error.mock.calls.map(call => String(call[0])).join("\n"); + expect(stderr).toContain("Restore failed:"); + // Recovery fields have their own output budget, after the bounded main message. + expect(stderr).not.toContain(message); + expect(stderr.split("\n")).toContain("Automatic recovery did not finish; check the client configuration before retrying."); + if (snapshotPath !== undefined) { + expect(stderr.split("\n")).toContain(`Backup: ${snapshotPath}`); + } else { + expect(stderr).not.toContain("Backup:"); + } + expect(log.mock.calls).toHaveLength(0); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles/2/restore", method: "POST", + body: { opId: "op-recovery", confirmDrift: false }, + }]); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); + + test.each([false, true])("bulk 207 retains each profile's recovery metadata and fails nonzero (json=%s)", async wantsJson => { + const snapshotPath = "/tmp/aside-recovery/profile 2/models.json.before-write"; + const otherSnapshot = String.raw`C:\Aside Recovery\profile 7\models.json.bak`; + const longMessage = `Profile 2 write failed: ${"could not replace models.json; ".repeat(80)}`; + const result = { + ok: false, clientId: "aside", message: "Three profiles could not be updated", + results: [ + { profileId: 0, ok: true, message: "updated" }, + { profileId: 2, ok: false, message: longMessage, reason: "write_failed", snapshotPath, residual: true }, + { profileId: 7, ok: false, message: "Profile 7 is conflicted", reason: "conflict", snapshotPath: otherSnapshot, residual: false }, + { profileId: 9, ok: false, message: "Profile 9 recovery failed", reason: "write_failed", residual: true }, + ], + }; + const runtime = fakeRuntime(() => Response.json(result, { status: 207 })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand([ + "enable", "--client", "aside", ...(wantsJson ? ["--json"] : []), + ], runtime.deps)).toBe(1); + const stdout = log.mock.calls.map(call => String(call[0])).join("\n"); + if (wantsJson) { + expect(JSON.parse(stdout)).toEqual(result); + } else { + // Exact rows catch dropped/truncated paths and metadata leaking to a sibling. + expect(stdout.split("\n")).toEqual([ + "aside:0 updated", + `aside:2 ${longMessage} Recovery did not finish. Backup: ${snapshotPath}`, + `aside:7 Profile 7 is conflicted Backup: ${otherSnapshot}`, + "aside:9 Profile 9 recovery failed Recovery did not finish.", + ]); + } + expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, + }]); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); +}); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index 35a60426d8..800ef329f2 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -76,6 +76,26 @@ describe("Aside profile desired state, ownership and history", () => { expect(saves).toBe(0); }); + test("sync retains backup and incomplete-recovery diagnostics for a failed profile", async () => { + seedLegacy(); + const io = store.io(); + let attempts = 0; + const outcomes = await refreshAsideProfiles(input({ models: models.slice(0, 1), + store: { ...store, putRecord() { throw new Error("synthetic ownership failure"); } }, io: { + ...io, + writeText(target, text) { + if (target !== path(0)) return io.writeText(target, text); + attempts += 1; + if (attempts === 1) return io.writeText(target, text); + throw new Error("synthetic write and compensation failure"); + }, + } })); + const failure = outcomes.find(row => row.profileId === 0); + expect(failure).toMatchObject({ ok: false, refusalReason: "write_failed", residual: true }); + expect(failure?.snapshotPath).toBeString(); + expect(existsSync(failure!.snapshotPath!)).toBe(true); + }); + test("legacy connection defaults all profiles on and refresh shares one catalog load", async () => { seedLegacy(); expect((await listAsideProfileStates(input())).enabledCount).toBe(3); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index bf78bd65c6..ffc00a16f8 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -160,6 +160,23 @@ describe("ocx sync refreshes an already-owned MCode integration", () => { expect(store.listOperations("mcode")).toHaveLength(0); }); + test("retains recovery details when refresh bookkeeping and compensation both fail", async () => { + expect(applyIntegration(input(oldModels)).ok).toBe(true); + const io = store.io(); + let writes = 0; + const result = await refreshOwnedIntegration({ ...input(newModels), io: { + ...io, + writeText(path, text) { + if (path === configPath && ++writes > 1) throw new Error("synthetic rollback failure"); + io.writeText(path, text); + }, + putRecord() { throw new Error("synthetic ownership failure"); }, + } }); + expect(result).toMatchObject({ client: "mcode", ok: false, refusalReason: "write_failed", residual: true }); + expect(result?.snapshotPath).toBeString(); + expect(result?.reason).toContain("could not be rolled back"); + }); + test("refuses a foreign edit without changing bytes or appending a journal row", async () => { expect(applyIntegration(input(oldModels)).ok).toBe(true); const recordBefore = JSON.stringify(store.readRecords().mcode);