diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md b/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md new file mode 100644 index 0000000000..ff81cf010a --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md @@ -0,0 +1,46 @@ +# Aside client + Integrations UX repair + +Unit opened 2026-08-31. Two outcomes travel together because they land on the +same page: Aside becomes an export/integration client, and the Integrations +("연결") surface stops flooding itself with rollback rows. + +They are one unit rather than two because the Aside work ADDS a twelfth card to +a page that is already too crowded to absorb one. Shipping the client first +would make the page measurably worse before it got better. + +## The two problems + +**Aside is unsupported.** Aside is a Chromium fork with a built-in browser +agent. Its custom-provider catalog lives at `~/.aside/u//models.json` +and its schema is the one Pi reads. The user on this machine already wired +opencodex into it BY HAND: the live file carries a `providers.opencodex` block +with 24 routed models, `api: "openai-completions"`, and +`apiKey: "opencodex-loopback"` — byte-identical to what `buildPiClientConfig` +emits. A hand-maintained integration is the strongest possible argument that +the client belongs in the registry. + +**The Integrations page floods.** The rollback journal renders up to 50 rows, +each with its own border, at the bottom of the overview AND again on every file +client tab. The user's words were "로그 밑에 막 다닥다닥 뜨는 히스토리" — the +per-row borders are literally what produces that texture. + +## Work phases + +| Phase | Doc | Deliverable | +|---|---|---| +| wp1 | this unit | Research and roadmap (docs only) | +| wp2 | 010 | Aside export client + integration registry | +| wp3 | 020 | Aside GUI surface, marks entry, nine locales | +| wp4 | 030 | Rollback surface redesign | +| wp5 | 040 | Brand marks for the nine clients showing a monogram | +| wp6 | 050 | Stacked PR chain | + +Research docs: 001 (Aside contract), 002 (registration checklist), +003 (Integrations UX diagnosis), 004 (brand mark provenance). + +## Ordering constraint + +wp4 and wp5 do not depend on wp2/wp3, and wp3 depends on wp2. The stack is +therefore not a single line: the Aside pair (wp2 then wp3) and the page repair +pair (wp4, wp5) are independent chains that both branch off `dev`. wp6 puts +them in review order. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md b/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md new file mode 100644 index 0000000000..d0a94cdd5c --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md @@ -0,0 +1,158 @@ +# What Aside actually reads + +Observed on this machine, 2026-08-31, against Aside CLI `1.26.810.1915` and app +bundle `1.0.825.1`. Every claim below was read off a real file or a live process, +not inferred from documentation. + +## Account roots + +`~/.aside/accounts.json` (mode 600) carries: + +```json +{ "version": ..., "currentAccountId": 0, "accounts": [ { "id": 0, "email": "..." }, { "id": 1 } ], ... } +``` + +Per-account state lives at `~/.aside/u//`. Both `u/0` and `u/1` exist here, +so multi-account is not hypothetical. The provider catalog is +`~/.aside/u//models.json`. + +**No environment override exists.** `strings` over the CLI binary yields +`ASIDE_DAEMON_BASE_URL`, `ASIDE_CLI_*`, `ASIDE_MCP_*`, `ASIDE_PRODUCT_VARIANT`, +`ASIDE_RELEASE_BASE_URL`, `ASIDE_NATIVE_HELPER_BINDING` — and nothing that +relocates the account root. The only literal `.aside` path in the binary is +`path.join(os.homedir(), ".aside", "cli", "update-check.json")`. + +This makes Aside unlike every existing client. `dshHomeDir` honors `DSH_HOME`; +`mcodeHomeDir` honors `MINIMAX_DATA_DIR` then `MAVIS_DATA_DIR`; `piAgentDir` +honors `PI_CODING_AGENT_DIR`. Aside has no such variable to honor, so the +resolver's variable is the ACCOUNT ID, not a path. + +### Decision: read the manifest, fail closed, add no opencodex env var + +A resolver that only reads `~/.aside` cannot be tested without writing to the +user's real Aside install. Every existing path helper takes `(env, home)` and +the tests redirect `home`; Aside does the same, so the root is +`join(home, ".aside")` and tests redirect `home` exactly as +`tests/prime-client.test.ts` does. + +An earlier draft added `OPENCODEX_ASIDE_ACCOUNT` so a user could target a +non-current account. **Dropped after audit.** This registry's contract is to +honor each client's OWN override (`registry.ts:45`) — `DSH_HOME`, +`MINIMAX_DATA_DIR`, `PI_CODING_AGENT_DIR` all belong to their clients. Aside +ships no such variable, so inventing an opencodex-namespaced one is new product +behavior dressed up as path resolution. Account selection, if it is ever wanted, +is its own unit with its own surface. + +We write the account Aside itself reports as current, and nothing else. + +**Fail closed rather than fall back.** An earlier draft defaulted to account `0` +when `accounts.json` was missing or unparseable. On this machine both `u/0` and +`u/1` exist, so that fallback could name a real config file belonging to the +WRONG account and then pass the installation gate — a silent write into another +account's catalog. Instead: + +- Manifest present with a non-negative integer `currentAccountId`: use it. +- Manifest absent, unreadable, unparseable, or the id malformed: throw + `ClientPathError`. The surface reports the client as unavailable with a real + reason, exactly as an unresolvable `DSH_HOME` does today. + +A missing manifest means Aside has not established which account is current, and +there is no honest value to guess. + +**Resolve both paths from one account read.** `freezeIntegrationInput` called +`configPath` and `detectDir` separately (`writer.ts:621`), and `readIntegrationState` +did the same. Both now depend on file contents rather than only `env` and `home`, +so a manifest rewritten between the two calls could verify one account's install +and then write a different account's catalog. + +A cache was the first idea and it does not work: any cache keyed on the manifest +re-reads exactly when the manifest changes, which is the case the consistency is +needed for, and nothing tells a path helper when an operation ends. The audit +caught that contradiction. + +The fix is a seam instead. `resolveIntegrationPaths(clientId, env, home)` in the +integration registry returns the PAIR, and an optional `resolvePaths` on a client +spec lets Aside derive both from a single `asideAccountDir` call. Every other +client keeps the default behavior, so the pair stays correct for them without +any of them knowing why the seam exists. + +## The provider block + +The live `~/.aside/u/0/models.json`, provider keys in their on-disk order with +values elided: + +```json +{ "providers": { "opencodex": { + "baseUrl": "http://127.0.0.1:10100/v1", + "apiKey": "opencodex-loopback", + "api": "openai-completions", + "models": [ { "id": "...", "name": "...", "reasoning": true, + "thinkingLevelMap": { "off": null, ..., "max": "max" }, + "input": ["text","image"], "contextWindow": 1000000, + "maxTokens": 32000 } ] +} } } +``` + +Four provider keys and 24 model entries. `thinkingLevelMap` uses the same seven +pi levels (`off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`) with `null` for +levels the model does not declare. `input` is `["text","image"]` throughout, or +`["text"]` alone. + +### The same key SET, not the same byte order + +An earlier draft called this file "byte-identical" to `buildPiClientConfig` +output. That was wrong, and the audit caught it. + +The builder emits `baseUrl`, `api`, `apiKey`, `models` +(`config-export.ts:1038`); the hand-written file has `baseUrl`, `apiKey`, +`api`, `models`. Model entries differ the same way — the builder writes `input` +before the optional reasoning fields, the live file after. `serializeDocument` +preserves insertion order, so the emitted bytes really do differ. + +The true claim is weaker and sufficient: **the same four provider keys, the same +dialect string, the same placeholder, and the same model field vocabulary.** JSON +key order is not semantic and Aside parses this file rather than diffing it. What +the builder produces is a document Aside accepts; it is not the document a human +happened to type. + +So this unit claims compatibility, not equality. The test backing it must NOT be +`buildAside(ctx) === buildPi(ctx)`: both call the same function, so that +assertion is tautological. wp2 asserts against a fixture captured from the +observed Aside shape — same key set, same dialect, placeholder rather than a +credential, model fields drawn from the observed vocabulary. + +## Reuse, and the one thing not to reuse + +`prime` set the precedent: it reuses `buildPiClientConfig` and `summarizePi` +verbatim and adds only `buildPrimeContribution`, so ownership records carry +`clientId: "prime"`. Aside follows that exactly. Restating the shape would +create a second copy of one fact, which is the bug that comment warns about. + +## loopbackOnly: true + +The provider block has four keys and none of them is `headers`. A dedicated +`x-opencodex-api-key` header has nowhere to live, so a non-loopback bind would +generate a config that 401s. Same reasoning, same verdict as `dsh`, `kimi`, +`gajae`, `mcode`, and `zcode`. + +`apiKeyEnv` is therefore `""` and `exportHint` says loopback needs no key. + +## Hazard: Aside overwrites the file while running + +The Aside skill reference states that editing `models.json` while Aside is +running risks the daemon overwriting it, and `Aside Daemon` was live during +this investigation. The pattern for this already exists: Claude Desktop's copy +says "Fully quit and reopen it for this change to take effect" +(`integrations.dialog.desktop.restart`). Aside gets the same treatment in its +`integrations.semantics.aside` string. + +This is a copy problem, not a writer problem. The writer already snapshots +before every mutation and journals what it did, so an overwrite by Aside is +recoverable the same way any drift is. + +## Not in scope + +`~/.aside/u/0/models.json` can hold a plaintext key for a user's OWN providers, +and `credentials.json` certainly does. We write one fragment, +`providers.opencodex`, and the merge layer touches nothing else. No Aside +credential is ever read, printed, or serialized. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md b/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md new file mode 100644 index 0000000000..e594069d08 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md @@ -0,0 +1,72 @@ +# Every surface a new export client must reach + +Derived from `git show c6fa2563d` (the `prime` client, 29 files) plus the +invariant tests. This is the checklist wp2 and wp3 execute. + +## Backend (wp2) + +- `src/clients/config-export.ts`: `asideHomeDir`/`asideAccountDir`/ + `asideConfigPath` helpers, `"aside"` in `ExportClientId`, + `buildAsideContribution`, and the `EXPORT_CLIENTS.aside` spec. The spec needs + all nine fields: `filename`, `destination`, `apiKeyEnv`, `exportHint`, + `build`, `format`, `summarize`, `buildContribution`, `loopbackOnly`. +- `src/integrations/registry.ts`: `INTEGRATION_CLIENTS.aside` with `configPath` + and `detectDir`. No `sourcePreservingYaml` (JSON) and no `writerLock`, same as + `pi` and `prime`. +- `src/cli/registry.ts`: the `export` entry's static `usage`/`summary` client + union. Acceptance itself comes from `EXPORT_CLIENT_IDS` via + `isExportClientId` in `src/cli/export-command.ts`, so this is help text only. +- `tests/aside-client.test.ts`: new, modeled on `tests/prime-client.test.ts`. + +`bun run skill:surface` is NOT implicated: its generator reads `CAPABILITIES`, +and a new `--client` value creates no capability. + +## Existing tests that assert exact lists (wp2) + +These fail until updated, which is the point: + +- `tests/client-config-export.test.ts`: ordered `EXPORT_CLIENT_IDS`. +- `tests/client-config-export-new-clients.test.ts`: the loopback-only set. +- `tests/integrations-invariants.test.ts`: the client count, and `SEED` is a + `Record` so typecheck forces an Aside fixture in + Aside's own JSON shape. +- `tests/integrations-state.test.ts`: the loopback-only set. + +## GUI (wp3) + +Five surfaces the invariant test compares against `EXPORT_CLIENT_IDS`: +`INTEGRATION_CLIENT_IDS`, the GUI `CLIENTS` tuple, `CLIENT_LABEL_KEYS` keys, +`FILE_INTEGRATION_CLIENTS`, and the hashes in `INTEGRATION_TAB_HASHES`. + +Plus three exhaustive `Record` maps that +typecheck catches: `FILE_LABEL_KEY` in `overview-clients.ts`, and +`SEMANTICS_KEY` + `TAB_LABEL_KEY` in `FileIntegrationPage.tsx`. + +**The two silent hazards.** `TABS` and `FILE_CLIENTS` in +`gui/src/pages/Integrations.tsx` are NOT exhaustive records and NOT covered by +the invariant test. Omitting Aside from either leaves typecheck and the +invariants green while the tab silently does not render. wp3 asserts both in a +GUI test rather than trusting the compiler. + +## i18n (wp3) + +Three keys across nine locales (`en`, `de`, `fr`, `ja`, `ko`, `ru`, `tr`, `zh`, +`zh-TW`): `integrations.tab.aside`, `integrations.semantics.aside`, +`api.clientConfig.clientAside`. + +"Aside" is a product name, so the tab and client labels stay English in every +locale. That means adding them to `ZH_TW_KEEP_ENGLISH` in +`gui/tests/locale-parity.test.ts` and `INTENTIONAL_ENGLISH` in +`gui/tests/fr-localization.test.ts`. The semantics string is prose and IS +translated. + +## Docs (wp3) + +`docs-site/.../reference/cli/agents.md` client union, flag table, and +destination table; `docs-site/.../guides/integrations.md` client table. Commit +`42adf4996` established that translated CLI reference pages are synchronized +too. + +The integrations guide currently lists ten clients and omits `zcode` — a real +gap found during this research. wp3 adds the missing `zcode` row alongside +`aside` rather than leaving a known hole next to a new entry. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md b/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md new file mode 100644 index 0000000000..e60c2e7c06 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md @@ -0,0 +1,96 @@ +# Why the Integrations page reads as noise + +Read against the current tree, 2026-08-31. + +## What the user is seeing + +"로그 밑에 막 다닥다닥 뜨는 히스토리" — the rollback journal. The real numbers: + +- The route `/api/client-integrations/journal` accepts only `client`. There is + no HTTP `limit` parameter; `?limit=` is ignored + (`src/server/management/integration-routes.ts:313`). +- It calls `store.listOperations()` with no limit, so `listOperations`' own + default applies: **50 rows**, newest first (`src/integrations/journal.ts:138`). +- Both consumers render the ENTIRE response with no slice — + `IntegrationsOverview.tsx:561` and `FileIntegrationPage.tsx:196`. +- Every row carries its own `1px` border and `border-radius` + (`styles-integrations.css:62`). Fifty bordered strips stacked at 6px gaps is + precisely the "다닥다닥" texture. + +So the flooding is real, and it is worse than one list: the overview shows the +global journal and every file client tab shows the same journal filtered. The +same operation is rendered twice in two places. + +Two related facts worth recording. Snapshot retention is 10 per client +(`journal.ts:54`), so of 50 visible rows at most 10 are restorable and the rest +render an "expired" badge — the list is mostly inert. And journal ROWS are never +pruned, so `journal.jsonl` is parsed in full on every request before the slice. + +## Defects, worst first + +1. **History floods and duplicates.** Above. +2. **Loading, failure, and empty are indistinguishable.** Both components do + `data ?? []` and then render the empty state, so a cold fetch, a failed + fetch, and a genuinely empty journal look identical. No retry, no stale + warning — even though `useDataSurface` exposes `state.kind` for exactly this. +3. **Undo is buried.** On the overview it sits below the summary, the API-key + row, onboarding copy, up to 15 cards, and an empty panel. The most valuable + recovery action on the page is viewports away from the switch that caused it. +4. **RestoreDialog is not modal.** It renders `` with inline + full-screen styles instead of `showModal()`, so background controls stay + reachable and focus is neither trapped nor restored. + `ConsequenceDialog.tsx:34` in the same directory does it correctly. +5. **Summary claims exceed its data.** "Last change" reads only the file-client + journal, so a Codex/Claude/Desktop/Grok change is invisible. Counts paint + zero while sources are still unsettled. The "no clients detected" panel tests + only FILE clients but its copy does not say so. +6. **Heading levels skip.** Overview goes `h2` straight to `h4` with no `h3`. + CSS targets `.integration-client-head h4` while the JSX renders `h3`. +7. **Card saturation.** No literal card-in-card, but a raised summary, a raised + API row, 15 bordered cards, a bordered empty panel, and 50 bordered history + rows give every level the same visual weight. +8. **No responsive block at all** in `styles-integrations.css`. + +## Patterns already in this repo + +Nothing here needs a new design system. + +- **Bounded pagination:** Claude Desktop reveals six rows at a time behind a + `btn btn-ghost btn-sm` show-more (`claude-desktop-lane.ts:11`, + `ClaudeDesktop.tsx:671`). `LANE_PAGE = 6` is the local precedent. +- **Disclosure:** `Logs.tsx:1112` uses native `
/` for + secondary detail. There is no generic Accordion component, and adding one is + out of scope. +- **State branching:** `DataSurfaceSkeleton`, `DataSurfaceStatus`, + `EmptyState`, `Notice` already exist. +- Virtualization (`Logs.tsx:518`) is overkill for at most 50 rows. + +## The redesign (wp4) + +**Overview.** Drop the journal block entirely. In its place, one unframed +"latest change" line directly below the summary: client, operation, time, and +its Undo or Restore-point action. Recovery moves above the fold and the +summary's "last change" scope becomes visible instead of implied. + +**Client tab.** Newest row stays visible next to the status and path. Older rows +move into a collapsed-by-default `
`, revealed six at a time. Expired +rows live only inside that disclosure, so the visible surface is the part that +can actually be undone. + +No total count is displayed: the API caps at 50 and returns neither `total` nor +`hasMore`, so any number shown would be a claim we cannot support. + +**Shared component.** The row JSX is currently duplicated in both files with +their own `KIND_KEY` maps. wp4 extracts one integrations-domain component. + +**Styling.** One list boundary with `border-top` separators instead of a border +per row. Fix `.integration-client-head h4` to `h3`, add `flex-wrap`, add a +narrow-viewport rule. + +**Also in wp4.** `RestoreDialog` adopts `ConsequenceDialog`'s modal lifecycle, +and the history resources branch on `state.kind` so cold, failed, and empty +stop looking alike. + +An HTTP `limit` parameter is deliberately NOT in wp4. It would shrink the +payload without fixing the full-file parse or the unbounded on-disk journal, and +the UI cap makes it unnecessary for this complaint. Recorded as a follow-up. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md b/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md new file mode 100644 index 0000000000..40f7ff2dec --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md @@ -0,0 +1,73 @@ +# First-party marks for the clients showing a monogram + +`CLIENT_MARKS` (`gui/src/components/apikeys-workspace/client-config-clients.ts`) +covers 2 of 11 clients. The other nine render +`{label.slice(0, 1)}` +(`ClientConfigRow.tsx:91`). + +The file's own rule: "a client with none falls back to a monogram tile rather +than borrowing another product's logo." So every entry below needs a mark that +belongs to that product, verified, or it stays a monogram. + +## Verified sources + +Each URL below was fetched in a real browser session on 2026-08-31 and returned +the content-type shown. This is live evidence, not a guess from a repo comment. + +| Client | Product | Asset | Type | +|---|---|---|---| +| `omp` | Oh My Pi (`can1357/oh-my-pi`) | `https://omp.sh/favicon.svg` | `image/svg+xml` | +| `hermes` | Hermes Agent (`NousResearch/hermes-agent`) | `.../hermes-agent/main/website/static/img/favicon.svg` | `image/svg+xml` | +| `openclaw` | OpenClaw (`openclaw/openclaw`) | `.../openclaw/main/ui/public/favicon.svg` | `image/svg+xml` | +| `dsh` | DeepSeek Harness (`deepseek-ai/deepseek-harness`) | `.../deepseek-harness/master/website/public/favicon.svg` | `image/svg+xml` | +| `prime` | Prime Agent (`PrimeIntellect-ai/prime-agent`) | `.../prime-agent/main/assets/brand/prime-butterfly.svg` | `image/svg+xml` | +| `zcode` | ZCode (Z.ai) | `https://z-cdn.chatglm.cn/z-ai/static/logo.svg` | recorded in `_fin/260705` notes | + +Two answers that settled open questions: + +**`dsh` is first-party DeepSeek.** The repo never named a publisher, which is +why `deepseek-color.svg` could not simply be reused. Live check: DeepSeek +publishes `deepseek-ai/deepseek-harness` and scopes its packages +`@deepseek-ai/dsh-*`. The bare npm `dsh` package is unrelated +(`infusion/node-dsh`, 2016). So the harness has its own first-party favicon and +we use that rather than the provider logo. + +**`prime` has its own mark.** The `prime-butterfly.svg` in the prime-agent repo +resolves the note in `config-export.ts` that Prime is "the pi coding agent +shipped under a different brand" — the brand has an asset, so `pi.svg` must not +be reused for it. + +## Reuse instead of fetching + +`kimi`: `gui/public/provider-icons/kimi-color.svg` is already committed and is +the same Moonshot AI brand as the Kimi Code client +(`_fin/260705_provider-quota-dashboard/svg-candidates/manifest.json:41`). Point +the client at the existing asset; add no file. + +## Not resolved + +`gajae` (Gajae Code, `Yeachan-Heo/gajae-code`) publishes a mascot PNG, a +vertical logo PNG, and a base64 PNG favicon — no SVG anywhere, and the npm +package ships no icon. Every committed asset in `provider-icons/` is SVG. + +wp5 keeps `gajae` on the monogram and records the reason here. A PNG could be +committed, but it would be the only raster mark in the set and would not scale +with the 20px `` at other densities. This is the one BLOCKED item in the +unit, and it is blocked on the upstream project having no vector mark rather +than on anything we can fix. + +## Aside's own mark + +Aside ships `/Applications/Aside.app/Contents/Resources/app.icns`, which is a +local macOS icon resource rather than a distributable brand asset, so wp5 checks +for a first-party web asset the same way as the others before assigning one. If +none is verified, Aside launches on the monogram and gains its mark later — +a missing mark must not block the client. + +## README obligation + +`gui/public/provider-icons/README.md` records provenance per asset, following +the `pi.svg` precedent: asset name, fetch date, source URL, what the project is, +and whether it was modified. Its licensing note points at +`devlog/_plan/260705_provider-quota-dashboard/...`, which has since moved to +`_fin/` — wp5 fixes that stale path while it is in the file. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md b/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md new file mode 100644 index 0000000000..f0d9bb5ba0 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md @@ -0,0 +1,168 @@ +# wp2 — Aside export client and integration registry + +Backend only. No GUI file changes; wp3 owns those. Contract read in 001, +checklist in 002. + +## src/clients/config-export.ts + +**Path helpers**, placed beside `dshHomeDir`/`mcodeHomeDir`: + +```ts +/** Aside's per-account state root. */ +export function asideHomeDir(env = process.env, home = homedir()): string { + return join(home, ".aside"); +} + +/** + * The account Aside reports as current. + * + * Unlike every other client here, Aside has no path override to honor: its CLI + * carries ASIDE_DAEMON_BASE_URL and friends but nothing that moves the account + * root. So the variable is the ACCOUNT, and Aside's own manifest is the only + * authority on it. We add no opencodex-namespaced override: this registry + * mirrors each client's variables and does not invent new ones. + * + * Throws when the manifest cannot answer. Defaulting to 0 would be a guess, and + * on a machine with u/0 and u/1 that guess writes into the wrong account's + * catalog while still passing the installed check. + */ +function asideCurrentAccountId(home: string): number + +export function asideAccountDir(env = process.env, home = homedir()): string + // join(asideHomeDir(env, home), "u", String(asideCurrentAccountId(home))) + +export function asideConfigPath(env = process.env, home = homedir()): string + // join(asideAccountDir(env, home), "models.json") +``` + +`asideCurrentAccountId` reads `/accounts.json` and returns +`currentAccountId` when it is a non-negative integer. A missing, unreadable, or +unparseable manifest, or a malformed id, throws `ClientPathError` naming the +manifest. That is the same failure shape `absoluteClientPath` uses for a bad +`DSH_HOME`, and the surfaces already render it as an unavailable client. + +**One account read per operation, via a registry seam.** `freezeIntegrationInput` +resolved `configPath` and `detectDir` in two separate calls (`writer.ts:621`), +and `readIntegrationState` did the same (`state.ts:385`). These resolvers read a +mutable file, so a switch between the calls could verify one account and write +another. + +Memoization was the first proposal and the audit rejected it correctly: a cache +invalidated by the manifest's mtime re-reads precisely when the manifest changes, +and a path helper has no way to know when an operation ends. + +So the pair becomes one call. `IntegrationClientSpec` gains an optional +`resolvePaths`, `resolveIntegrationPaths(clientId, env, home)` is the only place +that turns an id into both paths, and Aside implements `resolvePaths` by calling +`asideAccountDir` once and joining `models.json` onto it. Both writer and state +call the seam; every other client falls through to the previous behavior. + +Reading a file inside a path helper is not new: this module already calls +`existsSync` at four resolution sites (lines 236, 335, 337, 380). Parsing +contents rather than probing existence is the new part, which is why the failure +is explicit and the result is memoized. + +**Contribution builder**, beside `buildPrimeContribution`: + +```ts +function buildAsideContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} +``` + +A comment records WHY the Pi builder is reused: the live +`~/.aside/u/0/models.json` on the machine this was developed on already carried +a hand-written `providers.opencodex` block whose four keys, dialect, +`opencodex-loopback` placeholder, and `thinkingLevelMap` levels match +`buildPiClientConfig` exactly. Same argument prime's comment makes, with live +evidence instead of a package manifest. + +**`ExportClientId`**: add `| "aside"`. + +**`EXPORT_CLIENTS.aside`**, after `prime`: + +```ts +aside: { + id: "aside", + filename: "aside-models.json", + destination: env => asideConfigPath(env), + apiKeyEnv: "", + exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildAsideContribution, + loopbackOnly: true, +}, +``` + +`loopbackOnly: true` because the provider block has exactly four keys and none +is `headers`, so the dedicated admission header has nowhere to live. The comment +says that rather than restating the general rule. + +`filename` is `aside-models.json` and not `models.json`: the download name is +what lands in a user's Downloads folder, where a bare `models.json` collides +with pi's and prime's. Prime set this precedent with `prime-models.json`. + +## src/integrations/registry.ts + +Import `asideConfigPath` and `asideAccountDir`; add: + +```ts +aside: { + id: "aside", + configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home), + // The ACCOUNT directory, not ~/.aside: the CLI creates ~/.aside/cli for its + // own update check before any account exists, so the outer directory can be + // present on a machine that never signed in. + detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home), +}, +``` + +No `sourcePreservingYaml` (JSON) and no `writerLock`, same as `pi`/`prime`. + +## src/cli/registry.ts + +Add `aside` to the `export` command's `usage` and `summary` client union. +Acceptance is already dynamic through `EXPORT_CLIENT_IDS`. + +## tests/aside-client.test.ts (new) + +Modeled on `tests/prime-client.test.ts`, which locks seven properties. Aside's +cases: + +1. The generated document matches a FIXTURE captured from the observed Aside file + shape: four provider keys, `api: "openai-completions"`, the loopback + placeholder, and model fields from the observed vocabulary. Deliberately NOT + an equality check against `buildPiClientConfig`, which would be tautological + since Aside calls it. See 001 for why key order differs and why that is fine. +2. The owned document is `providers.opencodex` with `baseUrl`, `api: + "openai-completions"`, `apiKey: "opencodex-loopback"`, `models`. +3. Serialized JSON round-trips and contains no credential. +4. Contribution path is `["providers", "opencodex"]` with `clientId: "aside"`. +5. `currentAccountId: 0` resolves `/.aside/u/0/models.json`, and + `currentAccountId: 1` resolves `u/1`. +6. Absent, unreadable, unparseable, and malformed-id manifests each throw + `ClientPathError` — all four asserted, because the point is that none of them + silently picks an account. +7. `resolveIntegrationPaths("aside")` returns a config path that is the detect + directory plus `models.json`, so the two cannot name different accounts; a real + switch moves both together. A pure-path client still resolves through the + same seam. +8. `detectDir` is the account directory, so `~/.aside/cli` alone is not + "installed". +9. `loopbackOnly` is true and `apiKeyEnv` is empty. + +## Existing tests to update + +`client-config-export.test.ts` (ordered ids), `client-config-export-new-clients +.test.ts` (loopback-only set), `integrations-invariants.test.ts` (count plus an +`aside` `SEED` in real JSON shape with a user-owned sibling provider that must +survive), `integrations-state.test.ts` (loopback-only set). + +## Verification + +`bun test tests/aside-client.test.ts tests/client-config-export.test.ts +tests/client-config-export-new-clients.test.ts tests/integrations-invariants.test.ts +tests/integrations-state.test.ts` plus `bun run typecheck`. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md b/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md new file mode 100644 index 0000000000..a20b4ac208 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md @@ -0,0 +1,83 @@ +# wp3 — Aside GUI surface + +Depends on wp2. Five invariant-compared lists, three exhaustive maps, two +hand-maintained lists the compiler cannot see, three i18n keys times nine +locales, and the docs tables. + +## Invariant-compared (test fails if omitted) + +- `gui/src/components/apikeys-workspace/client-config-clients.ts`: `"aside"` in + `CLIENTS`, `aside: "api.clientConfig.clientAside"` in `CLIENT_LABEL_KEYS`. +- `gui/src/pages/integrations/integration-api.ts`: `"aside"` in + `FILE_INTEGRATION_CLIENTS`, which widens `FileIntegrationClientId`. +- `gui/src/app-routing.ts`: `"integrations/aside"` in `INTEGRATION_TAB_HASHES`. + +## Typecheck-enforced maps + +- `overview-clients.ts`: `FILE_LABEL_KEY.aside`. +- `FileIntegrationPage.tsx`: `SEMANTICS_KEY.aside`, `TAB_LABEL_KEY.aside`. + +## The two lists nothing checks + +`TABS` and `FILE_CLIENTS` in `gui/src/pages/Integrations.tsx` are plain arrays, +not exhaustive records, and the invariant test does not read them. Omitting +Aside from either leaves every gate green and the tab simply absent. + +Closed concretely: `gui/tests/integrations-tab-coverage.test.ts` (new) imports +`FILE_INTEGRATION_CLIENTS` and asserts every id appears in both `TABS` and +`FILE_CLIENTS`, deriving the expectation rather than restating a literal list, so +the next client added cannot repeat the omission. That file is named in the +verification command below. + +## Existing GUI tests that must be updated + +These carry exact literals and fail until edited. wp3 owns each edit rather than +discovering it at run time: + +- `gui/tests/integrations-overview-rows.test.ts:224`: the unsettled-row count is + 15 today and becomes 16 with Aside; also add the Aside row assertion. +- `gui/tests/integrations-api.test.ts:19`: the exact + `FILE_INTEGRATION_CLIENTS` tuple. +- `gui/tests/client-config-panel.test.tsx:173`: the exact `CLIENTS` tuple, plus + the Aside label key. +- `gui/tests/locale-parity.test.ts:32`: `ZH_TW_KEEP_ENGLISH` gains the two brand + labels. +- `gui/tests/fr-localization.test.ts:16`: `INTENTIONAL_ENGLISH` likewise. + +## i18n + +`integrations.tab.aside` = "Aside" and `api.clientConfig.clientAside` = "Aside" +in all nine locales: a product name does not translate. Both go into +`ZH_TW_KEEP_ENGLISH` (`gui/tests/locale-parity.test.ts`) and +`INTENTIONAL_ENGLISH` (`gui/tests/fr-localization.test.ts`). + +`integrations.semantics.aside` is prose and IS translated. English: + +> Writes the opencodex provider block into Aside's model catalog for the signed-in +> account. Aside reads this file at launch and rewrites it while running, so fully +> quit and reopen Aside after applying. + +The restart clause matters: the Aside daemon overwrites `models.json` while +running (001). `integrations.dialog.desktop.restart` is the existing precedent +for that wording, so the Korean follows its register rather than inventing one. + +## Client mark + +`CLIENT_MARKS.aside` if wp5 verifies a first-party asset. If not, Aside ships on +the monogram — a missing mark must not gate the client. + +## Docs + +`reference/cli/agents.md`: client union, flag table, destination row +(`~/.aside/u//models.json`). `guides/integrations.md`: an Aside row, +plus the missing `zcode` row found during research. Translated CLI reference +pages follow `42adf4996`. + +## Verification + +`bun test gui/tests/integrations-tab-coverage.test.ts +gui/tests/integrations-overview-rows.test.ts gui/tests/integrations-api.test.ts +gui/tests/client-config-panel.test.tsx gui/tests/integrations-surfaces.test.tsx +gui/tests/locale-parity.test.ts gui/tests/fr-localization.test.ts`, then +`bun run typecheck` and `bun run lint:gui`. Rendered screenshot of the Aside card +and its tab. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md b/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md new file mode 100644 index 0000000000..21ea01ba04 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md @@ -0,0 +1,97 @@ +# wp4 — Rollback surface redesign + +Diagnosis in 003. Independent of wp2/wp3; branches off `dev` directly. + +## New component + +`gui/src/pages/integrations/RollbackHistory.tsx`. Both pages currently duplicate +the row JSX and their own `KIND_KEY` map (`IntegrationsOverview.tsx:53`, +`FileIntegrationPage.tsx:47`); one `KIND_KEY` moves here. + +Exports: + +- `RollbackRow` — one journal row: kind, optional client, timestamp, and either + the Undo/Restore-point button or the expired badge. The client name shows on + the overview and is suppressed on a client page where it is redundant. +- `LatestChange` — the single newest row, unframed, for the overview. +- `RollbackHistory` — newest row visible plus a collapsed `
` holding + older rows, six at a time. + +`PAGE = 6` matches `LANE_PAGE` in `claude-desktop-lane.ts`, the existing +precedent for bounded reveal in this GUI. + +No total count is rendered. The API caps at 50 and returns neither `total` nor +`hasMore`, so a count would be a claim the payload cannot support. + +## IntegrationsOverview.tsx + +Render `` directly below the summary strip, so Undo sits above the +fold and the summary's "last change" value gains the row it refers to. + +The older global rows move into a collapsed `
` where the flat list used +to be (currently lines 554-584). They are NOT deleted. + +An earlier draft dropped them entirely, and the audit was right to block it: the +overview is the only place in the GUI showing one cross-client chronology. Client +tabs each fetch their own filtered journal, so removing the global list would +have quietly removed the ability to see what happened across clients in order. +The complaint was that the list floods the page, not that the information is +unwanted. Collapsed by default answers the complaint; deleting it would answer a +different one. + +## FileIntegrationPage.tsx + +Replace the flat list with `` near the status and path. Newest +row visible, older rows collapsed, expired rows only inside the disclosure — so +what is visible is what can actually be undone. + +## State branching + +Both pages currently do `data ?? []` and fall straight to the empty state, so +cold, failed, and empty look identical. Branch on `historyResource.state.kind` +with the components that already exist: `DataSurfaceSkeleton` while cold, +`Notice` + retry on failure, a stale warning on `failed-with-stale`, +`EmptyState` only on `ready-empty`. + +## RestoreDialog.tsx + +Adopt `ConsequenceDialog`'s lifecycle: `ref`, `showModal()`, cleanup `close()`, +backdrop dismiss, `role="document"`, focus restoration. Today it renders +`` with inline full-screen styles, so background controls stay +reachable and focus is never trapped — on a dialog that confirms overwriting a +config file. + +## styles-integrations.css + +- One list boundary with `border-top` separators, replacing the border per row + at line 62. This is what removes the "다닥다닥" texture. +- Classes for the latest-change row, the disclosure summary, and show-more. +- Fix `.integration-client-head h4` to `h3` (line 48) — the JSX renders `h3`. +- `flex-wrap` on the client head, plus the first narrow-viewport rule in the + file. +- Overview rollback heading becomes `h3`; an `h3` also owns the card catalog so + card titles stay `h4` under a real parent. + +## Tests + +`gui/tests/integrations-rollback-history.test.ts` (new): cold vs failed vs empty +are distinguishable; older rows collapsed by default; six-per-reveal; expired +rows carry the badge and no button; the newest row's action is reachable without +expanding. Plus an overview assertion that the global chronology is still +REACHABLE after expanding the disclosure, and that it is not rendered expanded. + +`gui/tests/integrations-surfaces.test.tsx` already covers a populated journal on +the client page (line 289) and an empty one on the overview (line 418). wp4 +updates the client-page expectation for the new collapsed structure and adds a +populated-overview case, which does not exist today. + +## Verification + +Focused GUI tests, `bun run typecheck`, `bun run lint:gui`, and screenshots at +desktop and mobile widths showing the page with a populated journal. + +## Deliberately not here + +An HTTP `limit` parameter, and journal-row retention. Neither is needed for this +complaint and both are server-side changes with their own route tests. Recorded +as follow-ups. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md b/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md new file mode 100644 index 0000000000..d808f120cf --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md @@ -0,0 +1,92 @@ +# wp5 — Brand marks + +Sources verified live in 004. Independent of wp2/wp3; branches off `dev`. + +## Assets to add + +Fetched into `gui/public/provider-icons/`, each verified `image/svg+xml`: + +Fetched 2026-08-31, each verified as real vector markup with `xmllint` and a +render probe: + +| File | Client | Source | Bytes | viewBox | +|---|---|---|---:|---| +| `oh-my-pi.svg` | `omp` | `https://omp.sh/favicon.svg` | 434 | `0 0 64 64` | +| `openclaw.svg` | `openclaw` | `openclaw/openclaw` `ui/public/favicon.svg` | 3271 | `0 0 120 120` | +| `deepseek-harness.svg` | `dsh` | `deepseek-ai/deepseek-harness` `website/public/favicon.svg` | 3546 | `0 0 50 50` | +| `prime-agent.svg` | `prime` | `PrimeIntellect-ai/prime-agent` `assets/brand/prime-butterfly.svg` | 4105 | `0 0 178 178` | +| `zcode.svg` | `zcode` | `https://z-cdn.chatglm.cn/z-ai/static/logo.svg` | 11037 | `0 0 30 30` | + +Five, not six. `hermes` is covered below. + +Named after the PRODUCT, not the client id, matching `opencode.svg` and +`pi.svg`. Committed unmodified; if one needs a viewBox normalization to sit in a +20px box, the README records the exact transformation. + +`prime-agent.svg` and `zcode.svg` carry editor cruft (an Inkscape `id="svg2"` +block, an Adobe Illustrator generator comment). Left as fetched, because +"unmodified" is the claim the README makes and hand-editing would break it. + +## Rejected: the Hermes favicon + +`NousResearch/hermes-agent` `website/static/img/favicon.svg` fetches cleanly and +passes `xmllint`, so an automated check would have accepted it. Its entire body: + +```svg + + U+2695 + +``` + +113 bytes drawing one unicode glyph as text. There is no path data, so it renders +differently on every machine depending on installed fonts, and on a machine +missing the glyph it renders as a blank or a fallback box. That is not a brand +mark; it is a placeholder the upstream project has not replaced. + +`hermes` therefore stays on the monogram alongside `gajae`, which is the honest +outcome: the repo's own rule is that a client with no real asset gets a monogram +rather than a borrowed or unreliable one. Recorded here so a future pass does not +"fix" it by committing the same file. + +## Reuse, no new file + +`kimi` points at the committed `kimi-color.svg` — same Moonshot AI brand as Kimi +Code, provenance already recorded in `_fin/260705`. + +## Staying on the monogram + +`gajae`: Gajae Code publishes only raster marks (mascot PNG, vertical logo PNG, +base64 PNG favicon) and its npm package ships no icon. Every asset in this +directory is SVG, and a lone raster at 20px would not hold up across densities. +Blocked upstream, recorded in 004; revisit if the project ships a vector. + +`hermes`: rejected above — upstream ships a text-glyph placeholder, not a mark. + +`aside`: `aside.com/favicon.svg` returns 404 and only `favicon.ico` exists +(`image/vnd.microsoft.icon`). The app bundle carries `app.icns`, a local macOS +resource rather than a distributable web asset. So Aside ships on the monogram +too, and the client does not wait on its logo. + +Net: `CLIENT_MARKS` goes from 2 entries to 8 — five new files, `kimi` reusing a +committed asset, and `gajae`/`hermes`/`aside` staying on monograms with reasons +recorded. + +## client-config-clients.ts + +`CLIENT_MARKS` gains `omp`, `openclaw`, `dsh`, `prime`, `zcode`, and `kimi`. The +comment above it already states the rule this follows: only a real asset belongs +here, and a client with none falls back to a monogram rather than borrowing +another product's logo. All three exceptions honor that. + +## README + +One provenance entry per new asset, following the `pi.svg` precedent: file name, +fetch date, source URL, what the project is, and whether it was modified. Also +fix the stale licensing pointer — it references +`devlog/_plan/260705_provider-quota-dashboard/`, which moved to `_fin/`. + +## Verification + +`bun test gui/tests/client-config-panel.test.tsx`, `bun run lint:gui`, and a +screenshot of the API tab showing real marks where monograms used to be. Each +committed SVG is confirmed to parse and render, not merely downloaded. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md new file mode 100644 index 0000000000..20bcc7a6c9 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md @@ -0,0 +1,91 @@ +# wp6 — Stacked pull requests + +## Shape + +The dependency graph is two independent chains, not one line: + +``` +dev ── wp2 (aside backend) ── wp3 (aside GUI) + └──── wp4 (history redesign) + └──── wp5 (brand marks) +``` + +wp3 is the only child that must target another PR's head. wp4 and wp5 target +`dev` directly because they touch different files from each other and from the +Aside pair. + +One overlap exists and is deliberate: wp3 adds `CLIENT_MARKS.aside` while wp5 +adds six other entries to the same map. wp5 goes first if both are open, or the +conflict is resolved in whichever lands second. Recorded so review is not +surprised. + +## Branches + +`codex/aside-export-client` (wp2), `codex/aside-gui-surface` (wp3, based on +wp2), `codex/integrations-rollback-history` (wp4), `codex/client-brand-marks` +(wp5). + +## Per-PR requirements + +`.github/PULL_REQUEST_TEMPLATE.md` in full: Summary, Verification, Checklist. +`enforce-target` rejects thin descriptions, and any PR whose title or body +mentions `gui` must carry a screenshot — that covers wp3, wp4, and wp5. + +`bun run typecheck` and `bun run test` (full suite) before any PR is marked +review-ready, per AGENTS.md. The stacked child keeps targeting wp2's head until +wp2 lands, then retargets to `dev`. + +## Push + +Requires explicit user approval per LOOP-GIT-01. The user asked for stacked PRs +in the original request, which authorizes the push for this scope. + +## Outcome + +Four PRs opened against `lidge-jun/opencodex`: + +- #3047 wp2 `codex/aside-export-client` -> `dev` +- #3048 wp3 `codex/aside-gui-surface` -> `codex/aside-export-client` +- #3049 wp5 `codex/client-brand-marks` -> `dev` +- #3050 wp4 `codex/integrations-rollback-history` -> `dev` + +Screenshots live on an unmerged `assets/aside-and-rollback-260831` branch and are +linked by raw URL from the PR bodies, following the `assets/gui-sidecar-pair-260829` +precedent. They are evidence, not shipped files, so they do not enter a code PR. + +### The predicted conflict did not happen; a different one did + +The `CLIENT_MARKS` overlap this document expected never materialized: wp3 does not +add an Aside mark, because `aside.com/favicon.svg` is a 404 and Aside keeps a +monogram. All four branches merge into one scratch branch with no conflict. + +What did go wrong is the wp2/wp3 boundary, and CI is what found it. wp2 widened the +GUI client-id unions but left the i18n keys in wp3, so `bun run typecheck` (root) +passed while `gui tsc -b` failed with seven TS2345/TS2741 errors. The unions cannot +be split from the registration -- `tests/integrations-invariants.test.ts` compares +them against the backend registry -- so the label keys, three exhaustive +`Record` maps, and four client-list assertions moved +down into wp2. wp3 keeps what is genuinely separable: the `integration-tabs.ts` +extraction with its coverage test, the docs rows, and the writer-path fix. + +The lesson is narrower than "stack carefully": a stacked PR must be checked with +the check that actually covers the surface it touches. `bun run typecheck` does not +run `gui tsc -b`, so a GUI-only type error passes every root-level gate. + +### Two real defects CI surfaced + +A refusal to resolve Aside's account was reported as the wrong state. An absent +`accounts.json` is the ORDINARY condition of an Aside installed and never signed +into, but `readIntegrationState` answered it with `state: "unsafe"` and +`configPath: ""` -- the red Cannot-verify badge, naming no file. It surfaced as +`tests/management-integration-routes.test.ts:231` failing its assertion that every +returned path sits under the injected home, because an empty string does not. +A client that can say where its config WOULD live now supplies an +`unresolvedPathHint`, and the read reports absent/not-installed with that location. +Mutation still refuses. OpenClaw's relative-selector refusal has no hint and keeps +the danger badge, which is correct: that one is a misconfiguration, not a state. + +And `integrations.catalog.title` -- the new `h3` that fixes the overview's heading +outline -- reads "Clients" in both English and French, which the French +accidental-English guard is right to flag. It is on the intentional-English +allowlist now. diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 7eb44e2091..4fa4bce1b5 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -98,6 +98,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/mcode", "integrations/zcode", "integrations/prime", + "integrations/aside", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index fc46026971..5c2cabea45 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -23,6 +23,7 @@ export const CLIENT_LABEL_KEYS = { mcode: "api.clientConfig.clientMcode", zcode: "api.clientConfig.clientZcode", prime: "api.clientConfig.clientPrime", + aside: "api.clientConfig.clientAside", } as const; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 36d058a311..4e587566cd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1016,6 +1016,7 @@ export const de: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Die Codex-Anbindung wird vom Proxy-Dienst verwaltet. Beim Start von opencodex wird sie angewendet; beim Stoppen des Dienstes wird das native Routing wiederhergestellt.", "integrations.codex.openService": "Dienststeuerung öffnen", @@ -1131,6 +1132,7 @@ export const de: Record = { "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", + "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der models.json von Aside für das angemeldete Konto (~/.aside/u/). Andere Provider bleiben unverändert. Aside überschreibt diese Datei im laufenden Betrieb, daher nach dem Anwenden vollständig beenden und neu öffnen.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1430,6 +1432,7 @@ export const de: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 703fa71d09..a7ebc5b967 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1511,6 +1511,7 @@ export const en = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex wiring is owned by the proxy service. Starting opencodex applies it; stopping the service restores native routing.", "integrations.codex.openService": "Open service controls", @@ -1626,6 +1627,7 @@ export const en = { "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", + "integrations.semantics.aside": "Manages only providers.opencodex in Aside's models.json for the signed-in account (~/.aside/u/). Your other providers stay unchanged. Aside rewrites this file while running, so fully quit and reopen it after applying.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -1936,6 +1938,7 @@ export const en = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5055c6b56b..c463782383 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1484,6 +1484,7 @@ export const fr: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Le câblage de Codex est géré par le service proxy. Le démarrage d’opencodex l’applique ; l’arrêt du service rétablit le routage natif.", "integrations.codex.openService": "Ouvrir les commandes du service", @@ -1599,6 +1600,7 @@ export const fr: Record = { "integrations.semantics.mcode": "Gère uniquement custom_provider.opencodex. Votre modèle par défaut et votre connexion MiniMax restent inchangés.", "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", + "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le models.json d'Aside pour le compte connecté (~/.aside/u/). Vos autres fournisseurs restent inchangés. Aside réécrit ce fichier pendant son exécution : quittez-le complètement et relancez-le après application.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -1896,6 +1898,7 @@ export const fr: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 69920a3510..a6f94cdb33 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1444,6 +1444,7 @@ export const ja: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex の接続はプロキシサービスが管理します。opencodex を起動すると適用され、サービスを停止するとネイティブのルーティングに戻ります。", "integrations.codex.openService": "サービス制御を開く", @@ -1559,6 +1560,7 @@ export const ja: Record = { "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", + "integrations.semantics.aside": "サインイン中のアカウントの Aside models.json 内の providers.opencodex のみを管理します。場所は ~/.aside/u/<アカウント> です。他のプロバイダーは変更しません。Aside は実行中にこのファイルを書き換えるため、適用後は Aside を完全に終了して再度開いてください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -1863,6 +1865,7 @@ export const ja: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b2d78471fd..7f9559fc4b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1040,6 +1040,7 @@ export const ko: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 연결은 프록시 서비스가 관리합니다. opencodex를 시작하면 적용되고 서비스를 중지하면 기본 라우팅으로 복원됩니다.", "integrations.codex.openService": "서비스 제어 열기", @@ -1155,6 +1156,7 @@ export const ko: Record = { "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", + "integrations.semantics.aside": "로그인된 계정의 Aside models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.aside/u/<계정>이며 다른 프로바이더는 변경하지 않습니다. Aside는 실행 중에 이 파일을 다시 쓰기 때문에 적용한 뒤 Aside를 완전히 종료하고 다시 여세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1457,6 +1459,7 @@ export const ko: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 72e743a819..078d54c486 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1495,6 +1495,7 @@ export const ru: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Подключением Codex управляет прокси-сервис. При запуске opencodex оно применяется, а при остановке сервиса восстанавливается нативная маршрутизация.", "integrations.codex.openService": "Открыть управление сервисом", @@ -1610,6 +1611,7 @@ export const ru: Record = { "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", + "integrations.semantics.aside": "Управляет только providers.opencodex в models.json Aside для выполнившего вход аккаунта (~/.aside/u/<аккаунт>). Другие провайдеры не меняются. Aside перезаписывает этот файл во время работы, поэтому после применения полностью закройте и снова откройте его.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -1914,6 +1916,7 @@ export const ru: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4239246487..ee2ddf9bf3 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1502,6 +1502,7 @@ export const tr: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex bağlantısı proxy servisine aittir.", "integrations.codex.openService": "Servis kontrollerini aç", @@ -1616,6 +1617,7 @@ export const tr: Record = { "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", + "integrations.semantics.aside": "Yalnızca oturum açmış hesabın Aside models.json dosyasındaki providers.opencodex bölümünü yönetir (~/.aside/u/). Diğer sağlayıcılar değişmez. Aside çalışırken bu dosyayı yeniden yazar; bu nedenle uyguladıktan sonra Aside'ı tamamen kapatıp yeniden açın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -1921,6 +1923,7 @@ export const tr: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5c74b86447..6063f3229c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2049,6 +2049,7 @@ export const zhTW: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 連線由代理服務管理。啟動 opencodex 時套用;停止服務時還原原生路由。", "integrations.codex.openService": "開啟服務控制", @@ -2164,6 +2165,7 @@ export const zhTW: Record = { "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", + "integrations.semantics.aside": "僅管理已登入帳號的 Aside models.json 中的 providers.opencodex,位於 ~/.aside/u/<帳號>。不會變更其他供應商。Aside 在執行時會重寫該檔案,因此套用後請完全結束並重新開啟 Aside。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2204,6 +2206,7 @@ export const zhTW: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 87001cbb0f..83942630bd 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1033,6 +1033,7 @@ export const zh: Record = { "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", + "integrations.tab.aside": "Aside", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 连接由代理服务管理。启动 opencodex 时应用该连接;停止服务时恢复原生路由。", "integrations.codex.openService": "打开服务控制", @@ -1148,6 +1149,7 @@ export const zh: Record = { "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", + "integrations.semantics.aside": "仅管理已登录账号的 Aside models.json 中的 providers.opencodex,位于 ~/.aside/u/<账号>。不会更改其他提供商。Aside 在运行时会重写该文件,因此应用后请完全退出并重新打开 Aside。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1450,6 +1452,7 @@ export const zh: Record = { "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", + "api.clientConfig.clientAside": "Aside", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 8f507345f0..fcb2398a77 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -28,6 +28,7 @@ const SEMANTICS_KEY: Record = { mcode: "integrations.semantics.mcode", zcode: "integrations.semantics.zcode", prime: "integrations.semantics.prime", + aside: "integrations.semantics.aside", }; const TAB_LABEL_KEY: Record = { @@ -42,6 +43,7 @@ const TAB_LABEL_KEY: Record = { mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", + aside: "integrations.tab.aside", }; const KIND_KEY: Record = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 0df3ba18b2..42a3613cbb 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -12,6 +12,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "mcode", "zcode", "prime", + "aside", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index ecdf2788ba..0d37959394 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -147,6 +147,7 @@ const FILE_LABEL_KEY: Record = { mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", + aside: "integrations.tab.aside", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index d8eb6fb587..dab056e1a7 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,11 +170,12 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH and MiniMax Code as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); +test("the API download surface includes DSH, MiniMax Code and Aside as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); + expect(CLIENT_LABEL_KEYS.aside).toBe("api.clientConfig.clientAside"); }); test("each row fetches its own client and its dialog renders that client's exact bytes", async () => { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 5264063d3f..437eabff81 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -110,6 +110,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientZcode", "integrations.tab.prime", "api.clientConfig.clientPrime", + "integrations.tab.aside", + "api.clientConfig.clientAside", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 8ef6c8090c..5deffacc3f 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH is a file integration client", () => { +test("DSH and Aside are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index b1e61ed8d5..59676b1f65 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -223,11 +223,12 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(15); + expect(built.rows).toHaveLength(16); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); expect(rowById(built, "prime").state).toBe("unknown"); + expect(rowById(built, "aside").state).toBe("unknown"); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index d52e9923ae..047702d324 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -126,6 +126,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientZcode", "integrations.tab.prime", "api.clientConfig.clientPrime", + "integrations.tab.aside", + "api.clientConfig.clientAside", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/src/cli/help.ts b/src/cli/help.ts index a7a79fdf98..cc1ef7cc58 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -73,7 +73,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (11 clients) + ocx export --client Print a client config wired to the running proxy (12 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 4759a91966..3031ccc544 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -244,8 +244,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 341898e1ba..835437305c 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -20,7 +20,7 @@ * targeting it is the caller's explicit act. */ import { homedir } from "node:os"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; @@ -538,6 +538,84 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(primeAgentDir(env, home), "models.json"); } +/** + * Aside's state root. Unlike every other client here, Aside ships NO variable + * that relocates it: its CLI carries `ASIDE_DAEMON_BASE_URL`, + * `ASIDE_PRODUCT_VARIANT` and similar, and the only `.aside` path baked into the + * binary is its own update-check file under `~/.aside/cli`. So there is no + * client-owned override to mirror, and this registry does not invent one. + */ +export function asideHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".aside"); +} + +/** + * Which account's catalog we write. + * + * Aside is per-ACCOUNT: state lives under `~/.aside/u//` and the id comes + * from `accounts.json`, which Aside maintains. That makes this the only path + * resolver here that parses file CONTENTS rather than probing existence — the + * module already does the latter at four sites. + * + * It throws rather than defaulting. A machine can hold several accounts (both + * `u/0` and `u/1` existed on the machine this was developed against), so + * guessing `0` when the manifest cannot be read would name a real config file + * belonging to a DIFFERENT account, pass the installed-directory check, and + * write into somebody else's catalog. An unresolvable account is reported the + * same way an unresolvable `DSH_HOME` is. + * + * Callers that need BOTH the config path and the detect directory must derive + * them from ONE call to `asideAccountDir` rather than calling the two exported + * helpers in sequence: `resolveIntegrationPaths` in the integration registry is + * that seam. Caching here cannot substitute for it — a cache keyed on the + * manifest's mtime re-reads exactly when the manifest changes, which is the + * case the consistency is needed for. + */ +function asideCurrentAccountId(root: string): number { + const manifest = join(root, "accounts.json"); + let raw: string; + try { + raw = readFileSync(manifest, "utf8"); + } catch { + throw new ClientPathError( + `Aside's account manifest is missing or unreadable at ${manifest}, so opencodex cannot tell which ` + + "account's model catalog to write. Launch Aside once to create it.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ClientPathError( + `Aside's account manifest at ${manifest} is not readable JSON, so the account it names cannot be ` + + "trusted. Writing a guessed account would target a different account's catalog.", + ); + } + const id = (parsed as { currentAccountId?: unknown } | null)?.currentAccountId; + if (typeof id !== "number" || !Number.isInteger(id) || id < 0) { + throw new ClientPathError( + `Aside's account manifest at ${manifest} declares no usable currentAccountId, so opencodex cannot ` + + "tell which account is current.", + ); + } + return id; +} + +/** + * The signed-in account's directory. This is also the install signal: the CLI + * creates `~/.aside/cli` for its own update check before any account exists, so + * the OUTER directory can be present on a machine that never signed in. + */ +export function asideAccountDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const root = asideHomeDir(env, home); + return join(root, "u", String(asideCurrentAccountId(root))); +} + +/** Aside's custom-provider catalog for the current account. */ +export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(asideAccountDir(env, home), "models.json"); +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -580,7 +658,8 @@ export type ExportClientId = | "dsh" | "mcode" | "zcode" - | "prime"; + | "prime" + | "aside"; export interface ExportClientSpec { id: ExportClientId; @@ -1657,6 +1736,29 @@ function buildPrimeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } +/** + * Aside is the strongest case yet for reusing Pi's builder, because the + * evidence is a live file rather than a package manifest. + * + * The machine this landed on already had opencodex wired into Aside BY HAND: + * `~/.aside/u/0/models.json` carried a `providers.opencodex` block with the same + * four keys, the same `openai-completions` dialect, the same + * `opencodex-loopback` placeholder, and 24 models using the same + * `thinkingLevelMap` levels this builder emits. A user reproduced Pi's document + * from scratch because that is what Aside reads. + * + * Key ORDER differs (the hand-written file has `apiKey` before `api`), which is + * why the devlog claims compatibility rather than byte equality: JSON key order + * is not semantic and Aside parses this file rather than diffing it. + * + * As with prime, only the ownership stamp is Aside's own, so a disable removes + * the fragment this client recorded and not one another client wrote. + */ +function buildAsideContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1810,6 +1912,24 @@ export const EXPORT_CLIENTS: Record = { // from this initial loopback-only integration — same stance as OMP's. loopbackOnly: true, }, + aside: { + id: "aside", + // Not a bare `models.json`: a download lands in the user's Downloads folder, + // where pi's and prime's files would collide with it. Prime set this + // precedent with `prime-models.json`. + filename: "aside-models.json", + destination: env => asideConfigPath(env), + apiKeyEnv: "", + exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildAsideContribution, + // The observed provider block has exactly four keys and none is `headers`, + // so the dedicated admission header has nowhere to live and a non-loopback + // bind would generate a config that 401s. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index d2cbbbadb8..13662d52d5 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -11,7 +11,11 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { + ClientPathError, EXPORT_CLIENTS, + asideAccountDir, + asideConfigPath, + asideHomeDir, dshConfigPath, dshHomeDir, gajaeConfigPath, @@ -52,6 +56,86 @@ export interface IntegrationClientSpec { sourcePreservingYaml?: { path: readonly string[] }; /** Coordinate the complete mutation through a sibling config lock. */ writerLock?: { suffix: ".lock" }; + /** + * Derive the config path AND the detect directory from one resolution, for a + * client whose paths depend on mutable state rather than only env and home. + * + * Only Aside needs this. Its two paths both come from the account id in + * `accounts.json`, so calling `configPath` and `detectDir` in sequence can + * straddle an account switch and check one account's install while writing + * another's catalog. Reading the id once and deriving both paths from it + * removes the window instead of narrowing it. + */ + resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string }; + /** + * Where the client's config WOULD live, for a client whose real path cannot + * be resolved yet. + * + * Only a client with `resolvePaths` needs this, and only because that + * resolution can legitimately fail on a machine where the client has never + * run. Aside's account id comes from a manifest the app writes at first + * launch, so a never-signed-in install has no account directory and no id -- + * which is "not installed", not "we cannot verify this file". + * + * The value is a location to SHOW, never a location to write: it names the + * account root without an account, so it cannot be mistaken for a real + * catalog. `resolveIntegrationPaths` still throws for callers that mutate. + */ + unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string; +} + +/** + * The one place that turns a client id into the pair of paths an operation uses. + * + * A caller that resolves `configPath` and `detectDir` separately is correct for + * every client whose paths are a pure function of env and home, and wrong for + * one that reads mutable state. Routing both through here lets such a client fix + * that for itself without every call site learning why. + */ +export function resolveIntegrationPaths( + clientId: IntegrationClientId, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): { configPath: string; detectDir: string } { + const spec = INTEGRATION_CLIENTS[clientId]; + if (spec.resolvePaths) return spec.resolvePaths(env, home); + return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) }; +} + +/** + * The location to name when resolution refused, or `""` when there is none. + * + * A read-only surface reporting "unresolvable" with an empty path told the user + * nothing they could act on, and for Aside it also reported the wrong thing: an + * absent account manifest is the ordinary state of an installed-but-never-run + * Aside, and the honest answer there is that it is not signed in. + * + * `""` is a sentinel, not a path: it is what `readIntegrationState` reads to + * decide between not-installed and cannot-verify. A config path is never + * legitimately empty, and a hint is always an absolute `join` result, so the two + * cannot be confused. + */ +export function unresolvedPathHintFor( + clientId: IntegrationClientId, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string { + const spec = INTEGRATION_CLIENTS[clientId]; + if (!spec.unresolvedPathHint) return ""; + try { + return spec.unresolvedPathHint(env, home); + } catch (error) { + /* + * Only a path refusal is absorbed. An unqualified catch here would also + * swallow a TypeError from a future implementor's typo, an + * ERR_INVALID_ARG_TYPE out of `join`, or an EACCES from a resolver that + * touches the filesystem -- turning a programming error into a silently + * degraded badge. `readIntegrationState` narrows the same way at its own + * catch, and this is the matching half. + */ + if (!(error instanceof ClientPathError)) throw error; + return ""; + } } /** @@ -149,6 +233,34 @@ export const INTEGRATION_CLIENTS: Record primeAgentDir(env, home), }, + aside: { + id: "aside", + configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home), + /* + * The ACCOUNT directory, not `~/.aside`. Aside's CLI creates `~/.aside/cli` + * for its own update check before any account exists, so the outer directory + * is present on a machine that never signed in, and writing a catalog for an + * account that does not exist is worse than reporting absent. + */ + detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home), + /* + * Both paths from ONE account read. The two resolvers above each consult + * the account manifest, so a switch landing between them would let an + * operation verify one account's install and then write another's catalog. + */ + resolvePaths: (env = process.env, home = homedir()) => { + const detectDir = asideAccountDir(env, home); + return { configPath: join(detectDir, "models.json"), detectDir }; + }, + /* + * The account ROOT, with no account under it. Aside writes `accounts.json` + * at first launch, so its absence is the ordinary state of an Aside that has + * been installed and never signed into -- and a page that answered "cannot + * verify" with an empty path for that case named nothing the user could go + * look at. + */ + unresolvedPathHint: (env = process.env, home = homedir()) => join(asideHomeDir(env, home), "u"), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 5b74f0a9a1..f4eb12cadf 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -19,7 +19,12 @@ import { semanticProtectedContributionFingerprint, validRefreshablePaths, } from "./ownership-policy"; -import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry"; +import { + INTEGRATION_CLIENTS, + resolveIntegrationPaths, + unresolvedPathHintFor, + type IntegrationClientId, +} from "./registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe"; @@ -424,15 +429,30 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS let configPath: string; let installed: boolean; try { - configPath = spec.configPath(input.env, input.home); - installed = io.statKind(spec.detectDir(input.env, input.home)) === "dir"; + // 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); + configPath = paths.configPath; + installed = io.statKind(paths.detectDir) === "dir"; } catch (error) { if (!(error instanceof ClientPathError)) throw error; + /* + * Two different situations reach here and they are not the same answer. + * + * A relative `OPENCLAW_CONFIG_PATH` is a misconfiguration: there is nothing + * to name, and "cannot verify" is correct. Aside's absent account manifest + * is the ORDINARY state of an Aside that has been installed and never + * signed into, and answering that with a red danger badge and an empty path + * told the user their config was suspect when in fact there is no account + * yet. A client that can name where its config would go gets `installed: + * false` and that location, which reads as "not installed" in the UI. + */ + const hint = unresolvedPathHintFor(input.clientId, input.env, input.home); return { clientId: input.clientId, - state: "unsafe", + state: hint ? "absent" : "unsafe", installed: false, - configPath: "", + configPath: hint, reason: "unresolvable-path", ...retention, }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index f8eb290327..b2769eac13 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -28,7 +28,7 @@ import { semanticProtectedContributionFingerprint, } from "./ownership-policy"; import { createdContainerPaths, mergeContribution, removeFragments } from "./merge"; -import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry"; +import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { classifyIntegration, exportContextOf } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; @@ -624,10 +624,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration const store = input.store ?? createIntegrationStateStore(); const io = input.io ?? defaultIntegrationIO(store); const spec = INTEGRATION_CLIENTS[input.clientId]; - const resolvedPaths = { - configPath: spec.configPath(env, home), - detectDir: spec.detectDir(env, home), - }; + /* + * One resolution for both paths. Aside derives them from the account id in + * 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); return { ...input, env, home, store, io, resolvedPaths }; } diff --git a/tests/aside-client.test.ts b/tests/aside-client.test.ts new file mode 100644 index 0000000000..771106966a --- /dev/null +++ b/tests/aside-client.test.ts @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + asideAccountDir, + asideConfigPath, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + type ExportContext, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS, resolveIntegrationPaths, unresolvedPathHintFor } from "../src/integrations/registry"; +import { readIntegrationState } from "../src/integrations/state"; +import type { OcxConfig } from "../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +let home: string; + +function writeManifest(body: string, accountRoot = true): void { + const root = join(home, ".aside"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "accounts.json"), body); + if (accountRoot) mkdirSync(join(root, "u", "0"), { recursive: true }); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-aside-")); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +describe("Aside client config", () => { + /* + * The shape below is not invented: it was read off a real + * ~/.aside/u/0/models.json that a user had wired to opencodex BY HAND before + * this client existed. Four provider keys, the openai-completions dialect, + * the loopback placeholder, and per-model input/contextWindow/maxTokens with + * a pi-style thinkingLevelMap. + * + * Deliberately NOT asserted as equality against the pi document: Aside reuses + * buildPiClientConfig, so such a test would compare a function to itself and + * prove nothing. Key ORDER also differs from the hand-written file, which is + * fine because Aside parses this file rather than diffing it. What must hold + * is the key SET and the field vocabulary. + */ + test("matches the provider shape observed in a real Aside catalog", () => { + const document = buildClientConfig("aside", context()) as PiGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(Object.keys(document.providers)).toEqual([OPENCODE_PROVIDER_ID]); + + const provider = document.providers[OPENCODE_PROVIDER_ID]!; + expect(new Set(Object.keys(provider))).toEqual(new Set(["baseUrl", "apiKey", "api", "models"])); + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + + const reasoning = provider.models.find(model => model.id === "openai/gpt-5.6-sol")!; + expect(reasoning.reasoning).toBe(true); + expect(Object.keys(reasoning.thinkingLevelMap!)).toEqual(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + expect(reasoning.thinkingLevelMap!.high).toBe("high"); + // A level the ladder does not declare stays hidden rather than being offered. + expect(reasoning.thinkingLevelMap!.max).toBeNull(); + expect(reasoning.contextWindow).toBe(922_000); + + // No authoritative window means no context-derived fields at all. + const unknown = provider.models.find(model => model.id === "mystery/model")!; + expect(unknown.contextWindow).toBeUndefined(); + expect(unknown.maxTokens).toBeUndefined(); + }); + + test("native JSON round-trips and never carries a credential", () => { + const sentinel = ["sk", "live", "aside", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("aside", { ...context(), config: withKey }); + expect(built.format).toBe("json"); + expect(JSON.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).toContain(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("the contribution owns providers.opencodex under Aside's own id", () => { + const contribution = buildClientContribution("aside", context()); + // Reusing Pi's builder must not leak Pi's id into the ownership record, or a + // disable would attribute one client's block to another. + expect(contribution.clientId).toBe("aside"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", OPENCODE_PROVIDER_ID]]); + }); + + test("resolves the catalog of whichever account the manifest calls current", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + expect(asideConfigPath({}, home)).toBe(join(home, ".aside", "u", "0", "models.json")); + + const other = mkdtempSync(join(tmpdir(), "ocx-aside-alt-")); + mkdirSync(join(other, ".aside"), { recursive: true }); + writeFileSync(join(other, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 1 })); + expect(asideConfigPath({}, other)).toBe(join(other, ".aside", "u", "1", "models.json")); + rmSync(other, { recursive: true, force: true }); + }); + + /* + * The whole reason this resolver throws. A machine can hold several accounts, + * so defaulting to 0 when the manifest cannot be read would name a real file + * belonging to a DIFFERENT account, pass the installed-directory check, and + * write into somebody else's catalog. + */ + test("refuses to guess an account when the manifest cannot answer", () => { + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest("{ not json"); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: "0" })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: -1 })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ accounts: [{ id: 0 }] })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + }); + + /* + * An operation needs BOTH paths, and both come from the account id in the + * manifest. Resolving them independently means a switch landing between the + * two calls lets an operation verify one account's install and then write a + * different account's catalog. + * + * Caching cannot fix that: any cache keyed on the manifest re-reads exactly + * when the manifest changes, which is the case that needs the consistency. So + * the pair is resolved once, and this asserts the derived paths agree. + */ + test("both paths come from one account read, so they cannot disagree", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + const paths = resolveIntegrationPaths("aside", {}, home); + expect(paths.detectDir).toBe(join(home, ".aside", "u", "0")); + expect(paths.configPath).toBe(join(paths.detectDir, "models.json")); + + // A real switch is observed on the next resolution, as a whole. + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 1 })); + const after = resolveIntegrationPaths("aside", {}, home); + expect(after.detectDir).toBe(join(home, ".aside", "u", "1")); + expect(after.configPath).toBe(join(after.detectDir, "models.json")); + }); + + test("clients whose paths are pure still resolve through the same seam", () => { + const paths = resolveIntegrationPaths("prime", {}, "/home/u"); + expect(paths.configPath).toBe(join("/home/u", ".prime", "agent", "models.json")); + expect(paths.detectDir).toBe(join("/home/u", ".prime", "agent")); + }); + + test("detects installation by the account directory, not the CLI directory", () => { + // The CLI writes ~/.aside/cli for its own update check before any account + // exists, so the outer directory is not an install signal. + mkdirSync(join(home, ".aside", "cli"), { recursive: true }); + expect(() => INTEGRATION_CLIENTS.aside.detectDir({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: 0 })); + expect(INTEGRATION_CLIENTS.aside.detectDir({}, home)).toBe(join(home, ".aside", "u", "0")); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.aside; + // The observed provider block has four keys and none is `headers`, so the + // dedicated admission header has nowhere to live on a remote bind. + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + // Not a bare models.json: pi's and prime's downloads would collide with it. + expect(spec.filename).toBe("aside-models.json"); + }); + + /* + * An unsigned-in Aside is not a broken Aside. + * + * Mutation must still refuse: there is no account to write. But the read-only + * state surface used to answer that refusal with `state: "unsafe"` and + * `configPath: ""`, which paints the red Cannot-verify badge and names no + * file. Absent `accounts.json` is the ordinary state of an Aside installed and + * never launched, so the read reports not-installed and names where the + * catalog would go. + */ + test("a never-signed-in Aside reads as not installed, not as unverifiable", () => { + mkdirSync(join(home, ".aside", "cli"), { recursive: true }); + + // Mutation still refuses, because there is no account directory to write. + expect(() => resolveIntegrationPaths("aside", {}, home)).toThrow(ClientPathError); + + // The read-only surface names the account root instead of nothing. + const hint = unresolvedPathHintFor("aside", {}, home); + expect(hint).toBe(join(home, ".aside", "u")); + // Deliberately NOT a writable catalog path: no account, no models.json. + expect(hint.endsWith("models.json")).toBe(false); + + const status = readIntegrationState({ + clientId: "aside", + models: context().models, + config: CONFIG, + port: 10100, + env: {}, + home, + }); + expect(status.state).toBe("absent"); + expect(status.installed).toBe(false); + expect(status.configPath).toBe(hint); + expect(status.reason).toBe("unresolvable-path"); + }); + + test("a client with no hint still reports unverifiable, because there is nothing to name", () => { + // OpenClaw's relative-selector refusal is a misconfiguration, not a + // not-yet-signed-in state, so the danger badge stays correct for it. + expect(unresolvedPathHintFor("openclaw", {}, home)).toBe(""); + const status = readIntegrationState({ + clientId: "openclaw", + models: context().models, + config: CONFIG, + port: 10100, + env: { OPENCLAW_CONFIG_PATH: "relative/config.json" }, + home, + }); + expect(status.state).toBe("unsafe"); + expect(status.configPath).toBe(""); + expect(status.reason).toBe("unresolvable-path"); + }); + + /* + * The hint lookup absorbs a path REFUSAL and nothing else. + * + * An unqualified catch there would read the same for a resolver that threw a + * TypeError from a typo or an EACCES from a filesystem probe: the badge would + * quietly say not-installed while the real cause went unreported. This drives + * a non-ClientPathError through the same seam and requires it to escape. + */ + test("a hint resolver that throws a programming error is not silently degraded", () => { + const spec = INTEGRATION_CLIENTS.aside as { unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string }; + const original = spec.unresolvedPathHint; + try { + spec.unresolvedPathHint = () => { throw new TypeError("join received undefined"); }; + expect(() => unresolvedPathHintFor("aside", {}, home)).toThrow(TypeError); + + // A path refusal is still absorbed, which is the whole point of the seam. + spec.unresolvedPathHint = () => { throw new ClientPathError("no account yet"); }; + expect(unresolvedPathHintFor("aside", {}, home)).toBe(""); + } finally { + spec.unresolvedPathHint = original; + } + }); +}); diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index d71612c012..4444547aa7 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -58,11 +58,12 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi and Gajae cannot emit the dedicated admission header. OMP and - // Prime can carry provider headers, but remote credential wiring is + // Pi, Kimi, Gajae and Aside cannot emit the dedicated admission header -- + // Aside's observed provider block has four keys and none is `headers`. OMP + // and Prime can carry provider headers, but remote credential wiring is // deliberately deferred from those initial generated integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index bad3b30c9f..09c46e1472 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -599,8 +599,8 @@ describe("stable ordering (accept criterion 4)", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the eleven file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + test("covers exactly the twelve file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index eb91532819..d9c91813a1 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -47,6 +47,17 @@ const TEST_ENV = {} as NodeJS.ProcessEnv; */ function installClient(clientId: IntegrationClientId): string { const spec = INTEGRATION_CLIENTS[clientId]; + /* + * Aside resolves its config path THROUGH its account manifest, so unlike + * every other client the path does not exist as a pure function of home. It + * throws rather than guessing an account, which is the point of that design, + * so the fixture has to establish which account is current before any + * resolver runs. + */ + if (clientId === "aside") { + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0 })); + } mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); const configPath = spec.configPath(TEST_ENV, home); mkdirSync(dirname(configPath), { recursive: true }); @@ -66,9 +77,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same eleven ids", async () => { + test("every list of clients holds exactly the same twelve ids", async () => { /* - * Five lists name the same eleven clients, and two of them are maintained by + * Five lists name the same twelve clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -79,7 +90,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(11); + expect(expected).toHaveLength(12); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -156,6 +167,8 @@ describe("every client survives a full lifecycle", () => { zcode: '{\n "provider": {\n "builtin:zai-start-plan": { "name": "Keep Me", "kind": "anthropic" }\n }\n}\n', // Prime reads Pi's models.json contract, so it seeds the same shape. prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + // Aside reads the same models.json contract as Pi and Prime. + aside: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', }; for (const clientId of INTEGRATION_CLIENT_IDS) { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index 040b5fa40a..3f2bdc5e65 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -755,9 +755,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode and prime are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime and aside are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); }); test("the registry restates nothing — it reads the export spec", () => {