diff --git a/docs-site/public/images/zcode-desktop-connected.png b/docs-site/public/images/zcode-desktop-connected.png new file mode 100644 index 0000000000..ff53e1cba1 Binary files /dev/null and b/docs-site/public/images/zcode-desktop-connected.png differ diff --git a/docs-site/public/images/zcode-provider.png b/docs-site/public/images/zcode-provider.png new file mode 100644 index 0000000000..4947ebfd5e Binary files /dev/null and b/docs-site/public/images/zcode-provider.png differ diff --git a/docs-site/public/images/zcode-quota-bars.png b/docs-site/public/images/zcode-quota-bars.png new file mode 100644 index 0000000000..984e56ceff Binary files /dev/null and b/docs-site/public/images/zcode-quota-bars.png differ diff --git a/docs-site/public/images/zcode-usage-flash.png b/docs-site/public/images/zcode-usage-flash.png new file mode 100644 index 0000000000..d3a13e611a Binary files /dev/null and b/docs-site/public/images/zcode-usage-flash.png differ diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f45f653c39..c7db8fdcbc 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -6,6 +6,9 @@ description: Every way opencodex authenticates and talks to an LLM provider — A **provider** is one upstream LLM endpoint plus how to reach it: an adapter, a base URL, an auth mode, and an optional model list. Providers live under `providers` in `~/.opencodex/config.json`. +For a tool-executing local agent rather than an HTTP model endpoint, see the opt-in +[ZCode app-server provider](/guides/zcode-agent/). It is separate from exporting models to ZCode Desktop. + The dashboard provider Overview separates connection details, account usage and editable notes. Notes appear once, below the connection and authentication sections. Supported sponsor presets also show a short introduction, a Sponsor label and links to the provider's site or console. diff --git a/docs-site/src/content/docs/guides/zcode-agent.md b/docs-site/src/content/docs/guides/zcode-agent.md new file mode 100644 index 0000000000..a8f316b69c --- /dev/null +++ b/docs-site/src/content/docs/guides/zcode-agent.md @@ -0,0 +1,437 @@ +--- +title: ZCode local agent provider +description: Run the official ZCode app server as an explicitly enabled OpenCodex provider with optional isolation. +--- + +## Direction and tool ownership + +The **ZCode (local agent)** provider sends OpenCodex requests to the official ZCode CLI's +`app-server`. This is the reverse of the existing **Integrations → ZCode** feature, which +exports OpenCodex models into ZCode Desktop. Enabling one does not enable the other. + +ZCode executes its own file and command tools. OpenCodex streams text, reasoning and +informational tool progress; it never sends those native actions back to the calling client +as executable function calls. Client tool catalogs, tool-result input and explicit +`tool_choice` constraints are not supported by this first version. + +This is an optional local transport, not a hosted API or a subscription-token bridge. +Only the official ZCode runtime communicates with Z.AI. Protocol compatibility was verified +with ZCode CLI **0.16.5**, shipped in ZCode Desktop **3.10.2** on Linux. The app-server +protocol is private and may change; unsupported framing fails closed. + +Attached images are described by the **vision sidecar configured in OpenCodex** before the +main request reaches ZCode. This helper can consume quota on its own configured provider; +the main GLM request still runs through official ZCode, never a direct Z.AI API fallback. +Search, image-generation and video-generation sidecars remain disabled for this native agent. + +The ZCode bridge itself accepts text, including for GLM-5.3-Flash: selecting Flash does not +make this transport image-capable. Neither model is offered as its own vision describer. +Configure an image-capable helper under **Dashboard → Vision sidecar**; existing backend/model +selection is respected. If vision is disabled, unavailable, capped or fails, images are replaced +with explicit unavailable-description markers rather than being silently passed to GLM or +presented as successfully analyzed. Unsupported video input and client tool results are not +converted into fabricated descriptions. + + This describes the technical path, not a guarantee of promotion eligibility, billing +or subscription terms; those remain the vendor's policy. + +## Connect from the dashboard (Linux) + +1. Install the official **ZCode Desktop**, open it on the **same computer as the proxy**, + and sign in/configure your Z.AI models there. Keep Desktop open during initial detection. +2. Use a browser session on a loopback-bound dashboard or a paired hub. An admin-token login on a public listener does not authorize Desktop setup. In OpenCodex, choose **Providers → Add Provider → ZCode (local agent)**. +3. Click **Detect again** if needed. OpenCodex finds running Desktop installations and standard + installation folders. For portable/extracted installations, the advanced folder field lets + you select the application directory; it never accepts a shell command. +4. Choose a **workspace folder** with the folder browser. It is the initial working directory, + not a filesystem boundary in the default host mode; choose a narrower project folder when + convenient, even though native Bash can still reach other files owned by the proxy user. +5. Review the native file/command execution notice, check the consent box and click + **Connect Desktop**. OpenCodex constructs the native launcher, verifies the protocol, enables the ZCode provider and updates the Codex catalog; + no environment variables, API-key entry, token import or separate CLI login is required. +6. Optionally select a model and click **Verify protocol again**. This repeats the official + model-catalog and `workspace/readState` checks without creating a model turn, running native + tools or consuming account quota. Only a real task can prove account entitlement and inference. +7. The provider is now available immediately. Select a ZCode model in your + calling client. Only configured, enabled built-in Z.AI profiles are exposed by this workflow; + custom Desktop providers and routes back to OpenCodex are not imported. + +For an existing provider, the same panel is in **Settings**. **Disconnect** revokes the managed +connection, disables that provider and removes its models from the converged Codex catalog while +preserving its customized settings for reconnection. It closes owned app-server children but does +not log out of Desktop, delete Desktop conversations, or change ZCode Desktop's own configuration. +A persisted connection still exposes **Disconnect** when a missing or incompatible prerequisite +makes it temporarily unusable, so the user is never trapped in a stale configured state. +A saved disconnected state also prevents an older environment-based setup from silently +reactivating. Reconnect to refresh the model catalog after changing Desktop's model configuration. + +The proxy host needs a Node.js version compatible with the installed ZCode +runtime. Missing prerequisites and unsupported platforms are shown in the panel. Managed setup +is currently Linux-only; Windows/macOS need the advanced launcher flow below. A remote browser's +local Desktop is not the proxy host's Desktop: detection and workspace selection operate on the +computer running OpenCodex. + +### Add another account (manual selection) + +In **Providers → Add Provider → ZCode (local agent)**, or an unbound ZCode Desktop +provider's **Settings**, scroll to **Saved ZCode accounts**. This is separate from +**Connect Desktop**: connecting Desktop continues to use its current local sign-in; +saved accounts retain independent official ZCode profiles. Settings for an account-bound +provider intentionally hide the unrelated global Desktop Connect/Disconnect controls. + +1. Select the installed Desktop runtime and working directory above the accounts section. +2. Give the account a recognizable name, such as **Personal** or **Work**. +3. Read and check the saved-account consent, then choose **Add account**. +4. Open the **Sign in with ZCode** link. Sign in to the intended account on ZCode's official + page; use a private browser window or switch accounts there when adding a second identity. +5. Return to the panel. OpenCodex waits for ZCode's own OAuth polling, verifies the local + protocol, enables a separate provider and publishes its models automatically. It never + asks you to copy tokens or API keys. Setup itself does not force an entitlement/quota + probe; use the explicit quota refresh or start an authorized task later. Do not close + the panel before this finishes. +6. Select the desired **account name / model** in your client's model picker. Adding an + account does **not** select it as the default and does not run inference. If an existing + Codex process still shows the old picker, restart Codex yourself after its tasks finish. + +Repeat for each account (up to 20). Accounts do not form a pool: there is no automatic +rotation or fallback to another ZCode account or the Desktop profile. Combos may target +explicit account providers if you configure those routes yourself. A combo containing any +ZCode target is advertised without caller-managed tool support, even when its other targets +support tools. Quota bars remain specific to each provider/account; a temporary quota-probe +failure preserves a recent valid reading, while a valid entitlement with no supported windows +clears it. + +**Sign in again** replaces only the matching account's sign-in, retaining its provider ID +and custom settings. A different identity is rejected; use **Add account** for it instead. +Duplicate saved identities are rejected too. **Rename** updates generated picker labels, +not customized model names. **Remove** deletes OpenCodex's saved profile and provider, not +ZCode Desktop's original profile. Remove references from defaults, combos, subagent routes and +other providers' routing/reviewer selectors before removing an account; provider and model aliases +count as references too. Wait for active tasks and +their native child processes to finish. No task is stopped automatically. +If Desktop revocation succeeds but saving the provider removal or updating the catalog fails, +OpenCodex reports a partial removal and refreshes the visible state. Retry **Remove**: the retry is +idempotent and never reconnects the account or silently switches to another one. + +A protocol/registration/catalog failure is displayed as pending, not complete success. +Use **Retry provider activation** for a saved connection whose catalog is pending. A +transient completion error keeps the authenticated job and the open panel retries it; +if the account-list refresh fails after server-side completion (including after provider/catalog +readiness), the same button retries that finished job directly without starting OAuth again; +for a failed or expired login, cancel the draft and start again. Saved accounts and +provider bindings persist across OpenCodex restarts; pending OAuth jobs do not. If a +restart or closed panel leaves an unfinished account, the next authenticated account +operation removes only that hidden orphaned draft; it never appears as a saved account or +permanently consumes one of the 20 account slots. A live new-account login temporarily +reserves its eventual slot so concurrent completions cannot exceed the limit. + +Native OAuth and Coding Plan credential setup run in the official installed ZCode host. +The bridge only carries a short-lived authorization URL and safe status codes to the +browser. Profiles stay in the proxy's private configuration directory; they are not +imported into the browser or pooled. The bridge checks the short refresh cache before queueing and +again after the profile becomes idle; if it expires behind a running task, the next task asks the +official runtime to refresh and reloads the resulting generation before dispatch. The same +host-access / optional-sandbox policy described below +applies. Setup is Linux-only and needs a dashboard-session principal, not just an admin API +token. + +### What “connect Desktop” means + +ZCode's app-server is a stdio child, not a public Desktop TCP endpoint. OpenCodex uses the +**official runtime bundled with the selected Desktop installation** and its existing model +configuration. It starts an owned native session; it does not attach to a conversation already +open in Desktop. The original **Integrations → ZCode** configuration export remains separate. + +By default, the managed runtime runs **without an OpenCodex OS sandbox**, with the +permissions of the proxy's operating-system user. It can read and modify files outside the +selected working directory, including sensitive files accessible to that user. Absolute +paths retain their host meaning. The selected workspace is a starting directory, not a +filesystem boundary. This default also applies to previously connected installations after +upgrading. Managed connections start ZCode in its non-interactive `yolo` permission mode so +native commands do not stop for an approval prompt that the OpenCodex protocol cannot display. +Because the official Bash tool otherwise creates its own sandbox, the disposable private ZCode +configuration also installs an official `PreToolUse` process hook for Bash. The hook preserves the +requested command and sets ZCode's supported `dangerouslyDisableSandbox=true` input +deterministically, even when a model omits it. OpenCodex does not patch the Desktop runtime or send +the request to a different API. Native file tools remain limited to the selected workspace, so a +bridge-owned reminder directs work on other absolute paths through Bash. This disables ZCode's +per-command sandbox; it does not elevate privileges or bypass the operating system or an outer +harness sandbox. + +Any isolation must be applied by the harness/operator **where the native ZCode process +executes**. A sandbox in a remote calling client does not automatically constrain this +server-side process. Only use trusted callers and projects. + +OpenCodex still separates bridge-owned runtime state from the Desktop profile to avoid +overwriting Desktop settings. Temporary compatible configs are private and removed when +the child exits normally; the session database remains private and persistent. Separate +state directories do not prevent native tools from accessing other host files. Only official +ZCode handles account and inference traffic; no direct-API fallback is introduced. + +### Optional OpenCodex sandbox + +Set the following in the environment of the OpenCodex service or terminal, then restart +OpenCodex at a safe time: + +```sh +export OCX_ZCODE_SANDBOX=1 +``` + +Bubblewrap is required only when this option is enabled. The existing restricted workspace +mounts and real sandbox preflight apply; failure never falls back to host execution. In this +mode the selected workspace appears as `/workspace`; other host files are not mounted, and the +managed host-execution hook and reminder are omitted. +Unset the variable to restore the default host execution. Environment changes apply to +existing managed connections without requiring another connection. They do not reconfigure +an advanced operator-supplied launcher. + +Managed consent and installation/workspace paths are stored privately under +`$OPENCODEX_HOME/zcode-desktop/`, separately from provider configuration. Data-plane requests +cannot choose an executable or override these paths. Status, folder browsing, connecting, +disconnecting and the optional tool-free protocol recheck require the dashboard's authenticated +GUI session, not a raw API/admin token. + +## Advanced: operator-supplied isolated launcher + +This remains available for existing deployments and environments without managed Linux setup. +A saved managed connection takes precedence over these environment variables. + +Install/extract the official runtime from [ZCode](https://zcode.z.ai/en/docs/install) into a +separate location. Do not replace your main ZCode or global OpenCodex installation. + +Create an **operator-owned executable launcher** that accepts ZCode arguments and starts +the runtime inside an OS sandbox (for example, Bubblewrap on Linux). Its contract is: + +- Expose only runtime/system files read-only, a separate writable ZCode home and the approved + disposable workspace. Do not mount the real home, main OpenCodex/ZCode data, host sockets, + SSH agents or production project directories. +- Set `HOME` and XDG directories inside that sandbox. The launcher must translate paths if + the sandbox home differs from its host path. +- Start `node /runtime/zcode.cjs "$@"` directly, without evaluating request text as shell code. +- Kill all sandbox descendants when the launcher exits; never reuse a desktop app-server. +- Permit the network access needed by your chosen provider. Filesystem isolation alone is + **not network isolation**: apply egress restrictions separately if untrusted commands must + not reach host-local services or the Internet. + +The bridge does not construct or attest an OS sandbox for you. Its native-execution opt-in +authorizes the launcher you configured. A plain, unsandboxed `zcode` executable is not a safe +replacement for that launcher. + +Run your launcher with `login --no-browser`, open the generated OAuth URL yourself and complete +login. Credentials remain in the **isolated** ZCode home; OpenCodex does not import credentials +from the main desktop installation. The official CLI creates its initial model configuration. +Additional models must be configured in that isolated profile, not just in Desktop's v2 file. + +## Configure OpenCodex + +Set these variables in the trusted environment that starts your isolated proxy (not a project +`.env` file or an incoming request): + +```bash +export OCX_ZCODE_NATIVE_TOOLS=1 +export OCX_ZCODE_COMMAND='["/srv/opencodex-zcode/zcode-sandbox"]' +export OCX_ZCODE_HOME=/srv/opencodex-zcode/zcode-home +export OCX_ZCODE_WORKSPACE=/workspace +``` + +`OCX_ZCODE_COMMAND` is a JSON argv array, not a shell command. The bridge appends `app-server`. +`OCX_ZCODE_HOME` is the **host-side** isolated home containing `.zcode/cli/config.json`; +`OCX_ZCODE_WORKSPACE` is the approved absolute path **inside** the launched environment. +Keep the proxy's own `HOME`, `CODEX_HOME`, `OPENCODEX_HOME`, XDG directories and listening port +separate from your main installation too. + +The dashboard's **Add Provider → ZCode Desktop** preset is the managed Linux Desktop flow; it +does not register an operator-supplied advanced launcher. For this advanced setup, add the +provider object to OpenCodex's `config.json` (for example through the dashboard JSON editor) and +then restart or reload the proxy: + +```json +{ + "providers": { + "zcode": { + "adapter": "zcode", + "authMode": "local", + "baseUrl": "https://zcode.z.ai", + "liveModels": true + } + } +} +``` + +The base URL is an identity marker; inference uses stdio, never HTTP to that URL. +Models come from the isolated `.zcode/cli/config.json`, with selectors such as +`zcode/zai/glm-5.1`. Disabled entries and obvious local proxy destinations are excluded to +avoid recursive routing. Do not configure indirect routes or DNS aliases back to OpenCodex. +The existing catalog refresh flow reloads these settings; no primary client configuration is +rewritten. **Test connection** checks the local catalog only, not account entitlement or quota. +The bridge fingerprints the bounded configuration contents into the native-session scope. Changing +an account or credential in place invalidates prior continuations; a change while a request waits +in the profile queue fails before the child starts. The fingerprint is not returned by management +or discovery APIs. + +## Safety and limitations + +- Native execution requires either a persisted, explicitly consented Desktop connection or all + four advanced environment settings. Disconnecting revokes managed execution. +- Managed Desktop sessions use ZCode's non-interactive `yolo` permission mode after the explicit + connection consent. Their disposable private config uses ZCode's official hook surface to apply + the supported unsandboxed-call input to native Bash. Advanced operator launchers retain `edit` + mode and their own isolation policy. Interactive permission + requests are still denied, and user-input requests are cancelled rather than answered + automatically; continue those workflows in the ZCode client itself. +- Each turn owns a child process. A profile has one active turn at a time because ZCode writes + model selection to shared settings; at most 24 active/queued calls are admitted per profile and + 32 across the process, so one stalled account cannot consume every reservation. +- `previous_response_id` uses OpenCodex's owner-fenced private continuation state, including when + Codex sends `store: false`. Client thread identity also supports process-local continuity. A + different model/profile or an uncorrelated request gets a separate session. If a saved session + cannot resume, the bridge reports failure rather than silently replaying work in a new session. +- Codex compaction runs in a fresh official session with an empty native-tool allowlist and cannot + replace the main continuation. Any unexpected tool event fails the compaction closed. Before a + turn starts, OpenCodex also checks the fully serialized `session/send` line against the Desktop + bridge limit, so escaped control characters cannot turn an accepted request into a later crash. +- The default turn deadline is five minutes. Cancellation/deadline closes the owned child. + The launcher is responsible for terminating descendants. `Task`, `TaskOutput` and `TaskStop` + are denied; work cannot keep an owned sandbox running after its turn ends. +- Once a task is sent, failures become non-retryable `zcode_agent_interrupted` incomplete + responses. Automatic empty-completion replay is disabled for this adapter. Do not manually + retry a failed mutation without checking the workspace first. +- Vendor errors and stderr are not forwarded. No raw credentials or protocol traces are + included in discovery/management results. Token usage is currently unavailable, not measured + as zero; model access still consumes your ZCode account quota. +- Linux was tested. Windows/macOS require an equivalent advanced launcher and are not yet + live-validated. GLM-5.3 and GLM-5.3-Flash advertise and map ZCode's `low`, `high`, and `max` + thought levels; Codex also offers `ultra` as its multi-agent/max orchestration tier. Unknown + future models expose no generic reasoning picker unless the operator configures one. + +## Verification + +Start the isolated proxy bound to loopback on an unused port, then check `/healthz` and +`/v1/models`. Explicitly submit a short request to a `zcode/...` model before claiming that +authentication works. Test a harmless file write in the disposable workspace and verify it +on disk. Do not use private projects or publish account-bearing logs as fixtures. + +## Usage-time notices + +Providers → Usage / current account limits and the overview rate-limit section +show time-based Coding Plan notices for ZCode. The browser's timezone is used +(not the proxy server's timezone), including date changes and daylight saving. +These notices never change routing, recorded usage, or estimated costs, and make +no direct provider API calls. + +Official rules verified September 11, 2026: + +- [Coding Plan rates](https://docs.z.ai/devpack/overview): peak hours are Monday + through Friday, 14:00–18:00 Singapore (UTC+8). Other hours, including weekends, + receive 50% off the standard **model credit** rate, not necessarily MCP charges. +- [Flash campaign](https://docs.z.ai/devpack/notice/event-glm-5.3-flash): September + 3–20, 2026, daily 23:00–09:00 Singapore, including weekends. GLM-5.3-Flash via + ZCode 3.10+ has zero quota consumption for paid Coding Plan subscribers only + while **both** the 5-hour and weekly quotas have remaining allowance. An + exhausted quota must reset before participating. Other agents receive a + different benefit, not zero consumption; GLM-5.3 is excluded. + +The notice distinguishes an active time window from account eligibility: +OpenCodex does not verify the subscription balance or Desktop version here. +Campaign notices expire automatically. Since the announcement does not specify +whether the final overnight window extends into September 21, OpenCodex +conservatively stops advertising it at midnight ending September 20 Singapore. +Source links and this boundary caveat are visible in the notice. Exact Z.AI +Coding Plan HTTP endpoints also show peak/off-peak notices, but never the +ZCode-only free-window alert; ordinary pay-as-you-go endpoints are excluded. + +## Remaining subscription quota + +The provider overview and Usage tab reuse the same quota-bar component as OpenAI. +For ZCode, the fill and label show **remaining** model credits in the 5-hour and +weekly windows, with localized reset times. Warning colors still reflect high +consumption. Unknown or failed readings do not appear as a full balance. + +Quota comes from the **official ZCode Desktop host service**, not from locally +counted tokens or a proxy-made Z.AI API request. OpenCodex starts the trusted +Desktop executable in its Node runtime mode and calls its existing read-only +entitlement RPC. Native ZCode owns authentication and network requests. This +separate short-lived host uses a disposable private profile copy so native normalization +does not rewrite Desktop settings. The copy is removed after the process exits. +When `OCX_ZCODE_SANDBOX=1` is enabled, this host additionally uses Bubblewrap and tmpfs. +No credential material is returned in the quota response. No model prompt, quota-reset action, or +purchase is requested. Tested with Desktop 3.10.2 on Linux; other private host +protocol versions can return unavailable instead of a fabricated quota. + +Managed Desktop connections need no extra quota configuration. For the advanced +operator-launcher setup, explicitly set `OCX_ZCODE_DESKTOP_RUNTIME` to the trusted +installed/extracted Desktop directory (the directory containing `resources/`). +The native quota host then uses the Z.AI key from the **same isolated CLI config**, +not the user's unrelated Desktop login. The source provider is the configured +default model's provider prefix, or `zai` when none is set; an absent key or +unsupported profile is reported as unavailable. This opt-in does not change +inference routing or migrate your model IDs. + +The dashboard's existing quota cache and Refresh quotas controls apply. Concurrent +reads share one native probe. Account/configuration identity changes invalidate snapshots. A +transient probe error retains only the bounded same-key last-good reading; it never fabricates a +full balance, and the report disappears after the last-good age expires. +These bars are informational and do not enable quota-based automatic rerouting. +The native session app-server's token accounting remains unavailable; subscription +quota is a distinct measurement. See [ZCode Usage Stats](https://zcode.z.ai/en/docs/usage-stats) +for the distinction between App Usage and Coding Plan statistics. + +### Desktop Node compatibility + +Managed Desktop setup requires **Node.js 24.0.0 or newer**, with working +node:sqlite, on the **OpenCodex process PATH**. This is the bridge's supported +floor, not a claim that every earlier Node version is incompatible with every +ZCode release. Selection checks each absolute PATH directory in order and skips +incompatible Node binaries, including an older npm/nvm prefix ahead of a modern +installation. Relative and empty PATH entries are not searched. It does not +assume that the Node which launched OpenCodex is the Node resolved by Bun. + +If no suitable executable is found, setup reports node_incompatible (or +node_missing when none exists) before reading the Desktop profile or starting +the app server. Add a compatible Node directory to the PATH used by your terminal +**or systemd service**, restart that process, and choose **Detect again**. +No global Node replacement is required. Capability checks discard stderr and +inherit neither credentials nor NODE_OPTIONS. Connection still requires explicit +consent and a successful official workspace/readState protocol check; the +bootstrap's model catalog alone does not prove readiness. + +The quota reader launches Desktop's own Electron executable with +ELECTRON_RUN_AS_NODE, not the external Node selected from PATH. Managed account +settings still pass the same Desktop prerequisites; advanced quota reads do not +use this external Node selection. + + +### One-step activation and recovery + +**Connect Desktop** includes protocol verification, idempotent provider registration +and the normal Codex catalog convergence. No separate **Use this provider** action is +required. It does not change default provider/model selections or execute inference. +Reconnection enables the existing ZCode provider without replacing custom options, +aliases, model filters or pricing; conflicting provider registrations are not overwritten. + +Success is shown only when the provider is enabled and every model allowed by its existing +visibility settings has converged into the actual Codex catalog. A protocol-only connection, +failed config write or failed, +skipped or incomplete catalog refresh remains a partial state. **Retry activation** +finishes registration/catalog publication without sending a prompt or reconnecting the +runtime. Review the activation consent again before retrying. Existing `selectedModels` and +`disabledModels` filters are preserved; models they intentionally hide are not required for a +ready state. + +If a running Codex instance does not pick up the models, restart it after its tasks +finish. OpenCodex does not terminate or restart Codex processes automatically. + + +### Sandbox policy and cold starts + +When OCX_ZCODE_SANDBOX=1 is set, detection runs a minimal Bubblewrap sandbox from the OpenCodex server itself, without +loading your profile or sending inference. If it reports `sandbox_unavailable`, ask +your administrator to review user namespace and AppArmor policy for that service. +A successful probe from a terminal or another application does not prove that the +server has the same permissions. OpenCodex never disables isolation or changes host +security policy automatically. After a policy correction, select **Detect again**. + +Managed Desktop native model IDs also supply routing after a cold restart, before +live discovery fills the cache. You do not need to add them manually to provider +settings. Existing model filters and defaults remain unchanged; ambiguous aliases +are still rejected instead of guessed. diff --git a/gui/.eslint/i18n-allowlist.ts b/gui/.eslint/i18n-allowlist.ts index 805d7dcfbc..a29b11da26 100644 --- a/gui/.eslint/i18n-allowlist.ts +++ b/gui/.eslint/i18n-allowlist.ts @@ -10,6 +10,7 @@ const BRAND_LITERALS = new Set([ "Anthropic", "GitHub", "Codex", + "ZCode Desktop", "OpenRouter", "Ollama", "xAI", diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 5daa45e2cb..a2d0216a79 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -20,11 +20,13 @@ import type { CatalogLoginHint } from "./provider-catalog/login-hint-visibility" import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../base-url-choice"; import { AddProviderOAuthPane } from "./add-provider-oauth-pane"; import { AddProviderFormPane } from "./add-provider-form-pane"; +import ZcodeDesktopPane from "./ZcodeDesktopPane"; import { useAddProviderOAuth } from "./use-add-provider-oauth"; import { addProviderModalReducer, createInitialAddProviderState, } from "./add-provider-modal-reducer"; +import type { ProviderAdditionMetadata } from "../provider-addition"; export type ProviderConfig = ProviderPayload; @@ -34,11 +36,12 @@ export default function AddProviderModal({ apiBase, existingNames, onClose, onAdded, initialTier, initialCustom = false, accountRows, accountStatus, accountBusy, accountLoginHint = null, onAccountLogin, onAccountCancelLogin, onAccountLogout, onAccountManage, onOpen, + onProviderStateMutation, }: { apiBase: string; existingNames: string[]; onClose: () => void; - onAdded: (name: string) => void; + onAdded: (name: string, metadata?: ProviderAdditionMetadata) => void; initialTier?: "accounts" | "free" | "paid"; initialCustom?: boolean; accountRows?: AccountLoginRow[]; @@ -51,6 +54,7 @@ export default function AddProviderModal({ onAccountLogout?: (provider: string) => void; onAccountManage?: (provider: string) => void; onOpen?: () => void; + onProviderStateMutation?: () => void; }) { const t = useT(); const fallbackPresets = useMemo(() => [ @@ -301,7 +305,10 @@ export default function AddProviderModal({ }} /> ) : form && ( - preset.auth === "oauth" && form.authMode === "oauth" ? ( + preset.id === "zcode" ? ( + dispatch({ type: "back" })} /> + ) : preset.auth === "oauth" && form.authMode === "oauth" ? ( (
@@ -419,11 +426,12 @@ function StackedQuotaRow({ row, threshold, t, locale, incomplete }: { {resetText}
-
-
+
+
- {t("quota.usedPercent", { pct: Math.round(row.percent) })} + {showRemaining ? remainingText : t("quota.usedPercent", { pct: Math.round(row.percent) })}
{exhausted && ( diff --git a/gui/src/components/ZcodeAccountsPane.tsx b/gui/src/components/ZcodeAccountsPane.tsx new file mode 100644 index 0000000000..c0e96840d6 --- /dev/null +++ b/gui/src/components/ZcodeAccountsPane.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; + +type Account = { id: string; label: string; activation: string; busy: boolean; providerName?: string }; +type Job = { jobId: string; accountId: string; phase: string; url?: string; error?: string }; +type Activation = { activation?: string; providerName?: string }; +export default function ZcodeAccountsPane({ apiBase, runtime, workspace, onProviderActivated, onProviderStateMutation }: { + apiBase: string; + runtime: string; + workspace: string; + onProviderActivated?: (name: string) => void; + onProviderStateMutation?: () => void; +}) { + const t = useT(); + const [accounts, setAccounts] = useState([]); + const [label, setLabel] = useState(""); + const [consented, setConsented] = useState(null); + const consentScope = JSON.stringify([apiBase, runtime, workspace]); + const consent = consented === consentScope; + const [job, setJob] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const onProviderActivatedRef = useRef(onProviderActivated); + const onProviderStateMutationRef = useRef(onProviderStateMutation); + useEffect(() => { onProviderActivatedRef.current = onProviderActivated; }, [onProviderActivated]); + useEffect(() => { onProviderStateMutationRef.current = onProviderStateMutation; }, [onProviderStateMutation]); + const read = useCallback(async (path: string, body?: Record) => { + const response = await fetch(apiBase + "/api/zcode-accounts" + path, body ? { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, consent: true }), + } : undefined); + const value = await response.json(); + if (!response.ok) throw new Error(value.error || "native_oauth_failed"); + return value; + }, [apiBase]); + const refresh = useCallback(async () => { + const value = await read(""); + setAccounts(value.accounts ?? []); + }, [read]); + const completeJob = useCallback(async (current: Job, stopped: () => boolean = () => false) => { + let result: Activation; + try { + result = await read("/complete", { jobId: current.jobId }) as Activation; + } catch (e) { + if (!stopped()) { + setError(e instanceof Error ? e.message : "native_oauth_failed"); + // A recovery retry already owns a finished, idempotent job. Keep that state so a + // transient /complete failure cannot restart OAuth polling or hide Retry activation. + setJob({ ...current, phase: current.phase === "recovery" ? "recovery" : "authenticated", url: undefined }); + } + return; + } + if (stopped()) return; + onProviderStateMutationRef.current?.(); + const ready = result.activation === "ready"; + let refreshFailed = false; + try { await refresh(); } catch { refreshFailed = true; } + if (stopped()) return; + setError(!ready ? "catalog_update_failed" : refreshFailed ? "account_refresh_failed" : ""); + // Server completion is idempotent. Retain its finished job id whenever the account row could + // not be refreshed so the user can retry /complete directly without repeating OAuth. This + // also covers a ready provider whose new account row is not visible in existing Settings. + setJob({ ...current, phase: refreshFailed ? "recovery" : "finished", url: undefined }); + if (ready && result.providerName && onProviderActivatedRef.current) { + onProviderActivatedRef.current(result.providerName); + } + }, [read, refresh]); + useEffect(() => { + let stopped = false; + void read("").then(value => { if (!stopped) setAccounts(value.accounts ?? []); }) + .catch(e => { if (!stopped) setError(e.message); }); + return () => { stopped = true; }; + }, [read]); + const jobId = job?.jobId, jobPhase = job?.phase; + useEffect(() => { + if (!jobId || !consent || !["waiting", "authenticated"].includes(jobPhase ?? "")) return; + let stopped = false, pending = false; + const poll = async () => { + if (pending) return; + pending = true; + try { + const next: Job = await read("/login?jobId=" + encodeURIComponent(jobId)); + if (stopped) return; + if (next.phase === "authenticated") { + await completeJob(next, () => stopped); + } else { setJob(next); if (next.phase === "failed") { setError(next.error || "native_oauth_failed"); await refresh(); } } + } catch (e) { if (!stopped) { setError(e instanceof Error ? e.message : "native_oauth_failed"); setJob(j => j ? { ...j, phase: "failed", url: undefined } : null); } } + finally { pending = false; } + }; + void poll(); + const timer = setInterval(() => void poll(), 2000); + return () => { stopped = true; clearInterval(timer); }; + }, [jobId, jobPhase, consent, read, refresh, completeJob]); + const action = async (path: string, body: Record) => { + if ((!consent && path !== "/cancel") || busy) return; + setBusy(true); setError(""); + try { + const result = await read(path, body); + if (path === "/login") setJob(result); + else if (path === "/cancel") setJob(null); + else if (result.activation && result.activation !== "ready") setError("catalog_update_failed"); + if (path === "/activate" || path === "/rename" || path === "/remove") { + onProviderStateMutationRef.current?.(); + } + const ready = path === "/activate" && result.activation === "ready"; + const activation = ready && result.providerName && onProviderActivatedRef.current + ? { name: result.providerName, notify: onProviderActivatedRef.current } : undefined; + try { await refresh(); } catch (error) { if (path !== "/activate") throw error; } + if (activation) activation.notify(activation.name); + } catch (e) { + const code = e instanceof Error ? e.message : "native_oauth_failed"; + // Removal revokes and persists provider state before catalog convergence. Its bounded + // partial error must still invalidate the parent and refresh this account list. + if (path === "/remove" && ["account_removal_partial", "catalog_update_failed"].includes(code)) { + onProviderStateMutationRef.current?.(); + try { await refresh(); } catch { /* The mutation callback remains authoritative. */ } + } + setError(code); + } + finally { setBusy(false); } + }; + const retryCompletion = async () => { + if (!job || job.phase !== "recovery" || !consent || busy) return; + setBusy(true); setError(""); + try { await completeJob(job); } finally { setBusy(false); } + }; + // Failed jobs retain their hidden profile until Cancel, so they must also block another Add or + // Reconnect attempt. The server applies the same ownership rule and the ten-minute expiry is a + // final cleanup bound rather than the ordinary retry path. + const loggingIn = !!job && ["waiting", "authenticated", "failed"].includes(job.phase); + return
+

{t("zcodeAccounts.title")}

+

{t("zcodeAccounts.help")}

+ + + + {job && ["waiting", "authenticated"].includes(job.phase) &&
+

{t("zcodeAccounts.loginHint")}

+ {job.url && {t("zcodeAccounts.login")}} + +
} + {job?.phase === "failed" && } + {job?.phase === "recovery" &&
+

{t("zcodeAccounts.pending")}

+ +
} + {error &&

{t("zcodeAccounts.failed")} {error}

} + {accounts.some(account => account.activation === "ready") &&

{t("zcodeDesktop.restartNotice")}

} + {accounts.map(account =>
+ {account.label} + {account.activation === "ready" ? t("zcodeDesktop.connected") : t("zcodeAccounts.pending")} + {account.providerName && {account.providerName}} +
+ + + + +
+
)} +
; +} diff --git a/gui/src/components/ZcodeDesktopPane.tsx b/gui/src/components/ZcodeDesktopPane.tsx new file mode 100644 index 0000000000..803a9fa321 --- /dev/null +++ b/gui/src/components/ZcodeDesktopPane.tsx @@ -0,0 +1,143 @@ +import ZcodeAccountsPane from "./ZcodeAccountsPane"; +import { useEffect, useState } from "react"; +import { useT } from "../i18n/shared"; +import type { ProviderAdditionMetadata } from "../provider-addition"; + +interface Status { + connected: boolean; configured?: boolean; sandbox?: boolean; issue?: string; runtimes: string[]; runtime: string; workspace: string; + activation?: string; providerName?: string; error?: string; + models: Array<{ id: string; label: string }>; +} +interface Folders { current: string; parent: string | null; folders: Array<{ name: string; path: string }> } + +export default function ZcodeDesktopPane({ apiBase, onConnected, onProviderStateMutation, onBack, error: parentError }: { + apiBase: string; + onConnected?: (name: string, metadata?: ProviderAdditionMetadata) => void; + onProviderStateMutation?: () => void; + onBack?: () => void; + error?: string; +}) { + const t = useT(); + const [status, setStatus] = useState(null); + const [runtime, setRuntime] = useState(""); + const [workspace, setWorkspace] = useState(""); + const [consent, setConsent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [tested, setTested] = useState(false); + const [model, setModel] = useState(""); + const [folders, setFolders] = useState(null); + const announceProvider = (name: string) => onConnected?.(name, { adapter: "zcode" }); + const applyStatus = (next: Status) => { + setStatus(next); setRuntime(next.runtime); setWorkspace(next.workspace); + setModel(next.models[0]?.id ?? ""); setError(next.error ?? next.issue ?? ""); + }; + useEffect(() => { + const controller = new AbortController(); + void fetch(`${apiBase}/api/zcode-desktop`, { signal: controller.signal }).then(async response => { + if (!response.ok) throw new Error(); + const next = await response.json() as Status; + if (!controller.signal.aborted) applyStatus(next); + }).catch(() => { if (!controller.signal.aborted) setError("runtime_failed"); }); + return () => controller.abort(); + }, [apiBase]); + + const perform = async (action: "refresh" | "connect" | "activate" | "disconnect" | "test") => { + setBusy(true); setError(""); setTested(false); + try { + const response = await fetch(`${apiBase}/api/zcode-desktop${action === "refresh" ? "" : `/${action}`}`, action === "refresh" ? {} : { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify(action === "connect" || action === "activate" ? { runtime, workspace, consent } : action === "test" ? { model, consent: true } : {}), + }); + const result = await response.json(); + if (!response.ok) { setError(result.error ?? "runtime_failed"); return; } + if (action === "test") { + setTested(result.ok === true); + if (result.ok !== true) setError(result.error ?? "protocol_failed"); + } + else { + applyStatus(result as Status); setConsent(false); + if (action === "connect" || action === "activate" || action === "disconnect") onProviderStateMutation?.(); + if ((action === "connect" || action === "activate") && result.activation === "ready" && result.providerName) announceProvider(result.providerName); + } + } catch { setError("runtime_failed"); } + finally { setBusy(false); } + }; + const browse = async (path?: string) => { + try { + const response = await fetch(`${apiBase}/api/zcode-desktop/folders${path ? `?path=${encodeURIComponent(path)}` : ""}`); + if (!response.ok) throw new Error(); + setFolders(await response.json()); + } catch { setError("workspace_invalid"); } + }; + const problem = error === "desktop_missing" ? t("zcodeDesktop.missing") + : error === "sandbox_unavailable" ? t("zcodeDesktop.sandboxUnavailable") + : error === "sandbox_missing" ? t("zcodeDesktop.sandboxMissing") + : error === "dashboard_required" ? t("zcodeDesktop.dashboardRequired") + : error === "node_missing" ? t("zcodeDesktop.nodeMissing") + : error === "node_incompatible" ? t("zcodeDesktop.nodeIncompatible") + : error === "platform_unsupported" ? t("zcodeDesktop.platformUnsupported") + : error === "profile_missing" || error === "models_missing" ? t("zcodeDesktop.loginNeeded") + : error === "workspace_invalid" ? t("zcodeDesktop.workspaceInvalid") + : error === "protocol_failed" ? t("zcodeDesktop.protocolFailed") + : error === "provider_registration_failed" ? t("zcodeDesktop.providerPending") + : error === "catalog_update_failed" ? t("zcodeDesktop.catalogPending") + : error ? t("zcodeDesktop.failed") : ""; + const changed = runtime !== status?.runtime || workspace !== status?.workspace; + const partial = status?.connected && status.activation !== "ready"; + return
+ {t("zcodeDesktop.title")} +

{t("zcodeDesktop.intro")}

+

{t(status?.sandbox ? "zcodeDesktop.sandboxAccess" : "zcodeDesktop.hostAccess")}

+
{status?.connected ? status.activation === "ready" ? t("zcodeDesktop.connected") + : status.activation === "provider_pending" ? t("zcodeDesktop.providerPending") : t("zcodeDesktop.catalogPending") + : t("zcodeDesktop.notConnected")}
+

{t("zcodeDesktop.restartNotice")}

+
+ {t("zcodeDesktop.open")} + + {t("zcodeDesktop.download")} +
+ {runtime && {runtime}} +
+ {t("zcodeDesktop.location")} + + {status?.runtimes.map(path => )} +
+ + + {folders &&
+ {folders.current} + {folders.parent && } + {folders.folders.map(folder =>
+ + +
)} +
} + {(!status?.connected || changed || partial) && } + {(problem || parentError) &&

{parentError || problem}

} + {tested &&

{t("zcodeDesktop.testPassed")}

} +
+ {onBack && } + {(!status?.connected || changed) && } + {(status?.configured || status?.connected) && } + {partial && !changed && } +
+ {status?.connected && !changed && <> +

{t("zcodeDesktop.testHint")}

+ + + } + +
; +} diff --git a/gui/src/components/ZcodeUsageNotices.tsx b/gui/src/components/ZcodeUsageNotices.tsx new file mode 100644 index 0000000000..ac56a5ecce --- /dev/null +++ b/gui/src/components/ZcodeUsageNotices.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { useI18n, useT } from "../i18n/shared"; +import { browserTimeZone, formatZcodeLocalTime, zcodeUsageSchedule, ZCODE_CAMPAIGN_SOURCE, ZCODE_USAGE_SOURCE } from "../zcode-usage-schedule"; + +export default function ZcodeUsageNotices({ viaZcode = true }: { viaZcode?: boolean }) { + const t = useT(); + const { locale } = useI18n(); + const [now, setNow] = useState(Date.now); + const schedule = zcodeUsageSchedule(now); + const zone = browserTimeZone(); + const local = (timestamp: number) => formatZcodeLocalTime(timestamp, locale, zone); + useEffect(() => { + const update = () => setNow(Date.now()); + const interval = setInterval(update, 1000); + window.addEventListener("focus", update); + document.addEventListener("visibilitychange", update); + return () => { clearInterval(interval); window.removeEventListener("focus", update); document.removeEventListener("visibilitychange", update); }; + }, []); + return
+

{t("zcodeUsage.title")}

+

{t("zcodeUsage.zone", { zone })}

+
+

+ {t(schedule.peak ? "zcodeUsage.peak" : "zcodeUsage.offPeak", { until: local(schedule.rateChangesAt) })} +

+ {viaZcode && schedule.campaign && <> +

+ {t(schedule.flashActive ? "zcodeUsage.flashActive" : "zcodeUsage.flashNext", { + start: local(schedule.flashStart), end: local(schedule.flashEnd), + })} +

+

{t("zcodeUsage.eligibility")}

+ } +
+

{t("zcodeUsage.rules")}

+ {t("zcodeUsage.source")} + {viaZcode && <> · {t("zcodeUsage.campaignSource")}} +
; +} diff --git a/gui/src/components/provider-workspace/ProviderAccountQuota.tsx b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx index dc1f7f9a7b..0b671d65bb 100644 --- a/gui/src/components/provider-workspace/ProviderAccountQuota.tsx +++ b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx @@ -6,7 +6,14 @@ import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; import type { AccountQuotaReading } from "./types"; /** The same reading states and credit/window renderer for current and all-account views. */ -export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quotaUnavailable, quotaPending, quotaFailure }: AccountQuotaReading) { +export default function ProviderAccountQuota({ + quota: rawQuota, + quotaMode, + quotaUnavailable, + quotaPending, + quotaFailure, + source, +}: AccountQuotaReading & { source?: string }) { const t = useT(); const quota = accountQuotaFromReport({ quota: rawQuota }); if (quotaMode === "unsupported") { @@ -19,7 +26,7 @@ export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quota {quotaUnavailable &&

{t(failure ? `pws.quotaFailure.${failure}` : "pws.accountQuotaUnavailable")}

} {quota || pending ? ( ) : !quotaUnavailable && ( diff --git a/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx b/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx index f651e25821..e5df4c311b 100644 --- a/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx +++ b/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx @@ -90,6 +90,7 @@ export function ProviderCapacityQuota({ report, pending }: { report: ProviderQuo {showsAggregate &&
{t("pws.capacity.estimate")}
} {(primaryQuota || pending) && ( Promise; @@ -45,7 +49,8 @@ export default function ProviderCurrentQuota({ report, reading, onRefreshQuota }
}
- + + {item && zcodeUsageScope(item) && } {quota && !effective.quotaUnavailable && effective.quotaMode !== "unsupported" &&
{!rowOwnsReading && current?.source?.trim() &&
{t("pws.stats.source")}
{formatQuotaSourceLabel(current.source)}
diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index aa843b86fc..5c7839648f 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -58,6 +58,7 @@ export default function ProviderDetails({ onCodexActiveNeedsReauthChange, codexController, onUpdateProvider, + onProviderStateMutation, isDefault, onRemoveProvider, onSetDisabled, @@ -99,6 +100,7 @@ export default function ProviderDetails({ /** Shared Codex account state owned by Providers (WP3). */ codexController?: CodexAccountPoolController; onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; + onProviderStateMutation?: () => void; isDefault?: boolean; onRemoveProvider?: (name: string) => void; onSetDisabled?: (name: string, disabled: boolean) => void; @@ -355,6 +357,7 @@ export default function ProviderDetails({ apiBase={apiBase} availableModels={availableModels} onUpdateProvider={onUpdateProvider} + onProviderStateMutation={onProviderStateMutation} onDirtyChange={setSettingsDirty} onRegisterSave={registerSettingsSave} /> diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 60efdb3950..7ad644a91c 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -279,7 +279,7 @@ export default function ProviderOverview({ )} - +
diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index 33c5dcd2f0..28549c2c3a 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -1,3 +1,5 @@ +import ZcodeUsageNotices from "../ZcodeUsageNotices"; +import { zcodeUsageScope } from "../../zcode-usage-schedule"; /** * ProviderOverviewDashboard — aggregate overview when no provider is selected. * Shows summary cards, attention list, per-provider rate limits (QuotaBars stacked), @@ -245,6 +247,7 @@ export default function ProviderOverviewDashboard({ ) : (

{t("pws.dashboard.noRateLimits")}

)} + {allItems.some(item => zcodeUsageScope(item)) && zcodeUsageScope(item) === "zcode")} />}
Promise; + /** Reload parent provider/catalog state after nested ZCode management mutations. */ + onProviderStateMutation?: () => void; onDirtyChange?: (dirty: boolean) => void; /** Lets parent dialogs trigger the same save path as the sticky bar. */ onRegisterSave?: (save: (() => Promise) | null) => void; @@ -326,6 +329,8 @@ export default function ProviderSettings({ return (
+ {item.adapter === "zcode" && item.zcodeAccountId === undefined && apiBase !== undefined + && }
)} - +
); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 992396cd49..c7e3902ca8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,7 +5,68 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { - "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", + "quota.remainingPercent": "{pct}% verbleibend", + "zcodeUsage.title": "GLM Coding Plan · zeitabhängige Vorteile", + "zcodeUsage.zone": "Zeiten in {zone}. Referenz: Singapur (UTC+8).", + "zcodeUsage.peak": "Spitzenzeit: regulärer Modell-Credittarif bis {until}.", + "zcodeUsage.offPeak": "Nebenzeit: 50 % Rabatt auf den regulären Modell-Credittarif bis {until}.", + "zcodeUsage.flashActive": "Kostenloses GLM-5.3-Flash-Zeitfenster über ZCode aktiv: kein Kontingentverbrauch bis {end}, sofern die Bedingungen unten erfüllt sind.", + "zcodeUsage.flashNext": "Nächstes kostenloses GLM-5.3-Flash-Zeitfenster über ZCode: {start} – {end}.", + "zcodeUsage.eligibility": "Erfordert einen kostenpflichtigen GLM Coding Plan, ZCode ab 3.10 und Restkontingent in BEIDEN Limits (5 Stunden und Woche). Guthaben und Berechtigung werden hier nicht geprüft. GLM-5.3 und andere Agenten sind von diesem Nullverbrauch ausgeschlossen.", + "zcodeUsage.rules": "Spitzenzeit: Mo–Fr 14–18 Uhr Singapur; sonst Nebenzeit. Flash-Aktion: 3.–20. September 2026, täglich 23–9 Uhr Singapur. Der Hinweis endet vorsorglich am 20. September; eine Verlängerung der letzten Nacht ist nicht angegeben. Hinweise ändern keine Verbrauchs- oder Kostenberichte.", + "zcodeUsage.source": "Offizielle Tarife · geprüft am 11. September 2026", + "zcodeUsage.campaignSource": "Offizielle Aktionsbedingungen", + "zcodeDesktop.providerPending": "Desktop-Protokoll geprüft, Anbieterregistrierung unvollständig. Aktivierung erneut versuchen; Einstellungen bleiben erhalten.", + "zcodeDesktop.catalogPending": "Anbieter aktiviert, Codex-Katalog noch nicht bereit. Aktivierung erneut versuchen und bei fehlenden Modellen eigene Sichtbarkeitsfilter prüfen.", + "zcodeDesktop.retryActivation": "Aktivierung wiederholen", + "zcodeDesktop.restartNotice": "Falls Codex die Modelle nicht zeigt, starten Sie Codex nach Abschluss Ihrer Aufgaben neu. Kein automatischer Prozessneustart.", + "zcodeDesktop.dashboardRequired": "Diese Aktion erfordert eine Browsersitzung über ein an Loopback gebundenes Dashboard oder einen gekoppelten Hub. Ein Admin-Token allein erlaubt keine Desktop-Einrichtung.", + "zcodeDesktop.hostAccess": "ZCode läuft ohne OpenCodex-Sandbox und kann alle Dateien lesen oder ändern, die Benutzerrechte und Harness erlauben. Der Arbeitsordner ist keine Zugriffsgrenze. Dies gilt nach Updates auch für bestehende Verbindungen. Für Isolation OCX_ZCODE_SANDBOX=1 am Proxy setzen und OpenCodex neu starten.", + "zcodeDesktop.sandboxAccess": "Die optionale Bubblewrap-Isolation ist aktiv. Ausgewählte System- und Laufzeitpfade sowie vorhandene Profil- und Anmeldedateien werden schreibgeschützt eingebunden. Ein privates Home und der gewählte Arbeitsordner unter /workspace sind beschreibbar; temporäre Dateisysteme sind verfügbar. Andere Hostpfade werden nicht eingebunden.", + "zcodeAccounts.title": "Gespeicherte ZCode-Konten", + "zcodeAccounts.help": "Jedes Konto hat eine eigene offizielle Anmeldung, Modelle und Quote. Modell manuell in Codex wählen; kein Pool oder automatischer Wechsel. Die Desktop-Verbindung bleibt separat.", + "zcodeAccounts.name": "Kontoname", + "zcodeAccounts.consent": "Ich erlaube die offizielle ZCode-Anmeldung und Verwaltung dieser Konten. Nach Anmeldung werden Anbieter und Katalog aktiviert, ohne Standardwerte zu ändern oder Inferenz auszuführen.", + "zcodeAccounts.add": "Konto hinzufügen", + "zcodeAccounts.loginHint": "Offizielle Anmeldung im Browser abschließen. Für ein anderes Konto dort wechseln oder ein privates Fenster nutzen. Der Link läuft ab; dieses Fenster offen lassen.", + "zcodeAccounts.login": "Offizielle Anmeldung öffnen", + "zcodeAccounts.failed": "Aktion fehlgeschlagen. Abgelaufene Anmeldung wiederholen, bestehende Konten erneut anmelden, aktive Aufgaben abwarten und Routing-Verweise vor dem Löschen entfernen.", + "zcodeAccounts.pending": "Einrichtung unvollständig", + "zcodeAccounts.reconnect": "Erneut anmelden", + "zcodeAccounts.rename": "Umbenennen", + "zcodeAccounts.remove": "Konto entfernen", + "zcodeAccounts.removeConfirm": "Gespeichertes Konto und Anbieter entfernen? Aktive Aufgaben und Routing-Verweise müssen zuerst beendet werden.", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "ZCode Desktop verbinden, um den lokalen Anbieter zu aktivieren und seine Modelle in Codex bereitzustellen. Standardwerte bleiben unverändert; keine Inferenz.", + "zcodeDesktop.connected": "ZCode bereit · Anbieter aktiviert · Modelle veröffentlicht", + "zcodeDesktop.notConnected": "Desktop nicht verbunden", + "zcodeDesktop.open": "ZCode Desktop öffnen", + "zcodeDesktop.detect": "Erneut erkennen", + "zcodeDesktop.download": "ZCode herunterladen", + "zcodeDesktop.location": "Desktop-Installationsordner (erweitert)", + "zcodeDesktop.workspace": "Arbeitsordner", + "zcodeDesktop.browse": "Ordner durchsuchen", + "zcodeDesktop.up": "Übergeordneter Ordner", + "zcodeDesktop.select": "Auswählen", + "zcodeDesktop.consent": "Ich erlaube OpenCodex, offizielles ZCode in diesem Arbeitsbereich auszuführen, das Desktop-Profil zu lesen, den Anbieter zu aktivieren und den Codex-Katalog zu aktualisieren. Bei späteren Anfragen darf ZCode native Werkzeuge ausführen.", + "zcodeDesktop.back": "Zurück", + "zcodeDesktop.connect": "Desktop verbinden", + "zcodeDesktop.disconnect": "Trennen", + "zcodeDesktop.testHint": "Optional: Prüfen Sie das offizielle ZCode-Protokoll und das ausgewählte Modell erneut, ohne einen Modelllauf oder native Werkzeuge zu starten oder Kontingent zu verbrauchen.", + "zcodeDesktop.model": "Modell", + "zcodeDesktop.test": "Protokoll erneut prüfen", + "zcodeDesktop.testPassed": "Die ZCode-Protokollprüfung war erfolgreich.", + "zcodeDesktop.missing": "Öffnen Sie ZCode Desktop auf dem Proxy-Computer und starten Sie die Erkennung erneut, oder wählen Sie den Installationsordner.", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap kann im Sicherheitskontext des Servers keine Sandbox erstellen. Lassen Sie User-Namespaces und AppArmor prüfen und starten Sie die Erkennung erneut. Die Isolation bleibt aktiv.", + "zcodeDesktop.sandboxMissing": "Installieren Sie bubblewrap mit dem Linux-Paketmanager für die Isolation und starten Sie die Erkennung erneut.", + "zcodeDesktop.nodeMissing": "Installieren Sie eine mit ZCode kompatible Node.js-Version und starten Sie die Erkennung erneut.", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop benötigt Node.js 24 oder neuer mit SQLite-Unterstützung. Fügen Sie eine kompatible Version zum PATH von OpenCodex hinzu, starten Sie den Dienst oder das Terminal neu und wiederholen Sie die Erkennung.", + "zcodeDesktop.platformUnsupported": "Die verwaltete Desktop-Verbindung unterstützt derzeit nur Linux. Andere Plattformen benötigen den erweiterten isolierten Starter.", + "zcodeDesktop.loginNeeded": "Melden Sie sich in ZCode Desktop an und richten Sie ein Z.AI-Modell ein. Verbinden Sie danach erneut.", + "zcodeDesktop.workspaceInvalid": "Wählen Sie einen vorhandenen Projektordner, nicht den Home-, System- oder Zugangsdatenordner.", + "zcodeDesktop.protocolFailed": "Die ZCode-Protokollprüfung wurde nicht abgeschlossen. Prüfen Sie Anmeldung und Modellzugriff in Desktop und verbinden Sie erneut. Es wurden weder Modelllauf noch natives Werkzeug noch direkter API-Ersatz verwendet.", + "zcodeDesktop.failed": "Die offizielle Laufzeit konnte nicht verbunden werden. Prüfen Sie Desktop, den Ordner und die Isolationsvoraussetzungen.", +"usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", "audio.dictation": "Diktat", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c208f9cd29..cab101b21a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,7 +6,68 @@ * `{var}` are plain interpolations. */ export const en = { - "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", + "quota.remainingPercent": "{pct}% remaining", + "zcodeUsage.title": "GLM Coding Plan · time-based benefits", + "zcodeUsage.zone": "Times shown in {zone}. Schedule reference: Singapore (UTC+8).", + "zcodeUsage.peak": "Peak hours: standard model credit rate until {until}.", + "zcodeUsage.offPeak": "Off-peak: 50% off the standard model credit rate until {until}.", + "zcodeUsage.flashActive": "GLM-5.3-Flash free window active through ZCode: zero quota consumption until {end}, subject to eligibility below.", + "zcodeUsage.flashNext": "Next GLM-5.3-Flash free window through ZCode: {start} – {end}.", + "zcodeUsage.eligibility": "Requires a paid GLM Coding Plan, ZCode 3.10 or later, and remaining quota in BOTH the 5-hour and weekly limits. Balance and eligibility are not verified here. GLM-5.3 and other agents are not covered by this zero-quota benefit.", + "zcodeUsage.rules": "Peak: Mon–Fri 14:00–18:00 Singapore; all other hours are off-peak. Flash campaign: September 3–20, 2026, daily 23:00–09:00 Singapore. We conservatively stop the notice at the end of September 20; the final overnight extension is not specified. Advisories do not modify usage or cost reports.", + "zcodeUsage.source": "Official rates · verified September 11, 2026", + "zcodeUsage.campaignSource": "Official campaign conditions", + "zcodeDesktop.providerPending": "Desktop protocol verified, but provider registration is incomplete. Retry activation; existing settings are preserved.", + "zcodeDesktop.catalogPending": "Provider enabled, but the Codex catalog is not ready. Retry activation; check custom model visibility filters if models remain missing.", + "zcodeDesktop.retryActivation": "Retry activation", + "zcodeDesktop.restartNotice": "If Codex does not show the new models, restart Codex when your tasks finish. No processes will be restarted automatically.", + "zcodeDesktop.dashboardRequired": "This action requires a browser session on a loopback-bound dashboard or a paired hub. An admin token alone cannot authorize Desktop setup.", + "zcodeDesktop.hostAccess": "ZCode runs without an OpenCodex sandbox and can read or modify any file your operating-system user and harness allow. The workspace is only a working directory. This also applies to existing connections after upgrading. To enable isolation, set OCX_ZCODE_SANDBOX=1 on the proxy and restart OpenCodex.", + "zcodeDesktop.sandboxAccess": "Optional Bubblewrap isolation is enabled. Selected system and runtime paths, plus profile and credential files when present, are mounted read-only. A private home and the selected workspace at /workspace are writable, and temporary filesystems are available. Other host paths are not mounted.", + "zcodeAccounts.title": "Saved ZCode accounts", + "zcodeAccounts.help": "Each account has its own official login, models and quota. Choose its model manually in Codex; there is no pool or automatic account switch. Existing Desktop connection stays separate.", + "zcodeAccounts.name": "Account name", + "zcodeAccounts.consent": "I authorize official ZCode login and management of these saved accounts. Completing login enables that account’s provider and catalog without changing defaults or sending inference.", + "zcodeAccounts.add": "Add account", + "zcodeAccounts.loginHint": "Complete the official login in the browser. For a different account, switch accounts there or use a private window. This link expires; keep this panel open.", + "zcodeAccounts.login": "Open official login", + "zcodeAccounts.failed": "Could not complete this action. Retry login if expired; reconnect duplicates, wait for active tasks, and remove routing references before deleting an account.", + "zcodeAccounts.pending": "Account setup incomplete", + "zcodeAccounts.reconnect": "Sign in again", + "zcodeAccounts.rename": "Rename", + "zcodeAccounts.remove": "Remove account", + "zcodeAccounts.removeConfirm": "Remove this saved account and its provider? Active tasks and routing references must be cleared first.", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "Connect ZCode Desktop to enable its local provider and publish its models to Codex. Defaults stay unchanged; no inference is sent.", + "zcodeDesktop.connected": "ZCode ready · provider enabled · models published", + "zcodeDesktop.notConnected": "Desktop not connected", + "zcodeDesktop.open": "Open ZCode Desktop", + "zcodeDesktop.detect": "Detect again", + "zcodeDesktop.download": "Download ZCode", + "zcodeDesktop.location": "Desktop installation folder (advanced)", + "zcodeDesktop.workspace": "Workspace folder", + "zcodeDesktop.browse": "Browse folders", + "zcodeDesktop.up": "Parent folder", + "zcodeDesktop.select": "Select", + "zcodeDesktop.consent": "I allow OpenCodex to run official ZCode in this workspace, read the Desktop profile, enable the ZCode provider and update the Codex catalog. ZCode may execute native tools when I later send a request.", + "zcodeDesktop.back": "Back", + "zcodeDesktop.connect": "Connect Desktop", + "zcodeDesktop.disconnect": "Disconnect", + "zcodeDesktop.testHint": "Optional: recheck the official ZCode protocol and selected model without starting a model turn, running native tools, or consuming quota.", + "zcodeDesktop.model": "Model", + "zcodeDesktop.test": "Verify protocol again", + "zcodeDesktop.testPassed": "ZCode protocol verification passed.", + "zcodeDesktop.missing": "Open ZCode Desktop on the proxy computer, then detect again. You can also select its installed or extracted application folder.", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap cannot create the required sandbox in the server’s security context. Ask your administrator to check user namespace and AppArmor policy, then detect again. Isolation will not be disabled.", + "zcodeDesktop.sandboxMissing": "Bubblewrap is required for isolation. Install bubblewrap using your Linux package manager, then detect again.", + "zcodeDesktop.nodeMissing": "Node.js is required by the ZCode runtime. Install a compatible Node.js version, then detect again.", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop requires Node.js 24 or newer with SQLite support. Add a compatible Node.js to the PATH used by OpenCodex, restart its service or terminal, then detect again.", + "zcodeDesktop.platformUnsupported": "Managed Desktop connection currently supports Linux only. Other platforms can use the advanced isolated-launcher setup.", + "zcodeDesktop.loginNeeded": "Sign in and configure a Z.AI model in ZCode Desktop, then detect and connect again.", + "zcodeDesktop.workspaceInvalid": "Choose an existing project folder, not your home, a system folder or a credentials/configuration directory.", + "zcodeDesktop.protocolFailed": "ZCode protocol verification did not complete. Check login and model access in Desktop, then reconnect. No model turn, native tool, or direct API fallback was used.", + "zcodeDesktop.failed": "Could not connect to the official runtime. Check Desktop, the selected folder and the isolation prerequisites, then retry.", +"usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.", "api.attribution.noRecordedUse": "No use in readable records", "audio.dictation": "Dictation", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index f6f30b11ba..7f4d5d7c98 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,7 +4,68 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { - "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", + "quota.remainingPercent": "{pct}% restant", + "zcodeUsage.title": "GLM Coding Plan · avantages horaires", + "zcodeUsage.zone": "Heures affichées dans {zone}. Référence : Singapour (UTC+8).", + "zcodeUsage.peak": "Heures de pointe : tarif standard en crédits jusqu’à {until}.", + "zcodeUsage.offPeak": "Heures creuses : réduction de 50 % du tarif standard en crédits jusqu’à {until}.", + "zcodeUsage.flashActive": "Créneau gratuit GLM-5.3-Flash via ZCode actif : aucun quota consommé jusqu’à {end}, sous réserve des conditions ci-dessous.", + "zcodeUsage.flashNext": "Prochain créneau gratuit GLM-5.3-Flash via ZCode : {start} – {end}.", + "zcodeUsage.eligibility": "Nécessite un GLM Coding Plan payant, ZCode 3.10 ou ultérieur et du quota restant dans LES DEUX limites (5 heures et semaine). Solde et éligibilité non vérifiés ici. GLM-5.3 et les autres agents sont exclus de cet avantage sans consommation.", + "zcodeUsage.rules": "Pointe : lun–ven 14 h–18 h à Singapour ; heures creuses le reste du temps. Promotion Flash : du 3 au 20 septembre 2026, chaque jour de 23 h à 9 h à Singapour. Par prudence, l’avis cesse à la fin du 20 septembre ; la prolongation de la dernière nuit n’est pas précisée. Aucun rapport de consommation ou de coût n’est modifié.", + "zcodeUsage.source": "Tarifs officiels · vérifiés le 11 septembre 2026", + "zcodeUsage.campaignSource": "Conditions officielles de la promotion", + "zcodeDesktop.providerPending": "Protocole Desktop vérifié, mais inscription du fournisseur incomplète. Réessayez l’activation ; les réglages sont préservés.", + "zcodeDesktop.catalogPending": "Fournisseur activé, mais catalogue Codex non prêt. Réessayez et vérifiez vos filtres de visibilité si des modèles manquent.", + "zcodeDesktop.retryActivation": "Réessayer l’activation", + "zcodeDesktop.restartNotice": "Si Codex n’affiche pas les modèles, redémarrez-le après vos tâches. Aucun processus ne sera redémarré automatiquement.", + "zcodeDesktop.dashboardRequired": "Cette action nécessite une session navigateur sur un tableau de bord lié à loopback ou un hub appairé. Le jeton administrateur seul ne permet pas la configuration Desktop.", + "zcodeDesktop.hostAccess": "ZCode fonctionne sans bac à sable OpenCodex et peut lire ou modifier tout fichier autorisé par votre utilisateur système et le harness. Le dossier de travail ne limite pas cet accès. Cela concerne aussi les connexions existantes après mise à jour. Pour activer l’isolation, définissez OCX_ZCODE_SANDBOX=1 sur le proxy et redémarrez OpenCodex.", + "zcodeDesktop.sandboxAccess": "L’isolation Bubblewrap optionnelle est active. Certains chemins système et d’exécution, ainsi que les fichiers de profil et d’identifiants présents, sont montés en lecture seule. Un dossier personnel privé et l’espace de travail choisi dans /workspace sont inscriptibles, et des systèmes de fichiers temporaires sont disponibles. Les autres chemins de l’hôte ne sont pas montés.", + "zcodeAccounts.title": "Comptes ZCode enregistrés", + "zcodeAccounts.help": "Chaque compte possède sa connexion officielle, ses modèles et son quota. Sélection manuelle dans Codex, sans pool ni basculement automatique. La connexion Desktop reste séparée.", + "zcodeAccounts.name": "Nom du compte", + "zcodeAccounts.consent": "J’autorise la connexion officielle ZCode et la gestion de ces comptes. La connexion active le fournisseur et le catalogue sans modifier les valeurs par défaut ni envoyer d’inférence.", + "zcodeAccounts.add": "Ajouter un compte", + "zcodeAccounts.loginHint": "Terminez la connexion officielle dans le navigateur. Changez de compte ou utilisez une fenêtre privée pour un autre compte. Le lien expire ; gardez ce panneau ouvert.", + "zcodeAccounts.login": "Ouvrir la connexion officielle", + "zcodeAccounts.failed": "Action impossible. Recommencez une connexion expirée, reconnectez les doublons, attendez les tâches actives et retirez les références de routage avant suppression.", + "zcodeAccounts.pending": "Configuration incomplète", + "zcodeAccounts.reconnect": "Se reconnecter", + "zcodeAccounts.rename": "Renommer", + "zcodeAccounts.remove": "Supprimer le compte", + "zcodeAccounts.removeConfirm": "Supprimer ce compte et son fournisseur ? Terminez les tâches et retirez d’abord les références de routage.", + "zcodeDesktop.title": "Application de bureau ZCode", + "zcodeDesktop.intro": "Connectez ZCode Desktop pour activer son fournisseur local et publier ses modèles dans Codex. Les choix par défaut restent inchangés ; aucune inférence.", + "zcodeDesktop.connected": "ZCode prêt · fournisseur activé · modèles publiés", + "zcodeDesktop.notConnected": "Desktop non connecté", + "zcodeDesktop.open": "Ouvrir ZCode Desktop", + "zcodeDesktop.detect": "Détecter à nouveau", + "zcodeDesktop.download": "Télécharger ZCode", + "zcodeDesktop.location": "Dossier d’installation de Desktop (avancé)", + "zcodeDesktop.workspace": "Dossier de travail", + "zcodeDesktop.browse": "Parcourir les dossiers", + "zcodeDesktop.up": "Dossier parent", + "zcodeDesktop.select": "Sélectionner", + "zcodeDesktop.consent": "J’autorise OpenCodex à exécuter ZCode officiel dans cet espace, lire le profil Desktop, activer le fournisseur et actualiser le catalogue Codex. ZCode pourra exécuter des outils natifs lors de mes futures requêtes.", + "zcodeDesktop.back": "Retour", + "zcodeDesktop.connect": "Connecter Desktop", + "zcodeDesktop.disconnect": "Déconnecter", + "zcodeDesktop.testHint": "Facultatif : revérifiez le protocole officiel ZCode et le modèle sélectionné sans lancer de tour de modèle, d’outil natif ni consommer de quota.", + "zcodeDesktop.model": "Modèle", + "zcodeDesktop.test": "Revérifier le protocole", + "zcodeDesktop.testPassed": "La vérification du protocole ZCode a réussi.", + "zcodeDesktop.missing": "Ouvrez ZCode Desktop sur l’ordinateur du proxy, puis relancez la détection, ou indiquez son dossier installé ou extrait.", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap ne peut pas créer le bac à sable dans le contexte de sécurité du serveur. Faites vérifier les espaces de noms utilisateur et AppArmor, puis relancez la détection. L’isolation ne sera pas désactivée.", + "zcodeDesktop.sandboxMissing": "Installez bubblewrap avec le gestionnaire de paquets Linux pour activer l’isolation, puis relancez la détection.", + "zcodeDesktop.nodeMissing": "Installez une version de Node.js compatible avec ZCode, puis relancez la détection.", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop nécessite Node.js 24 ou ultérieur avec SQLite. Ajoutez une version compatible au PATH utilisé par OpenCodex, redémarrez le service ou le terminal, puis relancez la détection.", + "zcodeDesktop.platformUnsupported": "La connexion Desktop gérée prend actuellement en charge Linux uniquement. Les autres plateformes nécessitent le lanceur isolé avancé.", + "zcodeDesktop.loginNeeded": "Connectez-vous et configurez un modèle Z.AI dans ZCode Desktop, puis reconnectez le fournisseur.", + "zcodeDesktop.workspaceInvalid": "Choisissez un dossier de projet existant, pas le dossier personnel, système ou des identifiants.", + "zcodeDesktop.protocolFailed": "La vérification du protocole ZCode n’a pas abouti. Vérifiez la connexion et l’accès au modèle dans Desktop, puis reconnectez. Aucun tour de modèle, outil natif ou repli vers une API directe n’a été utilisé.", + "zcodeDesktop.failed": "Connexion au runtime officiel impossible. Vérifiez Desktop, le dossier et les prérequis d’isolation.", +"usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", "audio.dictation": "Dictée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2014a28342..e5a2587dce 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,7 +4,68 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { - "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", + "quota.remainingPercent": "残り {pct}%", + "zcodeUsage.title": "GLM Coding Plan · 時間帯別特典", + "zcodeUsage.zone": "表示時刻:{zone}。基準:シンガポール(UTC+8)。", + "zcodeUsage.peak": "ピーク時間:{until}まで通常のモデルクレジット料金。", + "zcodeUsage.offPeak": "オフピーク:{until}まで通常のモデルクレジット料金が50%割引。", + "zcodeUsage.flashActive": "ZCode経由のGLM-5.3-Flash無料時間帯が有効:以下の条件を満たす場合、{end}まで枠の消費なし。", + "zcodeUsage.flashNext": "次のZCode経由GLM-5.3-Flash無料時間帯:{start} – {end}。", + "zcodeUsage.eligibility": "有料GLM Coding Plan、ZCode 3.10以降、5時間枠と週間枠の両方に残量が必要です。残量・適用資格はここでは確認していません。GLM-5.3と他のエージェントは消費ゼロ特典の対象外です。", + "zcodeUsage.rules": "ピーク:シンガポール時間の月~金14~18時。それ以外はオフピーク。Flashキャンペーン:2026年9月3~20日、毎日23~翌9時(シンガポール)。最終夜の延長は明記されていないため、安全側で9月20日末に表示を終了します。使用量・料金レポートは変更しません。", + "zcodeUsage.source": "公式料金 · 2026年9月11日確認", + "zcodeUsage.campaignSource": "公式キャンペーン条件", + "zcodeDesktop.providerPending": "Desktop プロトコルは確認済みですが、プロバイダー登録は未完了です。有効化を再試行してください。設定は保持されます。", + "zcodeDesktop.catalogPending": "プロバイダーは有効ですが、Codex カタログは未完了です。再試行し、モデルが不足する場合は表示フィルターを確認してください。", + "zcodeDesktop.retryActivation": "有効化を再試行", + "zcodeDesktop.restartNotice": "Codex にモデルが表示されない場合は、作業終了後に Codex を再起動してください。自動再起動は行いません。", + "zcodeDesktop.dashboardRequired": "この操作にはループバックにバインドされたダッシュボードまたはペアリング済みハブのブラウザーセッションが必要です。管理トークンだけでは Desktop を設定できません。", + "zcodeDesktop.hostAccess": "ZCode は OpenCodex のサンドボックスなしで実行され、OS ユーザーとハーネスが許可するファイルを読み書きできます。作業ディレクトリはアクセス制限ではありません。更新後の既存接続にも適用されます。隔離するにはプロキシで OCX_ZCODE_SANDBOX=1 を設定し、OpenCodex を再起動してください。", + "zcodeDesktop.sandboxAccess": "任意の Bubblewrap 隔離が有効です。選択されたシステムおよびランタイムのパスと、存在するプロファイル・認証情報ファイルは読み取り専用でマウントされます。プライベートホームと /workspace の選択した作業ディレクトリは書き込み可能で、一時ファイルシステムも利用できます。その他のホストパスはマウントされません。", + "zcodeAccounts.title": "保存済み ZCode アカウント", + "zcodeAccounts.help": "各アカウントは公式ログイン、モデル、利用枠を個別に持ちます。Codex でモデルを手動選択します。プールや自動切替はありません。Desktop 接続は別です。", + "zcodeAccounts.name": "アカウント名", + "zcodeAccounts.consent": "公式 ZCode ログインとアカウント管理を許可します。ログイン完了でプロバイダーとカタログを有効にします。既定値の変更や推論は行いません。", + "zcodeAccounts.add": "アカウントを追加", + "zcodeAccounts.loginHint": "ブラウザーで公式ログインを完了してください。別のアカウントには切替またはプライベートウィンドウを使用します。リンクは期限付きです。このパネルを開いたままにしてください。", + "zcodeAccounts.login": "公式ログインを開く", + "zcodeAccounts.failed": "操作を完了できません。期限切れは再ログイン、重複は再接続してください。実行中の作業を待ち、削除前にルーティング参照を除去してください。", + "zcodeAccounts.pending": "設定未完了", + "zcodeAccounts.reconnect": "再ログイン", + "zcodeAccounts.rename": "名前を変更", + "zcodeAccounts.remove": "アカウントを削除", + "zcodeAccounts.removeConfirm": "このアカウントとプロバイダーを削除しますか?先に作業を終了し、ルーティング参照を除去してください。", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "ZCode Desktop を接続するとローカルプロバイダーが有効になり、Codex にモデルを公開します。既定値は変更せず、推論も送信しません。", + "zcodeDesktop.connected": "ZCode 準備完了 · プロバイダー有効 · モデル公開済み", + "zcodeDesktop.notConnected": "Desktop 未接続", + "zcodeDesktop.open": "ZCode Desktop を開く", + "zcodeDesktop.detect": "再検出", + "zcodeDesktop.download": "ZCode をダウンロード", + "zcodeDesktop.location": "Desktop インストール先(詳細)", + "zcodeDesktop.workspace": "作業フォルダー", + "zcodeDesktop.browse": "フォルダーを参照", + "zcodeDesktop.up": "親フォルダー", + "zcodeDesktop.select": "選択", + "zcodeDesktop.consent": "このワークスペースで公式 ZCode を実行し、Desktop プロファイルを読み、プロバイダーを有効化して Codex カタログを更新することを許可します。後でリクエストを送ると ZCode がネイティブツールを実行する場合があります。", + "zcodeDesktop.back": "戻る", + "zcodeDesktop.connect": "Desktop に接続", + "zcodeDesktop.disconnect": "切断", + "zcodeDesktop.testHint": "任意:モデルターンやネイティブツールを開始せず、クォータも消費せずに、公式 ZCode プロトコルと選択したモデルを再確認します。", + "zcodeDesktop.model": "モデル", + "zcodeDesktop.test": "プロトコルを再確認", + "zcodeDesktop.testPassed": "ZCode プロトコルの確認に成功しました。", + "zcodeDesktop.missing": "プロキシのコンピューターで ZCode Desktop を開いて再検出するか、インストールまたは展開先を選択してください。", + "zcodeDesktop.sandboxUnavailable": "サーバーのセキュリティコンテキストで Bubblewrap の隔離環境を作成できません。管理者にユーザー名前空間と AppArmor の設定を確認してもらい、再検出してください。隔離は無効化されません。", + "zcodeDesktop.sandboxMissing": "隔離には Bubblewrap が必要です。Linux のパッケージマネージャーでインストールして再検出してください。", + "zcodeDesktop.nodeMissing": "ZCode 対応の Node.js をインストールして再検出してください。", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop には SQLite 対応の Node.js 24 以降が必要です。OpenCodex が使う PATH に対応版を追加し、サービスまたはターミナルを再起動して再検出してください。", + "zcodeDesktop.platformUnsupported": "管理された Desktop 接続は現在 Linux のみ対応です。他の環境では詳細な隔離ランチャー設定を使用してください。", + "zcodeDesktop.loginNeeded": "ZCode Desktop にサインインして Z.AI モデルを設定し、再接続してください。", + "zcodeDesktop.workspaceInvalid": "ホーム、システム、認証情報のフォルダーではなく、既存のプロジェクトフォルダーを選択してください。", + "zcodeDesktop.protocolFailed": "ZCode プロトコルの確認が完了しませんでした。Desktop でログインとモデルの権限を確認して再接続してください。モデルターン、ネイティブツール、直接 API へのフォールバックは使用されていません。", + "zcodeDesktop.failed": "公式ランタイムに接続できません。Desktop、フォルダー、隔離の前提条件を確認してください。", +"usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。", "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし", "audio.dictation": "音声入力", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8cd711d927..31fa1a2d18 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,7 +4,68 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { - "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", + "quota.remainingPercent": "{pct}% 남음", + "zcodeUsage.title": "GLM Coding Plan · 시간대별 혜택", + "zcodeUsage.zone": "{zone} 시간으로 표시합니다. 기준: 싱가포르(UTC+8).", + "zcodeUsage.peak": "피크 시간: {until}까지 표준 모델 크레딧 요금입니다.", + "zcodeUsage.offPeak": "오프피크: {until}까지 표준 모델 크레딧 요금 50% 할인.", + "zcodeUsage.flashActive": "ZCode 경유 GLM-5.3-Flash 무료 시간 활성: 아래 조건 충족 시 {end}까지 할당량 차감 없음.", + "zcodeUsage.flashNext": "다음 ZCode 경유 GLM-5.3-Flash 무료 시간: {start} – {end}.", + "zcodeUsage.eligibility": "유료 GLM Coding Plan, ZCode 3.10 이상, 5시간 및 주간 한도 모두에 잔여 할당량이 필요합니다. 잔액과 자격은 여기서 확인하지 않습니다. GLM-5.3 및 다른 에이전트에는 이 무차감 혜택이 적용되지 않습니다.", + "zcodeUsage.rules": "피크: 싱가포르 월–금 14–18시, 나머지는 오프피크. Flash 행사: 2026년 9월 3–20일, 매일 싱가포르 23–다음 날 9시. 마지막 밤 연장이 명시되지 않아 9월 20일 종료 시 안내를 보수적으로 중단합니다. 사용량 및 비용 보고서는 변경하지 않습니다.", + "zcodeUsage.source": "공식 요금 · 2026년 9월 11일 확인", + "zcodeUsage.campaignSource": "공식 행사 조건", + "zcodeDesktop.providerPending": "Desktop 프로토콜은 확인했지만 공급자 등록이 미완료입니다. 활성화를 재시도하세요. 기존 설정은 유지됩니다.", + "zcodeDesktop.catalogPending": "공급자는 활성화되었지만 Codex 카탈로그는 미완료입니다. 재시도하고 모델이 없으면 사용자 표시 필터를 확인하세요.", + "zcodeDesktop.retryActivation": "활성화 재시도", + "zcodeDesktop.restartNotice": "Codex에 모델이 표시되지 않으면 작업 완료 후 Codex를 다시 시작하세요. 프로세스를 자동으로 다시 시작하지 않습니다.", + "zcodeDesktop.dashboardRequired": "이 작업에는 루프백에 바인딩된 대시보드 또는 페어링된 허브의 브라우저 세션이 필요합니다. 관리자 토큰만으로는 Desktop을 설정할 수 없습니다.", + "zcodeDesktop.hostAccess": "ZCode는 OpenCodex 샌드박스 없이 실행되며 OS 사용자와 하네스가 허용하는 파일을 읽거나 수정할 수 있습니다. 작업 폴더는 접근 경계가 아닙니다. 업데이트 후 기존 연결에도 적용됩니다. 격리하려면 프록시에 OCX_ZCODE_SANDBOX=1을 설정하고 OpenCodex를 재시작하세요.", + "zcodeDesktop.sandboxAccess": "선택적 Bubblewrap 격리가 활성화되었습니다. 선택한 시스템 및 런타임 경로와 존재하는 프로필·자격 증명 파일은 읽기 전용으로 마운트됩니다. 비공개 홈과 /workspace의 선택한 작업 폴더는 쓰기 가능하며 임시 파일 시스템도 사용할 수 있습니다. 그 밖의 호스트 경로는 마운트되지 않습니다.", + "zcodeAccounts.title": "저장된 ZCode 계정", + "zcodeAccounts.help": "계정마다 공식 로그인, 모델, 할당량이 분리됩니다. Codex에서 모델을 직접 선택하세요. 풀이나 자동 계정 전환은 없습니다. Desktop 연결은 별도입니다.", + "zcodeAccounts.name": "계정 이름", + "zcodeAccounts.consent": "공식 ZCode 로그인과 계정 관리를 허용합니다. 로그인 완료 시 기본값 변경이나 추론 없이 공급자와 카탈로그를 활성화합니다.", + "zcodeAccounts.add": "계정 추가", + "zcodeAccounts.loginHint": "브라우저에서 공식 로그인을 완료하세요. 다른 계정은 계정을 전환하거나 비공개 창을 사용하세요. 링크는 만료됩니다. 이 패널을 열어 두세요.", + "zcodeAccounts.login": "공식 로그인 열기", + "zcodeAccounts.failed": "작업을 완료할 수 없습니다. 만료 시 재로그인하고 중복 계정은 재연결하세요. 실행 중인 작업을 기다리고 삭제 전 라우팅 참조를 제거하세요.", + "zcodeAccounts.pending": "계정 설정 미완료", + "zcodeAccounts.reconnect": "다시 로그인", + "zcodeAccounts.rename": "이름 변경", + "zcodeAccounts.remove": "계정 제거", + "zcodeAccounts.removeConfirm": "이 계정과 공급자를 제거할까요? 먼저 작업을 끝내고 라우팅 참조를 제거하세요.", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "ZCode Desktop을 연결하면 로컬 공급자를 활성화하고 Codex에 모델을 게시합니다. 기본값은 유지되며 추론은 실행하지 않습니다.", + "zcodeDesktop.connected": "ZCode 준비 완료 · 공급자 활성화 · 모델 게시 완료", + "zcodeDesktop.notConnected": "Desktop 연결 안 됨", + "zcodeDesktop.open": "ZCode Desktop 열기", + "zcodeDesktop.detect": "다시 감지", + "zcodeDesktop.download": "ZCode 다운로드", + "zcodeDesktop.location": "Desktop 설치 폴더(고급)", + "zcodeDesktop.workspace": "작업 폴더", + "zcodeDesktop.browse": "폴더 찾아보기", + "zcodeDesktop.up": "상위 폴더", + "zcodeDesktop.select": "선택", + "zcodeDesktop.consent": "이 작업 공간에서 공식 ZCode 실행, Desktop 프로필 읽기, 공급자 활성화 및 Codex 카탈로그 업데이트를 허용합니다. 나중에 요청을 보내면 ZCode가 네이티브 도구를 실행할 수 있습니다.", + "zcodeDesktop.back": "뒤로", + "zcodeDesktop.connect": "Desktop 연결", + "zcodeDesktop.disconnect": "연결 해제", + "zcodeDesktop.testHint": "선택 사항: 모델 턴이나 네이티브 도구를 시작하거나 할당량을 사용하지 않고 공식 ZCode 프로토콜과 선택한 모델을 다시 확인합니다.", + "zcodeDesktop.model": "모델", + "zcodeDesktop.test": "프로토콜 다시 확인", + "zcodeDesktop.testPassed": "ZCode 프로토콜 확인에 성공했습니다.", + "zcodeDesktop.missing": "프록시 컴퓨터에서 ZCode Desktop을 열고 다시 감지하거나 설치 또는 압축 해제 폴더를 선택하세요.", + "zcodeDesktop.sandboxUnavailable": "서버 보안 컨텍스트에서 Bubblewrap 격리 환경을 만들 수 없습니다. 관리자에게 사용자 네임스페이스와 AppArmor 정책 확인을 요청한 뒤 다시 감지하세요. 격리는 비활성화되지 않습니다.", + "zcodeDesktop.sandboxMissing": "격리를 위해 Linux 패키지 관리자로 bubblewrap을 설치한 뒤 다시 감지하세요.", + "zcodeDesktop.nodeMissing": "ZCode와 호환되는 Node.js를 설치한 뒤 다시 감지하세요.", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop에는 SQLite를 지원하는 Node.js 24 이상이 필요합니다. OpenCodex의 PATH에 호환 버전을 추가하고 서비스 또는 터미널을 다시 시작한 후 다시 감지하세요.", + "zcodeDesktop.platformUnsupported": "관리형 Desktop 연결은 현재 Linux만 지원합니다. 다른 플랫폼에서는 고급 격리 실행기 설정을 사용하세요.", + "zcodeDesktop.loginNeeded": "ZCode Desktop에서 로그인하고 Z.AI 모델을 설정한 뒤 다시 연결하세요.", + "zcodeDesktop.workspaceInvalid": "홈, 시스템 또는 인증 정보 폴더가 아닌 기존 프로젝트 폴더를 선택하세요.", + "zcodeDesktop.protocolFailed": "ZCode 프로토콜 확인이 완료되지 않았습니다. Desktop 로그인과 모델 권한을 확인하고 다시 연결하세요. 모델 턴, 네이티브 도구 또는 직접 API 대체는 사용되지 않았습니다.", + "zcodeDesktop.failed": "공식 런타임에 연결할 수 없습니다. Desktop, 폴더 및 격리 요구 사항을 확인하세요.", +"usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.", "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음", "audio.dictation": "받아쓰기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0c6034ae6e..7cee7bccc1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,7 +4,68 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { - "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", + "quota.remainingPercent": "Осталось {pct}%", + "zcodeUsage.title": "GLM Coding Plan · льготы по времени", + "zcodeUsage.zone": "Время в {zone}. Основа расписания: Сингапур (UTC+8).", + "zcodeUsage.peak": "Пиковые часы: стандартная ставка кредитов модели до {until}.", + "zcodeUsage.offPeak": "Вне пика: скидка 50% от стандартной ставки кредитов модели до {until}.", + "zcodeUsage.flashActive": "Бесплатное окно GLM-5.3-Flash через ZCode активно: квота не расходуется до {end} при выполнении условий ниже.", + "zcodeUsage.flashNext": "Следующее бесплатное окно GLM-5.3-Flash через ZCode: {start} – {end}.", + "zcodeUsage.eligibility": "Нужны платный GLM Coding Plan, ZCode 3.10 или новее и остаток ОБОИХ лимитов: 5-часового и недельного. Баланс и право на акцию здесь не проверяются. GLM-5.3 и другие агенты не получают эту льготу без расхода квоты.", + "zcodeUsage.rules": "Пик: пн–пт 14–18 по Сингапуру; остальное время — вне пика. Акция Flash: 3–20 сентября 2026, ежедневно 23–9 по Сингапуру. Уведомление консервативно прекращается в конце 20 сентября: продление последней ночи не уточнено. Отчёты о расходе и стоимости не меняются.", + "zcodeUsage.source": "Официальные тарифы · проверено 11 сентября 2026", + "zcodeUsage.campaignSource": "Официальные условия акции", + "zcodeDesktop.providerPending": "Протокол Desktop проверен, но регистрация провайдера не завершена. Повторите активацию; настройки сохраняются.", + "zcodeDesktop.catalogPending": "Провайдер включён, но каталог Codex не готов. Повторите активацию и проверьте фильтры видимости отсутствующих моделей.", + "zcodeDesktop.retryActivation": "Повторить активацию", + "zcodeDesktop.restartNotice": "Если модели не появились, перезапустите Codex после завершения задач. Автоматического перезапуска процессов не будет.", + "zcodeDesktop.dashboardRequired": "Требуется сеанс браузера на панели, привязанной к loopback, или сопряжённом хабе. Одного токена администратора недостаточно для настройки Desktop.", + "zcodeDesktop.hostAccess": "ZCode работает без песочницы OpenCodex и может читать или менять любые файлы, разрешённые системному пользователю и средой запуска. Рабочий каталог не ограничивает доступ. Это относится и к существующим подключениям после обновления. Для изоляции задайте OCX_ZCODE_SANDBOX=1 у прокси и перезапустите OpenCodex.", + "zcodeDesktop.sandboxAccess": "Дополнительная изоляция Bubblewrap включена. Выбранные системные пути и runtime, а также имеющиеся файлы профиля и учётных данных монтируются только для чтения. Приватный домашний каталог и выбранный рабочий каталог в /workspace доступны для записи; также доступны временные файловые системы. Остальные пути хоста не монтируются.", + "zcodeAccounts.title": "Сохранённые аккаунты ZCode", + "zcodeAccounts.help": "У каждого аккаунта свой официальный вход, модели и квота. Выбирайте модель вручную в Codex: без пула и автопереключения. Подключение Desktop остаётся отдельным.", + "zcodeAccounts.name": "Название аккаунта", + "zcodeAccounts.consent": "Разрешаю официальный вход ZCode и управление аккаунтами. Вход включает провайдера и каталог без изменения настроек по умолчанию и без инференса.", + "zcodeAccounts.add": "Добавить аккаунт", + "zcodeAccounts.loginHint": "Завершите официальный вход в браузере. Для другого аккаунта переключитесь там или используйте приватное окно. Ссылка истекает; оставьте панель открытой.", + "zcodeAccounts.login": "Открыть официальный вход", + "zcodeAccounts.failed": "Не удалось выполнить действие. Повторите истёкший вход, переподключите дубликат, дождитесь завершения задач и удалите ссылки маршрутизации перед удалением аккаунта.", + "zcodeAccounts.pending": "Настройка не завершена", + "zcodeAccounts.reconnect": "Войти повторно", + "zcodeAccounts.rename": "Переименовать", + "zcodeAccounts.remove": "Удалить аккаунт", + "zcodeAccounts.removeConfirm": "Удалить этот аккаунт и провайдера? Сначала завершите задачи и удалите ссылки маршрутизации.", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "Подключение ZCode Desktop включает локального провайдера и публикует модели в Codex. Значения по умолчанию сохраняются; генерация не запускается.", + "zcodeDesktop.connected": "ZCode готов · провайдер включён · модели опубликованы", + "zcodeDesktop.notConnected": "Desktop не подключён", + "zcodeDesktop.open": "Открыть ZCode Desktop", + "zcodeDesktop.detect": "Найти снова", + "zcodeDesktop.download": "Скачать ZCode", + "zcodeDesktop.location": "Папка установки Desktop (дополнительно)", + "zcodeDesktop.workspace": "Рабочая папка", + "zcodeDesktop.browse": "Обзор папок", + "zcodeDesktop.up": "Родительская папка", + "zcodeDesktop.select": "Выбрать", + "zcodeDesktop.consent": "Разрешаю OpenCodex запускать официальный ZCode в этой папке, читать профиль Desktop, включать провайдера и обновлять каталог Codex. При последующих запросах ZCode может выполнять встроенные инструменты.", + "zcodeDesktop.back": "Назад", + "zcodeDesktop.connect": "Подключить Desktop", + "zcodeDesktop.disconnect": "Отключить", + "zcodeDesktop.testHint": "Необязательно: повторно проверьте официальный протокол ZCode и выбранную модель без запуска модельного запроса, нативных инструментов и расхода квоты.", + "zcodeDesktop.model": "Модель", + "zcodeDesktop.test": "Повторно проверить протокол", + "zcodeDesktop.testPassed": "Проверка протокола ZCode прошла успешно.", + "zcodeDesktop.missing": "Откройте ZCode Desktop на компьютере прокси и повторите поиск либо выберите папку установки или распаковки.", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap не может создать изоляцию в контексте безопасности сервера. Попросите администратора проверить пользовательские пространства имён и AppArmor, затем повторите поиск. Изоляция не будет отключена.", + "zcodeDesktop.sandboxMissing": "Для изоляции установите bubblewrap через менеджер пакетов Linux и повторите поиск.", + "zcodeDesktop.nodeMissing": "Установите совместимую с ZCode версию Node.js и повторите поиск.", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop требует Node.js 24 или новее с SQLite. Добавьте совместимую версию в PATH OpenCodex, перезапустите службу или терминал и повторите обнаружение.", + "zcodeDesktop.platformUnsupported": "Управляемое подключение Desktop пока поддерживает только Linux. Для других платформ используйте расширенную настройку изолированного запуска.", + "zcodeDesktop.loginNeeded": "Войдите и настройте модель Z.AI в ZCode Desktop, затем подключитесь снова.", + "zcodeDesktop.workspaceInvalid": "Выберите существующую папку проекта, а не домашнюю, системную или папку с учётными данными.", + "zcodeDesktop.protocolFailed": "Проверка протокола ZCode не завершилась. Проверьте вход и доступ к модели в Desktop и подключитесь снова. Модельный запрос, нативные инструменты и прямой резервный API не использовались.", + "zcodeDesktop.failed": "Не удалось подключить официальный runtime. Проверьте Desktop, папку и требования изоляции.", +"usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.", "api.attribution.noRecordedUse": "В читаемых записях использование не найдено", "audio.dictation": "Диктовка", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5e624434ac..ee24f5251d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,7 +5,68 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { - "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", + "quota.remainingPercent": "%{pct} kaldı", + "zcodeUsage.title": "GLM Coding Plan · saat bazlı avantajlar", + "zcodeUsage.zone": "Saatler {zone} diliminde gösterilir. Referans: Singapur (UTC+8).", + "zcodeUsage.peak": "Yoğun saatler: {until} tarihine kadar standart model kredi tarifesi.", + "zcodeUsage.offPeak": "Yoğun olmayan saatler: {until} tarihine kadar standart model kredi tarifesinde %50 indirim.", + "zcodeUsage.flashActive": "ZCode üzerinden GLM-5.3-Flash ücretsiz zaman aralığı etkin: aşağıdaki koşullarla {end} tarihine kadar kota tüketimi yok.", + "zcodeUsage.flashNext": "ZCode üzerinden sonraki GLM-5.3-Flash ücretsiz aralığı: {start} – {end}.", + "zcodeUsage.eligibility": "Ücretli GLM Coding Plan, ZCode 3.10 veya üstü ve HEM 5 saatlik HEM haftalık limitte kalan kota gerektirir. Bakiye ve uygunluk burada doğrulanmaz. GLM-5.3 ve diğer ajanlar bu sıfır kota avantajına dahil değildir.", + "zcodeUsage.rules": "Yoğun: Singapur saatine göre Pzt–Cum 14–18; diğer tüm saatler yoğun değildir. Flash kampanyası: 3–20 Eylül 2026, her gün Singapur 23–ertesi gün 9. Son gece uzatması belirtilmediğinden bildirim tedbiren 20 Eylül sonunda durur. Kullanım veya maliyet raporlarını değiştirmez.", + "zcodeUsage.source": "Resmî tarifeler · 11 Eylül 2026 tarihinde doğrulandı", + "zcodeUsage.campaignSource": "Resmî kampanya koşulları", + "zcodeDesktop.providerPending": "Desktop protokolü doğrulandı, sağlayıcı kaydı tamamlanmadı. Etkinleştirmeyi yeniden deneyin; ayarlar korunur.", + "zcodeDesktop.catalogPending": "Sağlayıcı etkin, ancak Codex kataloğu hazır değil. Yeniden deneyin; eksik modeller için özel görünürlük filtrelerini kontrol edin.", + "zcodeDesktop.retryActivation": "Etkinleştirmeyi yeniden dene", + "zcodeDesktop.restartNotice": "Modeller görünmüyorsa görevler bitince Codex’i yeniden başlatın. Hiçbir işlem otomatik yeniden başlatılmaz.", + "zcodeDesktop.dashboardRequired": "Bu işlem, loopback’e bağlı panelde veya eşleştirilmiş hub’da tarayıcı oturumu gerektirir. Yalnızca yönetici jetonu Desktop kurulumuna yetki vermez.", + "zcodeDesktop.hostAccess": "ZCode, OpenCodex sanal alanı olmadan çalışır; işletim sistemi kullanıcınızın ve harness’in izin verdiği dosyaları okuyabilir veya değiştirebilir. Çalışma klasörü bir erişim sınırı değildir. Güncellemeden sonra mevcut bağlantılar için de geçerlidir. Yalıtım için proxy üzerinde OCX_ZCODE_SANDBOX=1 ayarlayıp OpenCodex’i yeniden başlatın.", + "zcodeDesktop.sandboxAccess": "İsteğe bağlı Bubblewrap yalıtımı etkin. Seçili sistem ve çalışma zamanı yolları ile mevcut profil ve kimlik bilgisi dosyaları salt okunur bağlanır. Özel bir ev dizini ve /workspace altındaki seçili çalışma klasörü yazılabilir; geçici dosya sistemleri de kullanılabilir. Diğer ana makine yolları bağlanmaz.", + "zcodeAccounts.title": "Kayıtlı ZCode hesapları", + "zcodeAccounts.help": "Her hesabın resmi oturumu, modelleri ve kotası ayrıdır. Codex’te modeli elle seçin; havuz veya otomatik geçiş yoktur. Desktop bağlantısı ayrıdır.", + "zcodeAccounts.name": "Hesap adı", + "zcodeAccounts.consent": "Resmi ZCode girişini ve hesap yönetimini onaylıyorum. Giriş, varsayılanları değiştirmeden veya çıkarım göndermeden sağlayıcıyı ve kataloğu etkinleştirir.", + "zcodeAccounts.add": "Hesap ekle", + "zcodeAccounts.loginHint": "Tarayıcıda resmi girişi tamamlayın. Başka hesap için hesap değiştirin veya özel pencere kullanın. Bağlantı süreli; bu paneli açık tutun.", + "zcodeAccounts.login": "Resmi girişi aç", + "zcodeAccounts.failed": "İşlem tamamlanamadı. Süresi dolan girişi yenileyin, hesabı yeniden bağlayın, etkin işleri bekleyin ve silmeden önce yönlendirme referanslarını kaldırın.", + "zcodeAccounts.pending": "Hesap kurulumu eksik", + "zcodeAccounts.reconnect": "Yeniden giriş yap", + "zcodeAccounts.rename": "Yeniden adlandır", + "zcodeAccounts.remove": "Hesabı kaldır", + "zcodeAccounts.removeConfirm": "Bu hesap ve sağlayıcı kaldırılsın mı? Önce etkin işleri bitirin ve yönlendirme referanslarını kaldırın.", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "ZCode Desktop bağlantısı yerel sağlayıcıyı etkinleştirip modellerini Codex’e ekler. Varsayılanlar değişmez; çıkarım yapılmaz.", + "zcodeDesktop.connected": "ZCode hazır · sağlayıcı etkin · modeller yayımlandı", + "zcodeDesktop.notConnected": "Desktop bağlı değil", + "zcodeDesktop.open": "ZCode Desktop aç", + "zcodeDesktop.detect": "Yeniden algıla", + "zcodeDesktop.download": "ZCode indir", + "zcodeDesktop.location": "Desktop kurulum klasörü (gelişmiş)", + "zcodeDesktop.workspace": "Çalışma klasörü", + "zcodeDesktop.browse": "Klasörlere göz at", + "zcodeDesktop.up": "Üst klasör", + "zcodeDesktop.select": "Seç", + "zcodeDesktop.consent": "OpenCodex’in bu çalışma alanında resmi ZCode’u çalıştırmasına, Desktop profilini okumasına, sağlayıcıyı etkinleştirip Codex kataloğunu güncellemesine izin veriyorum. Sonraki isteklerimde ZCode yerel araçları çalıştırabilir.", + "zcodeDesktop.back": "Geri", + "zcodeDesktop.connect": "Desktop bağla", + "zcodeDesktop.disconnect": "Bağlantıyı kes", + "zcodeDesktop.testHint": "İsteğe bağlı: Model turu veya yerel araç başlatmadan ve kota tüketmeden resmi ZCode protokolünü ve seçili modeli yeniden doğrulayın.", + "zcodeDesktop.model": "Model", + "zcodeDesktop.test": "Protokolü yeniden doğrula", + "zcodeDesktop.testPassed": "ZCode protokol doğrulaması başarılı.", + "zcodeDesktop.missing": "Proxy bilgisayarında ZCode Desktop açıp yeniden algılayın veya kurulum ya da çıkarılmış uygulama klasörünü seçin.", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap sunucunun güvenlik bağlamında yalıtım oluşturamıyor. Yöneticinizden kullanıcı ad alanı ve AppArmor politikasını kontrol etmesini isteyip yeniden algılayın. Yalıtım kapatılmayacaktır.", + "zcodeDesktop.sandboxMissing": "Yalıtım için Linux paket yöneticisiyle bubblewrap kurup yeniden algılayın.", + "zcodeDesktop.nodeMissing": "ZCode ile uyumlu Node.js sürümünü kurup yeniden algılayın.", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop, SQLite destekli Node.js 24 veya üzerini gerektirir. Uyumlu sürümü OpenCodex PATH ortamına ekleyin, hizmeti veya terminali yeniden başlatıp tekrar algılayın.", + "zcodeDesktop.platformUnsupported": "Yönetilen Desktop bağlantısı şu anda yalnızca Linux destekler. Diğer platformlarda gelişmiş yalıtılmış başlatıcıyı kullanın.", + "zcodeDesktop.loginNeeded": "ZCode Desktop içinde oturum açıp bir Z.AI modeli yapılandırın, ardından yeniden bağlanın.", + "zcodeDesktop.workspaceInvalid": "Ev, sistem veya kimlik bilgileri klasörü yerine mevcut bir proje klasörü seçin.", + "zcodeDesktop.protocolFailed": "ZCode protokol doğrulaması tamamlanmadı. Desktop oturumunu ve model erişimini kontrol edip yeniden bağlanın. Model turu, yerel araç veya doğrudan API yedeği kullanılmadı.", + "zcodeDesktop.failed": "Resmî çalışma zamanına bağlanılamadı. Desktop, klasör ve yalıtım gereksinimlerini kontrol edin.", +"usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.", "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok", "audio.dictation": "Dikte", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 919318c809..7291352a02 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,7 +2,68 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { - "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", + "quota.remainingPercent": "剩餘 {pct}%", + "zcodeUsage.title": "GLM Coding Plan · 時段優惠", + "zcodeUsage.zone": "時間顯示為 {zone}。時段基準:新加坡(UTC+8)。", + "zcodeUsage.peak": "尖峰時段:截至 {until} 按標準模型點數費率計費。", + "zcodeUsage.offPeak": "離峰時段:截至 {until},標準模型點數費率優惠 50%。", + "zcodeUsage.flashActive": "透過 ZCode 使用 GLM-5.3-Flash 的免費時段已生效:符合以下條件時,截至 {end} 不消耗額度。", + "zcodeUsage.flashNext": "下一個透過 ZCode 使用 GLM-5.3-Flash 的免費時段:{start} – {end}。", + "zcodeUsage.eligibility": "需要付費 GLM Coding Plan、ZCode 3.10 或更新版本,且 5 小時和每週額度皆須有剩餘。此處未驗證餘額及資格。GLM-5.3 和其他 Agent 不享有此零額度消耗優惠。", + "zcodeUsage.rules": "尖峰:新加坡時間週一至週五 14:00–18:00,其餘皆為離峰。Flash 活動:2026 年 9 月 3–20 日,每天新加坡時間 23:00–翌日 09:00。末夜是否延長未明確說明,因此提示保守地於 9 月 20 日結束時停止。不修改用量或費用報告。", + "zcodeUsage.source": "官方費率 · 2026 年 9 月 11 日驗證", + "zcodeUsage.campaignSource": "官方活動條件", + "zcodeDesktop.providerPending": "Desktop 協定已驗證,但供應商註冊尚未完成。請重試啟用;現有設定將保留。", + "zcodeDesktop.catalogPending": "供應商已啟用,但 Codex 目錄尚未就緒。請重試;若模型仍缺失,請檢查自訂可見性篩選。", + "zcodeDesktop.retryActivation": "重試啟用", + "zcodeDesktop.restartNotice": "若 Codex 未顯示新模型,請於任務結束後重新啟動 Codex。不會自動重新啟動任何程序。", + "zcodeDesktop.dashboardRequired": "此操作需要綁定至迴路位址的儀表板或已配對 Hub 的瀏覽器工作階段。僅管理員權杖無法授權 Desktop 設定。", + "zcodeDesktop.hostAccess": "ZCode 預設不使用 OpenCodex 沙箱,可讀寫作業系統使用者與 harness 允許存取的任何檔案。工作目錄不是存取邊界。升級後現有連線也適用。如需隔離,請在代理上設定 OCX_ZCODE_SANDBOX=1 並重新啟動 OpenCodex。", + "zcodeDesktop.sandboxAccess": "選用的 Bubblewrap 隔離已啟用。選定的系統與執行階段路徑,以及存在的設定檔與憑證檔案會以唯讀方式掛載。私有家目錄和掛載於 /workspace 的所選工作區可寫入,並提供暫存檔案系統。其他主機路徑不會掛載。", + "zcodeAccounts.title": "已儲存的 ZCode 帳戶", + "zcodeAccounts.help": "每個帳戶擁有獨立的官方登入、模型與額度。請在 Codex 中手動選擇模型;不使用帳戶池或自動切換。Desktop 連線保持獨立。", + "zcodeAccounts.name": "帳戶名稱", + "zcodeAccounts.consent": "我授權官方 ZCode 登入並管理這些帳戶。登入完成後啟用該帳戶的供應商與目錄,不更改預設設定,也不傳送推論請求。", + "zcodeAccounts.add": "新增帳戶", + "zcodeAccounts.loginHint": "請在瀏覽器完成官方登入。使用其他帳戶時,請切換帳戶或使用私密視窗。連結會過期,請保持此面板開啟。", + "zcodeAccounts.login": "開啟官方登入", + "zcodeAccounts.failed": "無法完成操作。登入過期請重試,重複帳戶請重新連線,等待執行中工作結束,並在刪除前移除路由參照。", + "zcodeAccounts.pending": "帳戶設定未完成", + "zcodeAccounts.reconnect": "重新登入", + "zcodeAccounts.rename": "重新命名", + "zcodeAccounts.remove": "移除帳戶", + "zcodeAccounts.removeConfirm": "移除此帳戶及其供應商?請先結束工作並移除路由參照。", + "zcodeDesktop.title": "ZCode 桌面版", + "zcodeDesktop.intro": "連接 ZCode Desktop 將啟用本機供應商並將模型發佈至 Codex。不變更預設設定,也不執行推論。", + "zcodeDesktop.connected": "ZCode 已就緒 · 供應商已啟用 · 模型已發佈", + "zcodeDesktop.notConnected": "Desktop 未連接", + "zcodeDesktop.open": "開啟 ZCode Desktop", + "zcodeDesktop.detect": "重新偵測", + "zcodeDesktop.download": "下載 ZCode", + "zcodeDesktop.location": "Desktop 安裝目錄(進階)", + "zcodeDesktop.workspace": "工作目錄", + "zcodeDesktop.browse": "瀏覽目錄", + "zcodeDesktop.up": "上層目錄", + "zcodeDesktop.select": "選取", + "zcodeDesktop.consent": "我允許 OpenCodex 在此工作區執行官方 ZCode、讀取 Desktop 設定、啟用供應商並更新 Codex 目錄。日後傳送請求時,ZCode 可能執行原生工具。", + "zcodeDesktop.back": "返回", + "zcodeDesktop.connect": "連接 Desktop", + "zcodeDesktop.disconnect": "中斷連接", + "zcodeDesktop.testHint": "選用:重新驗證官方 ZCode 協定和所選模型,不會啟動模型回合、執行原生工具或消耗配額。", + "zcodeDesktop.model": "模型", + "zcodeDesktop.test": "重新驗證協定", + "zcodeDesktop.testPassed": "ZCode 協定驗證成功。", + "zcodeDesktop.missing": "請在代理所在電腦開啟 ZCode Desktop 後重新偵測,或選取安裝或解壓縮目錄。", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap 無法在伺服器的安全環境中建立隔離。請管理員檢查使用者命名空間與 AppArmor 政策後重新偵測。不會停用隔離。", + "zcodeDesktop.sandboxMissing": "隔離需要 Bubblewrap。請透過 Linux 套件管理員安裝 bubblewrap 後重新偵測。", + "zcodeDesktop.nodeMissing": "請安裝與 ZCode 相容的 Node.js 版本後重新偵測。", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop 需要支援 SQLite 的 Node.js 24 或更新版本。請將相容版本加入 OpenCodex 使用的 PATH,重新啟動服務或終端機後再次偵測。", + "zcodeDesktop.platformUnsupported": "受管理的 Desktop 連接目前僅支援 Linux。其他平台可使用進階隔離啟動器設定。", + "zcodeDesktop.loginNeeded": "請在 ZCode Desktop 登入並設定 Z.AI 模型,然後重新連接。", + "zcodeDesktop.workspaceInvalid": "請選取現有專案目錄,而非家目錄、系統目錄或憑證設定目錄。", + "zcodeDesktop.protocolFailed": "ZCode 協定驗證未完成。請在 Desktop 檢查登入狀態及模型權限後重新連接。未啟動模型回合、原生工具或直接 API 備援。", + "zcodeDesktop.failed": "無法連接官方執行環境。請檢查 Desktop、目錄與隔離依賴後重試。", +"usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。", "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄", "audio.dictation": "聽寫", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 93b43a4ee2..cf0a13d84d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,7 +4,68 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { - "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", + "quota.remainingPercent": "剩余 {pct}%", + "zcodeUsage.title": "GLM Coding Plan · 时段优惠", + "zcodeUsage.zone": "时间显示为 {zone}。时段基准:新加坡(UTC+8)。", + "zcodeUsage.peak": "高峰时段:截至 {until} 按标准模型积分费率计费。", + "zcodeUsage.offPeak": "非高峰时段:截至 {until},标准模型积分费率优惠 50%。", + "zcodeUsage.flashActive": "通过 ZCode 使用 GLM-5.3-Flash 的免费时段已生效:符合以下条件时,截至 {end} 不消耗额度。", + "zcodeUsage.flashNext": "下一个通过 ZCode 使用 GLM-5.3-Flash 的免费时段:{start} – {end}。", + "zcodeUsage.eligibility": "需要付费 GLM Coding Plan、ZCode 3.10 或更高版本,且 5 小时和每周额度都必须有剩余。此处未核验余额及资格。GLM-5.3 和其他 Agent 不享受此零额度消耗优惠。", + "zcodeUsage.rules": "高峰:新加坡时间周一至周五 14:00–18:00,其余均为非高峰。Flash 活动:2026 年 9 月 3–20 日,每天新加坡时间 23:00–次日 09:00。末夜是否延长未明确说明,因此提示保守地于 9 月 20 日结束时停止。不修改用量或费用报告。", + "zcodeUsage.source": "官方费率 · 2026 年 9 月 11 日核验", + "zcodeUsage.campaignSource": "官方活动条件", + "zcodeDesktop.providerPending": "Desktop 协议已验证,但提供商注册尚未完成。请重试启用;现有设置将保留。", + "zcodeDesktop.catalogPending": "提供商已启用,但 Codex 目录尚未就绪。请重试;若模型仍缺失,请检查自定义可见性筛选。", + "zcodeDesktop.retryActivation": "重试启用", + "zcodeDesktop.restartNotice": "若 Codex 未显示新模型,请在任务结束后重启 Codex。不会自动重启任何进程。", + "zcodeDesktop.dashboardRequired": "此操作需要绑定到回环地址的仪表板或已配对 Hub 的浏览器会话。仅管理员令牌无法授权 Desktop 设置。", + "zcodeDesktop.hostAccess": "ZCode 默认不使用 OpenCodex 沙箱,可读写操作系统用户和 harness 允许访问的任何文件。工作目录不是访问边界。升级后现有连接也适用。如需隔离,请在代理上设置 OCX_ZCODE_SANDBOX=1 并重启 OpenCodex。", + "zcodeDesktop.sandboxAccess": "可选的 Bubblewrap 隔离已启用。选定的系统和运行时路径,以及存在的配置文件和凭据文件将以只读方式挂载。私有主目录和挂载到 /workspace 的所选工作区可写,并提供临时文件系统。其他主机路径不会挂载。", + "zcodeAccounts.title": "已保存的 ZCode 账户", + "zcodeAccounts.help": "每个账户拥有独立的官方登录、模型和额度。请在 Codex 中手动选择模型;不使用账户池或自动切换。Desktop 连接保持独立。", + "zcodeAccounts.name": "账户名称", + "zcodeAccounts.consent": "我授权官方 ZCode 登录并管理这些账户。登录完成后启用该账户的提供商和目录,不更改默认设置,也不发送推理请求。", + "zcodeAccounts.add": "添加账户", + "zcodeAccounts.loginHint": "请在浏览器完成官方登录。使用其他账户时,请切换账户或使用隐私窗口。链接会过期,请保持此面板打开。", + "zcodeAccounts.login": "打开官方登录", + "zcodeAccounts.failed": "无法完成操作。登录过期请重试,重复账户请重新连接,等待活动任务结束,并在删除前移除路由引用。", + "zcodeAccounts.pending": "账户设置未完成", + "zcodeAccounts.reconnect": "重新登录", + "zcodeAccounts.rename": "重命名", + "zcodeAccounts.remove": "移除账户", + "zcodeAccounts.removeConfirm": "移除此账户及其提供商?请先结束活动任务并移除路由引用。", + "zcodeDesktop.title": "ZCode Desktop", + "zcodeDesktop.intro": "连接 ZCode Desktop 将启用本地提供商并将模型发布到 Codex。不更改默认设置,也不执行推理。", + "zcodeDesktop.connected": "ZCode 已就绪 · 提供商已启用 · 模型已发布", + "zcodeDesktop.notConnected": "Desktop 未连接", + "zcodeDesktop.open": "打开 ZCode Desktop", + "zcodeDesktop.detect": "重新检测", + "zcodeDesktop.download": "下载 ZCode", + "zcodeDesktop.location": "Desktop 安装目录(高级)", + "zcodeDesktop.workspace": "工作目录", + "zcodeDesktop.browse": "浏览目录", + "zcodeDesktop.up": "上级目录", + "zcodeDesktop.select": "选择", + "zcodeDesktop.consent": "我允许 OpenCodex 在此工作区运行官方 ZCode、读取 Desktop 配置、启用提供商并更新 Codex 目录。以后发送请求时,ZCode 可能执行原生工具。", + "zcodeDesktop.back": "返回", + "zcodeDesktop.connect": "连接 Desktop", + "zcodeDesktop.disconnect": "断开连接", + "zcodeDesktop.testHint": "可选:重新验证官方 ZCode 协议和所选模型,不会启动模型轮次、运行原生工具或消耗配额。", + "zcodeDesktop.model": "模型", + "zcodeDesktop.test": "重新验证协议", + "zcodeDesktop.testPassed": "ZCode 协议验证成功。", + "zcodeDesktop.missing": "请在代理所在计算机上打开 ZCode Desktop 后重新检测,或选择其安装或解压目录。", + "zcodeDesktop.sandboxUnavailable": "Bubblewrap 无法在服务器的安全上下文中创建隔离环境。请管理员检查用户命名空间和 AppArmor 策略,然后重新检测。不会禁用隔离。", + "zcodeDesktop.sandboxMissing": "隔离需要 Bubblewrap。请通过 Linux 包管理器安装 bubblewrap 后重新检测。", + "zcodeDesktop.nodeMissing": "请安装与 ZCode 兼容的 Node.js 版本后重新检测。", + "zcodeDesktop.nodeIncompatible": "ZCode Desktop 需要支持 SQLite 的 Node.js 24 或更新版本。请将兼容版本加入 OpenCodex 使用的 PATH,重启服务或终端后重新检测。", + "zcodeDesktop.platformUnsupported": "托管 Desktop 连接目前仅支持 Linux。其他平台可以使用高级隔离启动器配置。", + "zcodeDesktop.loginNeeded": "请在 ZCode Desktop 中登录并配置 Z.AI 模型,然后重新连接。", + "zcodeDesktop.workspaceInvalid": "请选择现有项目目录,而非主目录、系统目录或凭据配置目录。", + "zcodeDesktop.protocolFailed": "ZCode 协议验证未完成。请在 Desktop 中检查登录状态及模型权限后重新连接。未启动模型轮次、原生工具或直接 API 回退。", + "zcodeDesktop.failed": "无法连接官方运行时。请检查 Desktop、所选目录和隔离依赖后重试。", +"usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。", "api.attribution.noRecordedUse": "可读取的记录中没有使用记录", "audio.dictation": "听写", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 88cb0b6f79..112d8f7cfb 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,6 +1,7 @@ import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; +import { refreshQuotasAfterProviderAddition } from "../provider-addition"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import { matchingWorkspacePreset, type CatalogPreset } from "../components/provider-catalog/provider-presets"; import { isAccountProvider, type WorkspaceProvider } from "../provider-workspace/catalog"; @@ -457,7 +458,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { }); }, [apiBase, fetchConfig, fetchOauth]); - const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1); + const bumpModelsRefresh = useCallback(() => setModelsRefreshToken(n => n + 1), []); + const refreshProviderStateAfterNestedMutation = useCallback(() => { + void fetchConfig().finally(bumpModelsRefresh); + }, [fetchConfig, bumpModelsRefresh]); const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({ apiBase, t, aliveRef, accountSets, setAccountSets, @@ -645,6 +649,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onSetDisabled={setProviderDisabled} onSetDefault={name => { void setDefaultProvider(name); }} onUpdateProvider={updateProvider} + onProviderStateMutation={refreshProviderStateAfterNestedMutation} codexController={codexPool} /> ); @@ -691,14 +696,15 @@ export default function Providers({ apiBase }: { apiBase: string }) { setAdding(false); setAddIntent(null); }} - onAdded={(name) => { + onAdded={(name, metadata) => { setAdding(false); setAddIntent(null); clearStatus(); modelsNotice.open(name, !config.providers[name]); fetchConfig(); fetchOauth(); - fetchProviderQuotas(true); + // Desktop connection is protocol/catalog only, not consent to validate accounts. + refreshQuotasAfterProviderAddition(metadata, fetchProviderQuotas); bumpModelsRefresh(); }} onAccountLogin={onAccountLogin} @@ -706,6 +712,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAccountLogout={(provider) => { void logoutOAuth(provider); }} onAccountManage={onAccountManage} onOpenAdd={fetchOauth} + onProviderStateMutation={refreshProviderStateAfterNestedMutation} onCloseCodexLogin={() => setCodexLoginOpen(false)} onCodexAdded={(completion) => { setCodexLoginOpen(false); diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index ba5964675e..b781a7d264 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -8,6 +8,7 @@ import type { AccountLoginRow, AccountLoginStatus } from "../components/provider import type { ProvidersConfig } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +import type { ProviderAdditionMetadata } from "../provider-addition"; export function ProvidersPageModals({ apiBase, @@ -32,6 +33,7 @@ export function ProvidersPageModals({ onAccountLogout, onAccountManage, onOpenAdd, + onProviderStateMutation, onCloseCodexLogin, onCodexAdded, onCancelRemove, @@ -58,12 +60,13 @@ export function ProvidersPageModals({ jsonSaving?: boolean; oauthTosPending: { provider: string; addAccount: boolean } | null; onCloseAdd: () => void; - onAdded: (name: string) => void; + onAdded: (name: string, metadata?: ProviderAdditionMetadata) => void; onAccountLogin: (provider: string, addAccount?: boolean) => void; onAccountCancelLogin: (provider: string) => void; onAccountLogout: (provider: string) => void; onAccountManage?: (provider: string) => void; onOpenAdd: () => void; + onProviderStateMutation?: () => void; onCloseCodexLogin: () => void; onCodexAdded: (completion: CodexAccountMutationCompletion) => void; onCancelRemove: () => void; @@ -94,6 +97,7 @@ export function ProvidersPageModals({ onAccountLogout={onAccountLogout} onAccountManage={onAccountManage} onOpen={onOpenAdd} + onProviderStateMutation={onProviderStateMutation} /> )} {codexLoginOpen && ( diff --git a/gui/src/provider-addition.ts b/gui/src/provider-addition.ts new file mode 100644 index 0000000000..57d34cc79d --- /dev/null +++ b/gui/src/provider-addition.ts @@ -0,0 +1,13 @@ +export interface ProviderAdditionMetadata { + /** Adapter returned by the completed activation, independent of the caller's stale config snapshot. */ + adapter?: string; +} + +/** Native ZCode activation is protocol/catalog-only and must not start any quota read. */ +export function refreshQuotasAfterProviderAddition( + metadata: ProviderAdditionMetadata | undefined, + refresh: (force: boolean) => unknown, +): void { + if (metadata?.adapter === "zcode") return; + refresh(true); +} diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 26a0f42fdd..ed7e7bb1c7 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -12,6 +12,7 @@ const PROVIDER_ICON_ALIASES: Record = { "command-code": "commandcode-color.svg", commandcode: "commandcode-color.svg", cursor: "cursor-color.svg", + zcode: "zcode.svg", deepseek: "deepseek-color.svg", /* * One mark for the merged Devin provider. `devin` is Cognition's cloud; the @@ -167,6 +168,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { siliconflow: "SiliconFlow", "tencent-coding-plan": "Tencent Cloud Coding Plan", codebuddy: "CodeBuddy", + zcode: "ZCode", "codebuddy-cn": "CodeBuddy CN", qoder: "Qoder", "qoder-cn": "Qoder CN", diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index bc42c9e941..4f4686f9db 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -75,7 +75,7 @@ export interface ProviderPayload { apiKey?: string; apiKeyTransport?: "x-api-key" | "bearer"; defaultModel?: string; - authMode?: "key" | "forward" | "oauth"; + authMode?: "key" | "forward" | "oauth" | "local"; codexAccountMode?: "pool" | "direct"; allowPrivateNetwork?: boolean; } @@ -89,7 +89,7 @@ export function buildProviderPayload(form: ProviderPayloadForm): ProviderPayload if (form.responsesPath?.trim()) { provider.responsesPath = form.responsesPath.trim(); } - if (form.authMode === "key" || form.authMode === "forward") { + if (form.authMode === "key" || form.authMode === "forward" || (form.authMode === "local" && form.adapter.trim() === "zcode")) { provider.authMode = form.authMode; } if (form.authMode === "key" && form.apiKey.trim()) { diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 522aa32857..b20943eb9f 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -55,6 +55,8 @@ export interface WorkspaceProvider { }; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; + /** Exact saved-account binding for ZCode; absent on the global Desktop provider. */ + zcodeAccountId?: string; /** Derived state of the two xAI Grok Responses model-adapter entries. */ xaiResponsesOptInState?: boolean | "mixed"; } diff --git a/gui/src/zcode-usage-schedule.ts b/gui/src/zcode-usage-schedule.ts new file mode 100644 index 0000000000..334f66cfc9 --- /dev/null +++ b/gui/src/zcode-usage-schedule.ts @@ -0,0 +1,48 @@ +/** Advisory only: never changes routing, measured usage or billing estimates. + * Official rules verified 2026-09-11; Singapore has a fixed UTC+8 offset. + */ +export const ZCODE_USAGE_SOURCE = "https://docs.z.ai/devpack/overview"; +export const ZCODE_CAMPAIGN_SOURCE = "https://docs.z.ai/devpack/notice/event-glm-5.3-flash"; +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const OFFSET = 8 * HOUR; +export const CAMPAIGN_START = Date.parse("2026-09-03T00:00:00+08:00"); +// The notice does not explicitly extend the final night into September 21. +// Conservatively stop advertising free usage at the end of September 20 SGT. +export const CAMPAIGN_END = Date.parse("2026-09-21T00:00:00+08:00"); + +export function zcodeUsageScope(provider: { adapter: string; baseUrl: string; disabled?: boolean }): "zcode" | "coding-plan" | undefined { + if (provider.disabled) return; + if (provider.adapter === "zcode") return "zcode"; + try { + const url = new URL(provider.baseUrl); + if (url.protocol === "https:" && url.hostname === "api.z.ai" && !url.username && !url.password + && /^\/api\/coding\/paas\/v4\/?$/.test(url.pathname)) return "coding-plan"; + } catch { /* Non-HTTP local adapters are not Coding Plan endpoints. */ } +} + +export function zcodeUsageSchedule(now: number) { + if (!Number.isFinite(now)) throw new RangeError("Invalid schedule timestamp"); + const shifted = new Date(now + OFFSET); + const dayStart = Math.floor((now + OFFSET) / DAY) * DAY - OFFSET; + const weekday = shifted.getUTCDay(); + const hour = shifted.getUTCHours(); + const peak = weekday >= 1 && weekday <= 5 && hour >= 14 && hour < 18; + let nextPeakStart = dayStart + 14 * HOUR; + while (nextPeakStart <= now || [0, 6].includes(new Date(nextPeakStart + OFFSET).getUTCDay())) nextPeakStart += DAY; + const rateChangesAt = peak ? dayStart + 18 * HOUR : nextPeakStart; + let flashStart = hour < 9 ? dayStart - HOUR : dayStart + 23 * HOUR; + flashStart = Math.max(flashStart, CAMPAIGN_START); + const flashEnd = Math.min(hour < 9 ? dayStart + 9 * HOUR : dayStart + DAY + 9 * HOUR, CAMPAIGN_END); + const campaign = now >= CAMPAIGN_START && now < CAMPAIGN_END; + return { peak, rateChangesAt, campaign, flashActive: campaign && now >= flashStart && now < flashEnd, + flashStart, flashEnd }; +} + +export function formatZcodeLocalTime(timestamp: number, locale: string, timeZone: string): string { + return new Intl.DateTimeFormat(locale, { timeZone, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" }).format(timestamp); +} + +export function browserTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +} diff --git a/gui/tests/provider-addition-policy.test.ts b/gui/tests/provider-addition-policy.test.ts new file mode 100644 index 0000000000..a8e4afbca6 --- /dev/null +++ b/gui/tests/provider-addition-policy.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { refreshQuotasAfterProviderAddition } from "../src/provider-addition"; + +test("newly activated ZCode providers skip quota reads entirely", () => { + const calls: boolean[] = []; + const refresh = (force: boolean) => calls.push(force); + + refreshQuotasAfterProviderAddition({ adapter: "zcode" }, refresh); + expect(calls).toEqual([]); + + refreshQuotasAfterProviderAddition(undefined, refresh); + refreshQuotasAfterProviderAddition({ adapter: "openai-chat" }, refresh); + expect(calls).toEqual([true, true]); +}); diff --git a/gui/tests/zcode-desktop-pane.test.tsx b/gui/tests/zcode-desktop-pane.test.tsx new file mode 100644 index 0000000000..19a96ce324 --- /dev/null +++ b/gui/tests/zcode-desktop-pane.test.tsx @@ -0,0 +1,654 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ZcodeDesktopPane from "../src/components/ZcodeDesktopPane"; +import AddProviderModal from "../src/components/AddProviderModal"; +import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + + + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let closeCalls: number; +let mutationCalls: number; +let additions: Array<{ name: string; adapter?: string }>; +let requests: Array<{ path: string; body?: Record }>; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + closeCalls = 0; mutationCalls = 0; additions = []; requests = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + requests.push({ path: url.pathname, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + if (url.pathname.endsWith("/test")) return Response.json({ ok: true }); + return Response.json({ connected: url.pathname.endsWith("/connect"), activation: url.pathname.endsWith("/connect") ? "ready" : "disconnected", providerName: "zcode", runtimes: ["/installed/ZCode"], runtime: "/installed/ZCode", workspace: "/project", models: [{ id: "builtin:zai/model", label: "Model" }] }); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountPane(withConnectionCallback = true) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( { mutationCalls++; }} + onConnected={withConnectionCallback ? (name, metadata) => { + closeCalls++; additions.push({ name, adapter: metadata?.adapter }); + } : undefined} />); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); +} +function button(text: string): HTMLButtonElement { + const found = [...host.querySelectorAll("button")].find(b => b.textContent === text); + expect(found).toBeTruthy(); return found!; +} +async function click(element: HTMLElement) { + await act(async () => { element.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); +} +async function waitUntil(condition: () => boolean, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for the UI condition"); + await act(async () => { await Bun.sleep(20); }); + } +} +test("Desktop connect requires explicit consent and does not automatically spend model quota", async () => { + await mountPane(); + expect(button("Connect Desktop").disabled).toBe(true); + const consent = host.querySelector('input[type="checkbox"]')!; + await click(consent); + expect(button("Connect Desktop").disabled).toBe(false); + await click(button("Connect Desktop")); + expect(requests.find(r => r.path.endsWith("/connect"))?.body).toEqual({ runtime: "/installed/ZCode", workspace: "/project", consent: true }); + expect(requests.some(r => r.path.endsWith("/test"))).toBe(false); + expect(closeCalls).toBe(1); + expect(mutationCalls).toBe(1); + expect(additions).toEqual([{ name: "zcode", adapter: "zcode" }]); + expect(host.textContent).not.toContain("Use this provider"); + expect(requests.some(r => r.path === "/api/providers")).toBe(false); +}); +test("protocol recheck is a separate tool-free action that does not claim quota use", async () => { + await mountPane(); await click(host.querySelector('input[type="checkbox"]')!); + await click(button("Connect Desktop")); + expect(host.textContent).toContain("without starting a model turn, running native tools, or consuming quota"); + await click(button("Verify protocol again")); + expect(requests.find(r => r.path.endsWith("/test"))?.body).toEqual({ model: "builtin:zai/model", consent: true }); + expect(host.textContent).toContain("ZCode protocol verification passed."); +}); + +test("a successful HTTP response with a failed protocol recheck shows the actionable error", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path.endsWith("/test")) return Response.json({ ok: false }); + return Response.json({ connected: true, activation: "ready", providerName: "zcode", runtimes: ["/installed/ZCode"], + runtime: "/installed/ZCode", workspace: "/project", models: [{ id: "builtin:zai/model", label: "Model" }] }); + } }); + await mountPane(); + await click(button("Verify protocol again")); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("protocol verification did not complete"); + expect(host.textContent).not.toContain("ZCode protocol verification passed."); +}); + +test("incompatible Node preflight shows safe actionable guidance without connecting", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + requests.push({ path }); + return Response.json({ configured: true, connected: false, issue: "node_incompatible", runtimes: [], + runtime: "/installed/ZCode", workspace: "/project", models: [] }); + } }); + await mountPane(); + const alert = host.querySelector('[role="alert"]')!; + expect(alert.textContent).toContain("Node.js 24"); + expect(alert.textContent).toContain("PATH"); + expect(alert.textContent).toContain("restart"); + expect(alert.textContent).not.toContain("runtime_failed"); + expect(host.querySelector('input[type="checkbox"]')!.checked).toBe(false); + expect(button("Connect Desktop").disabled).toBe(true); + expect(button("Disconnect").disabled).toBe(false); + await click(button("Disconnect")); + expect(requests.some(request => request.path.endsWith("/disconnect"))).toBe(true); +}); + +test("partial activation remains visible and retries without a second protocol connection", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const path = new URL(String(input), "http://localhost").pathname; + requests.push({ path, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + return Response.json({ connected: true, activation: path.endsWith("/activate") ? "ready" : "catalog_pending", + ...(path.endsWith("/activate") ? {} : { error: "catalog_update_failed" }), providerName: "zcode", + runtimes: ["/installed/ZCode"], runtime: "/installed/ZCode", workspace: "/project", models: [] }); + } }); + await mountPane(); + expect(host.textContent).toContain("catalog is not ready"); + expect(closeCalls).toBe(0); + expect(button("Retry activation").disabled).toBe(true); + await click(host.querySelector('input[type="checkbox"]')!); + await click(button("Retry activation")); + expect(closeCalls).toBe(1); + expect(requests.filter(r => r.path.endsWith("/connect") || r.path.endsWith("/test"))).toHaveLength(0); + expect(host.textContent).toContain("No processes will be restarted automatically"); +}); + +test("the Add Provider ZCode flow refreshes parent state after partial activation", async () => { + let modalMutations = 0; + let modalAdditions = 0; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path.endsWith("/api/provider-presets")) return Response.json({ providers: [{ + id: "zcode", label: "ZCode (local agent)", adapter: "zcode", baseUrl: "https://zcode.z.ai", auth: "local", + }] }); + if (path.endsWith("/api/oauth/providers")) return Response.json({ providers: [] }); + if (path.endsWith("/api/usage")) return Response.json({ providers: [] }); + if (path.endsWith("/api/zcode-accounts")) return Response.json({ accounts: [] }); + if (path.endsWith("/api/zcode-desktop/connect")) return Response.json({ connected: true, + activation: "catalog_pending", error: "catalog_update_failed", providerName: "zcode", + runtimes: ["/installed/ZCode"], runtime: "/installed/ZCode", workspace: "/project", models: [] }); + return Response.json({ connected: false, activation: "disconnected", runtimes: ["/installed/ZCode"], + runtime: "/installed/ZCode", workspace: "/project", models: [] }); + } }); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( {}} onAdded={() => { modalAdditions++; }} + onProviderStateMutation={() => { modalMutations++; }} />); + }); + await waitUntil(() => !!host.querySelector(".provider-catalog-search")); + const search = host.querySelector(".provider-catalog-search")!; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(search, "zcode"); + search.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + await click(host.querySelector(".provider-catalog-row-wrap .list-row")!); + await waitUntil(() => !!host.querySelector('[aria-label="ZCode Desktop"]')); + const pane = host.querySelector('[aria-label="ZCode Desktop"]')!; + await click(pane.querySelector('input[type="checkbox"]')!); + await click(button("Connect Desktop")); + + expect(modalMutations).toBe(1); + expect(modalAdditions).toBe(0); + expect(host.querySelector(".modal-overlay")).toBeTruthy(); + expect(host.textContent).toContain("catalog is not ready"); +}); + +test("Desktop disconnect refreshes parent provider state", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/api/zcode-accounts") return Response.json({ accounts: [] }); + return Response.json({ connected: !path.endsWith("/disconnect"), activation: path.endsWith("/disconnect") ? "disconnected" : "ready", + providerName: "zcode", runtimes: ["/installed/ZCode"], runtime: "/installed/ZCode", workspace: "/project", + models: [{ id: "builtin:zai/model", label: "Model" }] }); + } }); + await mountPane(false); + await click(button("Disconnect")); + expect(mutationCalls).toBe(1); +}); + +test("admin-token setup refusal shows browser-session guidance, not success", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (_input: RequestInfo | URL, options?: RequestInit) => + options?.method === "POST" ? Response.json({ error: "dashboard_required" }, { status: 403 }) : + Response.json({ connected: false, activation: "disconnected", runtimes: ["/installed/ZCode"], runtime: "/installed/ZCode", workspace: "/project", models: [] }) + }); + await mountPane(); + await click(host.querySelector('input[type="checkbox"]')!); + await click(button("Connect Desktop")); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("browser session"); + expect(closeCalls).toBe(0); + expect(host.textContent).not.toContain("ZCode ready"); +}); + +test("sandbox denial explains server policy and never reports complete success", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async () => Response.json({ + connected: false, issue: "sandbox_unavailable", runtimes: [], runtime: "", workspace: "/project", models: [], + }) }); + await mountPane(); + const alert = host.querySelector('[role="alert"]')!; + expect(alert.textContent).toContain("AppArmor"); + expect(alert.textContent).toContain("server"); + expect(alert.textContent).not.toContain("Permission denied"); + expect(button("Connect Desktop").disabled).toBe(true); + expect(closeCalls).toBe(0); +}); + +test("default host access is disclosed before connection consent", async () => { + await mountPane(); + expect(host.querySelector('[role="note"]')?.textContent).toContain("without an OpenCodex sandbox"); + expect(host.querySelector('[role="note"]')?.textContent).toContain("existing connections"); + expect(host.querySelector('[role="note"]')?.textContent).toContain("OCX_ZCODE_SANDBOX=1"); + expect(host.querySelector('input[type="checkbox"]')!.checked).toBe(false); +}); + +test("explicit sandbox mode shows its filesystem boundary", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async () => Response.json({ + connected: false, sandbox: true, runtimes: [], runtime: "", workspace: "/project", models: [], + }) }); + await mountPane(); + expect(host.querySelector('[role="note"]')?.textContent).toContain("Selected system and runtime paths"); + expect(host.querySelector('[role="note"]')?.textContent).not.toContain("without an OpenCodex sandbox"); +}); + +test("account-bound provider settings do not expose controls for the global Desktop connection", async () => { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + expect(host.querySelector('[aria-label="ZCode Desktop"]')).toBeNull(); + expect(requests.some(request => request.path.startsWith("/api/zcode-desktop"))).toBe(false); +}); + +test("existing provider settings refresh parent provider state after Desktop mutation", async () => { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( { mutationCalls++; }} />); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + const pane = host.querySelector('[aria-label="ZCode Desktop"]')!; + await click(pane.querySelector('input[type="checkbox"]')!); + await click(button("Connect Desktop")); + expect(mutationCalls).toBe(1); +}); + +test("saved accounts offer separate manual login and never start OAuth without consent", async () => { + await mountPane(); + expect(host.textContent).toContain("Saved ZCode accounts"); + expect(host.textContent).toContain("no pool or automatic account switch"); + expect(button("Add account").disabled).toBe(true); + expect(requests.some(r => r.path.endsWith("/zcode-accounts/login"))).toBe(false); +}); + +test("a failed saved-account OAuth job keeps cancellation available", async () => { + let cancelled = false; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + requests.push({ path: url.pathname, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + if (url.pathname.endsWith("/login")) return Response.json({ jobId: "failed-job", accountId: "failed-account", + phase: options?.method === "POST" ? "waiting" : "failed", error: "native_oauth_failed" }); + if (url.pathname.endsWith("/cancel")) { cancelled = true; return Response.json({ ok: true }); } + return Response.json({ accounts: [] }); + } }); + await mountPane(); + const section = host.querySelector("h3")!.closest("section")!; + const input = section.querySelector('input:not([type="checkbox"])') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "Failed fixture"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Add account")); + await waitUntil(() => host.textContent?.includes("native_oauth_failed") === true + && [...section.querySelectorAll("button")].some(item => item.textContent === "Cancel")); + await click(button("Cancel")); + + expect(cancelled).toBe(true); + expect(requests.find(request => request.path.endsWith("/cancel"))?.body).toMatchObject({ jobId: "failed-job" }); + expect(button("Add account").disabled).toBe(false); +}); + +test("account activation failure stays partial and separate account labels remain visible", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/api/zcode-accounts") return Response.json({ accounts: [ + { id: "a", label: "Personal fixture", activation: "ready", busy: false }, + { id: "b", label: "Work fixture", activation: "catalog_pending", busy: false }, + ] }); + return Response.json({ connected: false, runtimes: [], runtime: "", workspace: "/project", models: [] }); + } }); + await mountPane(); + expect(host.textContent).toContain("Personal fixture"); + expect(host.textContent).toContain("Work fixture"); + expect(host.textContent).toContain("Account setup incomplete"); + expect(button("Add account").disabled).toBe(true); +}); + +test("saved account UI completes official login then shows provider/catalog readiness", async () => { + let complete = 0; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + requests.push({ path: url.pathname, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + if (url.pathname.endsWith("/login")) return Response.json({jobId:"fixture-job",accountId:"fixture-account",phase:options?.method === "POST" ? "waiting" : "authenticated"}); + if (url.pathname.endsWith("/complete")) { complete++; return Response.json({activation:"ready",providerName:"zcode-fixture"}); } + return Response.json({accounts:complete ? [{id:"fixture-account",label:"Personal fixture",activation:"ready",providerName:"zcode-fixture",busy:false}] : []}); + } }); + await mountPane(); + const section = host.querySelector("h3")!.closest("section")!; + const input = section.querySelector('input:not([type="checkbox"])') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "Personal fixture"); input.dispatchEvent(new win.Event("input", {bubbles:true}) as unknown as Event); + (section.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); + }); + expect(button("Add account").disabled).toBe(false); + await act(async () => { button("Add account").click(); }); + await waitUntil(() => complete === 1 && section.textContent?.includes("Personal fixture") === true); + expect(complete).toBe(1); + expect(section.textContent).toContain("Personal fixture"); + expect(section.textContent).toContain("provider enabled · models published"); + expect(section.textContent).toContain("No processes will be restarted automatically"); + expect(requests.some(r => r.path.endsWith("/test"))).toBe(false); + expect(requests.filter(r => r.body).every(r => r.body!.consent === true)).toBe(true); + expect(closeCalls).toBe(1); + expect(mutationCalls).toBe(1); + expect(additions).toEqual([{ name: "zcode-fixture", adapter: "zcode" }]); +}); + +test("saved account completion preserves an HTTP-200 partial activation", async () => { + let completed = false; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + if (url.pathname.endsWith("/login")) return Response.json({ jobId: "partial-job", accountId: "partial-account", + phase: options?.method === "POST" ? "waiting" : "authenticated" }); + if (url.pathname.endsWith("/complete")) { + completed = true; + return Response.json({ activation: "catalog_pending", error: "catalog_update_failed" }); + } + return Response.json({ accounts: completed ? [{ id: "partial-account", label: "Partial fixture", + activation: "catalog_pending", busy: false }] : [] }); + } }); + await mountPane(); + const section = host.querySelector("h3")!.closest("section")!; + const input = section.querySelector('input:not([type="checkbox"])') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "Partial fixture"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Add account")); + await waitUntil(() => completed && host.textContent?.includes("Partial fixture") === true); + expect(host.textContent).toContain("Partial fixture"); + expect(host.textContent).toContain("catalog_update_failed"); + expect(host.textContent).not.toContain("native_oauth_failed"); + expect(closeCalls).toBe(0); + expect(mutationCalls).toBe(1); +}); + +test("partial completion keeps its finished job recoverable when the account refresh fails", async () => { + let completions = 0; + let failNextRefresh = false; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + requests.push({ path: url.pathname, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + if (url.pathname.endsWith("/login")) return Response.json({ jobId: "recovery-job", accountId: "recovery-account", + phase: options?.method === "POST" ? "waiting" : "authenticated" }); + if (url.pathname.endsWith("/complete")) { + completions++; + if (completions === 1) { + failNextRefresh = true; + return Response.json({ activation: "catalog_pending", error: "catalog_update_failed" }); + } + return Response.json({ activation: "ready", providerName: "zcode-recovered" }); + } + if (failNextRefresh) { + failNextRefresh = false; + return Response.json({ error: "refresh_failed" }, { status: 500 }); + } + return Response.json({ accounts: completions > 1 ? [{ id: "recovery-account", label: "Recovery fixture", + activation: "ready", providerName: "zcode-recovered", busy: false }] : [] }); + } }); + await mountPane(); + const section = host.querySelector("h3")!.closest("section")!; + const input = section.querySelector('input:not([type="checkbox"])') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "Recovery fixture"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Add account")); + await waitUntil(() => completions === 1 && host.textContent?.includes("catalog_update_failed") === true + && [...section.querySelectorAll("button")].some(item => item.textContent === "Retry activation")); + expect(requests.filter(request => request.path.endsWith("/login") && request.body)).toHaveLength(1); + await click(button("Retry activation")); + await waitUntil(() => completions === 2 && host.textContent?.includes("Recovery fixture") === true); + expect(requests.filter(request => request.path.endsWith("/login") && request.body)).toHaveLength(1); + expect(host.textContent).toContain("provider enabled · models published"); + expect(mutationCalls).toBe(2); + expect(additions).toEqual([{ name: "zcode-recovered", adapter: "zcode" }]); +}); + +test("a failed recovery retry stays recoverable without restarting OAuth polling", async () => { + let completions = 0; + let failNextRefresh = false; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + requests.push({ path: url.pathname, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + if (url.pathname.endsWith("/login")) return Response.json({ jobId: "retry-job", accountId: "retry-account", + phase: options?.method === "POST" ? "waiting" : "authenticated" }); + if (url.pathname.endsWith("/complete")) { + completions++; + if (completions === 1) { + failNextRefresh = true; + return Response.json({ activation: "catalog_pending", error: "catalog_update_failed" }); + } + if (completions === 2) return Response.json({ error: "native_oauth_failed" }, { status: 503 }); + return Response.json({ activation: "ready", providerName: "zcode-retry" }); + } + if (failNextRefresh) { + failNextRefresh = false; + return Response.json({ error: "refresh_failed" }, { status: 500 }); + } + return Response.json({ accounts: completions > 2 ? [{ id: "retry-account", label: "Retry fixture", + activation: "ready", providerName: "zcode-retry", busy: false }] : [] }); + } }); + await mountPane(); + const section = host.querySelector("h3")!.closest("section")!; + const input = section.querySelector('input:not([type="checkbox"])') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "Retry fixture"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Add account")); + await waitUntil(() => completions === 1 && [...section.querySelectorAll("button")] + .some(item => item.textContent === "Retry activation")); + const loginPollsBeforeRetry = requests.filter(request => request.path.endsWith("/login") && !request.body).length; + + await click(button("Retry activation")); + await waitUntil(() => completions === 2 && host.textContent?.includes("native_oauth_failed") === true); + await act(async () => { await Bun.sleep(50); }); + expect(button("Retry activation").disabled).toBe(false); + expect(requests.filter(request => request.path.endsWith("/login") && !request.body)).toHaveLength(loginPollsBeforeRetry); + + await click(button("Retry activation")); + await waitUntil(() => completions === 3 && host.textContent?.includes("Retry fixture") === true); + expect(host.textContent).toContain("provider enabled · models published"); + expect(requests.filter(request => request.path.endsWith("/login") && request.body)).toHaveLength(1); +}); + +test("saved account activation refreshes the parent only after provider and catalog readiness", async () => { + let activation = "catalog_pending"; + let attempts = 0; + let failNextRefresh = false; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + if (url.pathname.endsWith("/activate")) { + attempts++; + activation = attempts === 1 ? "catalog_pending" : "ready"; + failNextRefresh = activation === "ready"; + return Response.json({ activation, providerName: "zcode-saved", ...(activation === "ready" ? {} : { error: "catalog_update_failed" }) }); + } + if (failNextRefresh) { failNextRefresh = false; return Response.json({ error: "refresh_failed" }, { status: 500 }); } + return Response.json({ accounts: [{ id: "saved", label: "Saved fixture", activation, providerName: "zcode-saved", busy: false }] }); + } }); + await mountPane(); + const section = host.querySelector("h3")!.closest("section")!; + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Retry activation")); + expect(closeCalls).toBe(0); + expect(host.textContent).toContain("catalog_update_failed"); + await click(button("Retry activation")); + expect(closeCalls).toBe(1); + expect(mutationCalls).toBe(2); + expect(additions).toEqual([{ name: "zcode-saved", adapter: "zcode" }]); +}); + +test("ready account activation treats its local refresh as best effort without a parent callback", async () => { + let failNextRefresh = false; + let activations = 0; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + if (url.pathname.endsWith("/activate")) { + activations++; + failNextRefresh = true; + return Response.json({ activation: "ready", providerName: "zcode-saved" }); + } + if (failNextRefresh) return Response.json({ error: "refresh_failed" }, { status: 500 }); + return Response.json({ accounts: [{ id: "saved", label: "Saved fixture", activation: "catalog_pending", + providerName: "zcode-saved", busy: false }] }); + } }); + await mountPane(false); + const section = host.querySelector("h3")!.closest("section")!; + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Retry activation")); + expect(activations).toBe(1); + expect(host.textContent).not.toContain("native_oauth_failed"); + expect(host.textContent).not.toContain("refresh_failed"); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(mutationCalls).toBe(1); +}); + +test("saved account rename and removal refresh parent provider state", async () => { + let removed = false; + const prior = globalThis.fetch; + Object.defineProperty(win, "prompt", { configurable: true, value: () => "Renamed fixture" }); + Object.defineProperty(win, "confirm", { configurable: true, value: () => true }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + if (url.pathname.endsWith("/rename")) return Response.json({ activation: "ready", providerName: "zcode-saved" }); + if (url.pathname.endsWith("/remove")) { removed = true; return Response.json({ ok: true }); } + return Response.json({ accounts: removed ? [] : [{ id: "saved", label: "Saved fixture", + activation: "ready", providerName: "zcode-saved", busy: false }] }); + } }); + await mountPane(false); + const section = host.querySelector("h3")!.closest("section")!; + await click(section.querySelector('input[type="checkbox"]')!); + await click([...section.querySelectorAll("button")].find(item => item.textContent === "Rename")!); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + expect(mutationCalls).toBe(1); + await click(button("Remove account")); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + expect(mutationCalls).toBe(2); +}); + +for (const removalError of ["catalog_update_failed", "account_removal_partial"]) { + test(`partial account removal refreshes parent state after ${removalError}`, async () => { + let partialRemoval = false; + const prior = globalThis.fetch; + Object.defineProperty(win, "confirm", { configurable: true, value: () => true }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + if (url.pathname.endsWith("/remove")) { + partialRemoval = true; + return Response.json({ error: removalError }, { status: 400 }); + } + return Response.json({ accounts: [{ id: "saved", label: "Saved fixture", + activation: partialRemoval ? "provider_pending" : "ready", + ...(partialRemoval ? {} : { providerName: "zcode-saved" }), busy: false }] }); + } }); + await mountPane(false); + const section = host.querySelector("h3")!.closest("section")!; + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Remove account")); + await waitUntil(() => mutationCalls === 1 && host.textContent?.includes(removalError) === true); + expect(partialRemoval).toBe(true); + expect(section.textContent).toContain("Account setup incomplete"); + }); +} + +test("ready completion keeps a refresh failure recoverable without repeating OAuth", async () => { + let completions = 0; + let failNextRefresh = false; + const prior = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, options?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (!url.pathname.startsWith("/api/zcode-accounts")) return prior(input, options); + requests.push({ path: url.pathname, body: options?.body ? JSON.parse(String(options.body)) : undefined }); + if (url.pathname.endsWith("/login")) return Response.json({ jobId: "fixture-job", accountId: "fixture-account", + phase: options?.method === "POST" ? "waiting" : "authenticated" }); + if (url.pathname.endsWith("/complete")) { + completions++; + if (completions === 1) return Response.json({ error: "busy" }, { status: 400 }); + failNextRefresh = completions === 2; + return Response.json({ activation: "ready", providerName: "zcode-saved" }); + } + if (failNextRefresh) { failNextRefresh = false; return Response.json({ error: "refresh_failed" }, { status: 500 }); } + return Response.json({ accounts: completions > 2 ? [{ id: "fixture-account", label: "No callback fixture", + activation: "ready", providerName: "zcode-saved", busy: false }] : [] }); + } }); + await mountPane(false); + const section = host.querySelector("h3")!.closest("section")!; + const input = section.querySelector('input:not([type="checkbox"])') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "No callback fixture"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + await click(section.querySelector('input[type="checkbox"]')!); + await click(button("Add account")); + await waitUntil(() => completions === 2 && host.textContent?.includes("account_refresh_failed") === true + && [...section.querySelectorAll("button")].some(item => item.textContent === "Retry activation")); + expect(completions).toBe(2); + expect(button("Add account").disabled).toBe(false); + expect(host.querySelector('[role="alert"] code')?.textContent).toBe("account_refresh_failed"); + expect(requests.filter(request => request.path.endsWith("/login") && request.body)).toHaveLength(1); + await click(button("Retry activation")); + await waitUntil(() => completions === 3 && host.textContent?.includes("No callback fixture") === true); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(requests.filter(request => request.path.endsWith("/login") && request.body)).toHaveLength(1); + expect(mutationCalls).toBe(2); +}); diff --git a/gui/tests/zcode-quota-bars.test.tsx b/gui/tests/zcode-quota-bars.test.tsx new file mode 100644 index 0000000000..9ba6404b44 --- /dev/null +++ b/gui/tests/zcode-quota-bars.test.tsx @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LanguageProvider } from "../src/i18n/provider"; +import { ProviderCapacityQuota } from "../src/components/provider-workspace/ProviderCapacityQuota"; +import ProviderCurrentQuota from "../src/components/provider-workspace/ProviderCurrentQuota"; + +const report = () => ({ source: "zcode-desktop", updatedAt: Date.now(), quota: { + updatedAt: Date.now(), fiveHourPercent: 25, weeklyPercent: 90, + fiveHourResetAt: Date.now() + 3600000, weeklyResetAt: Date.now() + 86400000, +} }); + +test("ZCode uses shared bars with remaining balances and reset labels", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("75% remaining"); + expect(html).toContain("10% remaining"); + expect(html).toContain('aria-valuenow="75"'); + expect(html).toContain('--bar-scale:0.75'); + expect(html).toContain('quota-stacked-reset'); +}); + +test("current account Usage retains the native source through the shared renderer", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("75% remaining"); + expect(html).toContain("10% remaining"); +}); + +test("OpenAI keeps its existing consumption display and width", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("25% used"); + expect(html).toContain("90% used"); + expect(html).not.toContain("remaining"); + expect(html).toContain('--bar-scale:0.25'); +}); + +test("an exhausted native quota stays exhausted and missing quota has no progress bar", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("0% remaining"); + expect(html).toContain("Limit reached"); + const missing = renderToStaticMarkup(); + expect(missing).not.toContain('role="progressbar"'); +}); diff --git a/gui/tests/zcode-usage-schedule.test.ts b/gui/tests/zcode-usage-schedule.test.ts new file mode 100644 index 0000000000..a8fda89f12 --- /dev/null +++ b/gui/tests/zcode-usage-schedule.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement } from "react"; +import { buildProviderPayload } from "../src/provider-payload"; +import ZcodeUsageNotices from "../src/components/ZcodeUsageNotices"; +import { LanguageProvider } from "../src/i18n/provider"; +import { CAMPAIGN_END, CAMPAIGN_START, formatZcodeLocalTime, zcodeUsageSchedule, zcodeUsageScope } from "../src/zcode-usage-schedule"; +const at = (value: string) => zcodeUsageSchedule(Date.parse(value + "+08:00")); + +describe("ZCode official schedule advisories", () => { + test("Desktop provider creation preserves local auth instead of requesting an API key", () => { + expect(buildProviderPayload({ name: "zcode", adapter: "zcode", baseUrl: "https://zcode.z.ai", authMode: "local", apiKey: "", defaultModel: "" })).toEqual({ adapter: "zcode", baseUrl: "https://zcode.z.ai", authMode: "local" }); + }); + test("peak boundaries are half-open Singapore weekdays", () => { + expect(at("2026-09-11T13:59:59").peak).toBe(false); + expect(at("2026-09-11T14:00:00").peak).toBe(true); + expect(at("2026-09-11T17:59:59").peak).toBe(true); + expect(at("2026-09-11T18:00:00").peak).toBe(false); + expect(at("2026-09-12T15:00:00").peak).toBe(false); + expect(at("2026-09-13T15:00:00").peak).toBe(false); + expect(at("2026-09-11T18:00:00").rateChangesAt).toBe(Date.parse("2026-09-14T14:00:00+08:00")); + }); + test("daily Flash window crosses midnight and includes weekends", () => { + expect(at("2026-09-11T22:59:59").flashActive).toBe(false); + expect(at("2026-09-11T23:00:00").flashActive).toBe(true); + expect(at("2026-09-12T08:59:59").flashActive).toBe(true); + expect(at("2026-09-12T09:00:00").flashActive).toBe(false); + expect(at("2026-09-12T09:00:00").flashStart).toBe(Date.parse("2026-09-12T23:00:00+08:00")); + }); + test("campaign expires without extending the undocumented final night", () => { + expect(zcodeUsageSchedule(CAMPAIGN_START - 1).campaign).toBe(false); + expect(zcodeUsageSchedule(CAMPAIGN_START).campaign).toBe(true); + expect(at("2026-09-20T23:30:00").flashEnd).toBe(CAMPAIGN_END); + expect(zcodeUsageSchedule(CAMPAIGN_END - 1).flashActive).toBe(true); + expect(zcodeUsageSchedule(CAMPAIGN_END).flashActive).toBe(false); + expect(at("2027-09-11T23:30:00").campaign).toBe(false); + expect(() => zcodeUsageSchedule(NaN)).toThrow(); + }); + test("local rendering converts date rollover and DST without changing eligibility", () => { + const instant = Date.parse("2026-09-11T23:00:00+08:00"); + expect(formatZcodeLocalTime(instant, "en-GB", "America/Argentina/Buenos_Aires")).toContain("12:00"); + expect(formatZcodeLocalTime(instant, "en-GB", "America/Los_Angeles")).toContain("08:00"); + expect(formatZcodeLocalTime(Date.parse("2026-11-01T09:00:00Z"), "en-GB", "America/Los_Angeles")).toContain("01:00"); + expect(formatZcodeLocalTime(Date.parse("2026-09-11T09:00:00+08:00"), "en-GB", "America/Argentina/Buenos_Aires")).toContain("10 Sept"); + }); + test("never treats provider names, general API or lookalike endpoints as ZCode", () => { + expect(zcodeUsageScope({ adapter: "zcode", baseUrl: "" })).toBe("zcode"); + expect(zcodeUsageScope({ adapter: "zcode", baseUrl: "", disabled: true })).toBeUndefined(); + expect(zcodeUsageScope({ adapter: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4" })).toBe("coding-plan"); + for (const baseUrl of ["https://api.z.ai/api/paas/v4", "https://api.z.ai.evil/api/coding/paas/v4", "http://api.z.ai/api/coding/paas/v4", "https://api.z.ai@evil/api/coding/paas/v4"]) + expect(zcodeUsageScope({ adapter: "openai", baseUrl })).toBeUndefined(); + }); + test("free window is conditional, not a verified balance or billing result", () => { + const original = Date.now; + Date.now = () => Date.parse("2026-09-11T23:30:00+08:00"); + try { + const render = (viaZcode: boolean) => renderToStaticMarkup(createElement(LanguageProvider, null, createElement(ZcodeUsageNotices, { viaZcode }))); + expect(render(true)).toContain("free window active"); + expect(render(true)).toContain("Balance and eligibility are not verified"); + expect(render(false)).not.toContain("free window active"); + Date.now = () => CAMPAIGN_END; + expect(render(true)).not.toContain("free window active"); + expect(render(true)).not.toContain("Next GLM-5.3-Flash free window"); + } finally { Date.now = original; } + }); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c65f7c378b..73377976d2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1412,16 +1412,22 @@ "yaml-fragment-source.test.ts": "config", "z-fulfill.test.ts": "images", "z-handler-activation.test.ts": "images", + "zcode-adapter.test.ts": "providers", "zcode-client.test.ts": "providers", + "zcode-desktop-routes.test.ts": "providers", + "zcode-desktop.test.ts": "providers", + "zcode-routing.test.ts": "providers", + "zcode-transport.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", - "usage-log-ws-stage.test.ts": "usage", +"usage-log-ws-stage.test.ts": "usage", "main-device-reauth.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration", - "main-device-reauth-ui.test.ts": "gui" + "main-device-reauth-ui.test.ts": "gui", + "zcode-quota.test.ts": "providers" }, "migrated": [ "adapters", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index b743136788..b098d918b8 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -22,6 +22,12 @@ export interface IncomingMeta { } export interface ProviderAdapter { + /** False for native agents whose accepted turns may have filesystem side effects. */ + replaySafe?: boolean; + /** False when all inference must stay inside the provider's own agent runtime. */ + allowExternalSidecars?: boolean; + /** Explicit exception for input-image description only; native tools still belong to the agent. */ + allowVisionSidecar?: boolean; name: string; /** diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index f9398b4fa8..8bb86bfce1 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -1,3 +1,4 @@ +import { createZcodeAdapter } from "./zcode/adapter"; import { createAnthropicAdapter } from "./anthropic"; import { createAzureAdapter } from "./azure"; import type { ProviderAdapter } from "./base"; @@ -33,6 +34,7 @@ export interface AdapterFactoryContext { } export type AdapterWire = + | "zcode" | "codebuddy" | "command-code" | "openai-chat" @@ -45,6 +47,7 @@ export type AdapterWire = | "devin"; export type AdapterMutationContract = + | "agent-owned-with-explicit-opt-in" | "codex-owned" | "codex-owned-with-gated-native-fallback"; @@ -68,6 +71,11 @@ type InheritedAdapterDefinition = { type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition; export const ADAPTER_REGISTRY = { + zcode: { + wire: "zcode", + mutation: "agent-owned-with-explicit-opt-in", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createZcodeAdapter(provider), + }, codebuddy: { wire: "codebuddy", mutation: "codex-owned", diff --git a/src/adapters/zcode/account-runtime.ts b/src/adapters/zcode/account-runtime.ts new file mode 100644 index 0000000000..e92716616a --- /dev/null +++ b/src/adapters/zcode/account-runtime.ts @@ -0,0 +1,37 @@ +import { readAccount, accountProfile } from "./accounts"; +import { desktopAccountBusy, desktopStatus, waitForDesktopAccountIdle } from "./desktop"; +import { runNativeOAuth } from "./native-oauth"; + +const refreshes = new Map>(); +const refreshed = new Map(); +export const accountRuntimeBusy = (id: string) => refreshes.has(id) || desktopAccountBusy(id); +export const invalidateAccountRefresh = (id: string) => refreshed.delete(id); +/** Refresh belongs to the official host, never a proxy OAuth/API implementation. */ +export async function refreshAccount(id: string): Promise { + if ((refreshed.get(id) ?? 0) > Date.now()) return false; + const pending = refreshes.get(id); if (pending) return pending; + const promise = (async () => { + // Reserving `refreshes` before this await prevents another queued turn from starting a + // child. An already-running turn keeps the profile immutable until its child closes. + await waitForDesktopAccountIdle(id); + const account = readAccount(id); + if (!account.subjectHash) throw new Error("account_login_required"); + const status = desktopStatus(id); + if (!status.connected) throw new Error("account_login_required"); + let identity: string | undefined; + let nativeError: string | undefined; + try { await runNativeOAuth({ runtime: status.runtime, profileHome: accountProfile(id), mode: "refresh", + expectedSubjectHash: account.subjectHash, signal: AbortSignal.timeout(30_000), onEvent: event => { + if (event.type === "authenticated") identity = event.subjectHash; + if (event.type === "error") nativeError = event.code; + } }); } catch (error) { + if (nativeError === "account_identity_mismatch") throw new Error(nativeError); + throw error; + } + if (identity !== account.subjectHash) throw new Error("account_identity_mismatch"); + refreshed.set(id, Date.now() + 60_000); + return true; + })(); + refreshes.set(id, promise); + try { return await promise; } finally { refreshes.delete(id); } +} diff --git a/src/adapters/zcode/accounts.ts b/src/adapters/zcode/accounts.ts new file mode 100644 index 0000000000..4e546d5ad6 --- /dev/null +++ b/src/adapters/zcode/accounts.ts @@ -0,0 +1,78 @@ +import { constants, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { getConfigDir } from "../../config/paths"; + +export interface ZcodeAccount { id: string; label: string; subjectHash?: string; draftFor?: string; pending?: boolean } +const ACCOUNT_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +// Normal operation has at most 20 saved accounts plus eight live OAuth jobs. Keep enough room to +// recover older orphaned drafts without turning a user-writable directory into unbounded sync IO. +const MAX_ACCOUNT_DIRECTORIES = 256; +export function accountRoot(id: string): string { + if (!ACCOUNT_ID.test(id)) throw new Error("account_invalid"); + return join(getConfigDir(), "zcode-accounts", id); +} +export const accountProfile = (id: string) => join(accountRoot(id), "profile"); +export function readAccount(id: string): ZcodeAccount { + const fd = openSync(join(accountRoot(id), "account.json"), constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const st = fstatSync(fd); + if (!st.isFile() || st.size > 4096 || (st.mode & 0o077) || st.uid !== process.getuid?.()) throw new Error(); + const value = JSON.parse(readFileSync(fd, "utf8")); + if (value.id !== id || typeof value.label !== "string" || value.label.length > 80 + || (value.subjectHash !== undefined && !/^[a-f0-9]{64}$/.test(value.subjectHash)) + || (value.draftFor !== undefined && !ACCOUNT_ID.test(value.draftFor)) + || (value.pending !== undefined && value.pending !== true)) throw new Error(); + return { id, label: value.label, ...(value.subjectHash ? { subjectHash: value.subjectHash } : {}), + ...(value.draftFor ? { draftFor: value.draftFor } : {}), ...(value.pending ? { pending: true } : {}) }; + } finally { closeSync(fd); } +} +export function writeAccount(account: ZcodeAccount): void { + const dir = accountRoot(account.id); + const label = account.label.trim(); + if (!label || label.length > 80 || /[\x00-\x1f]/.test(label) + || (account.subjectHash !== undefined && !/^[a-f0-9]{64}$/.test(account.subjectHash)) + || (account.draftFor !== undefined && !ACCOUNT_ID.test(account.draftFor)) + || (account.pending !== undefined && account.pending !== true)) throw new Error("account_invalid"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const temp = join(dir, randomUUID() + ".tmp"); + writeFileSync(temp, JSON.stringify({ ...account, label }), { mode: 0o600, flag: "wx" }); + renameSync(temp, join(dir, "account.json")); +} +function storedAccountIds(): string[] { + const dir = join(getConfigDir(), "zcode-accounts"); + if (!existsSync(dir)) return []; + const ids = readdirSync(dir).filter(id => ACCOUNT_ID.test(id)); + if (ids.length > MAX_ACCOUNT_DIRECTORIES) throw new Error("account_invalid"); + return ids; +} +function storedAccounts(): ZcodeAccount[] { + // Read every bounded, syntactically valid account directory before applying visibility or + // capacity rules. Slicing directory entries first can omit a real account when legacy/orphaned + // drafts sort ahead of it, which makes duplicate and account-limit checks order-dependent. + return storedAccountIds().flatMap(id => { + try { return [readAccount(id)]; } catch { return []; } + }); +} +export function listAccounts(): ZcodeAccount[] { + return storedAccounts().filter(account => !account.draftFor && !account.pending); +} +/** Remove hidden OAuth profiles whose owning in-memory job vanished after a process restart. */ +export function reconcileAccountDrafts(activeDraftIds: ReadonlySet): void { + for (const id of storedAccountIds()) { + if (activeDraftIds.has(id)) continue; + try { const account = readAccount(id); if (account.draftFor || account.pending) removeAccountFiles(id); } + catch { /* Invalid state is never deleted implicitly. */ } + } +} +export function allocateAccount(label: string, replaceId?: string): ZcodeAccount { + if (replaceId) readAccount(replaceId); + // A live standalone draft reserves its eventual slot without becoming a visible saved account. + // Restart reconciliation removes abandoned reservations before management routes allocate again. + if (!replaceId && storedAccounts().filter(account => !account.draftFor).length >= 20) throw new Error("account_limit"); + const account = { id: randomUUID(), label, ...(replaceId ? { draftFor: replaceId } : { pending: true as const }) }; + writeAccount(account); + mkdirSync(accountProfile(account.id), { recursive: true, mode: 0o700 }); + return account; +} +export function removeAccountFiles(id: string): void { rmSync(accountRoot(id), { recursive: true, force: true }); } diff --git a/src/adapters/zcode/adapter.ts b/src/adapters/zcode/adapter.ts new file mode 100644 index 0000000000..0206f30e1f --- /dev/null +++ b/src/adapters/zcode/adapter.ts @@ -0,0 +1,364 @@ +import { refreshAccount } from "./account-runtime"; +import { createHash } from "node:crypto"; +import type { OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { ProviderAdapter } from "../base"; +import { ZcodeClient } from "./client"; +import { loadZcodeSettings, readZcodeModels, record, type JsonObject, type ZcodeSettings } from "./settings"; +import { zcodeThoughtLevel } from "./reasoning"; + +type Client = Pick; +const MAX_ZCODE_INPUT_CHARS = 200_000; +const MAX_ZCODE_PROTOCOL_LINE_CHARS = 1024 * 1024; +const MAX_ZCODE_SESSION_ID = `sess_${"x".repeat(80)}`; +const HISTORY_TRUNCATED = "[Earlier OpenCodex conversation history truncated to fit the ZCode bridge.]"; +const CANCELLED_BEFORE_DISPATCH = "ZCode request cancelled before dispatch."; +const HOST_EXECUTION_POLICY = "[OpenCodex bridge capability: the operator approved host execution for this managed Desktop connection, and native Bash is configured through ZCode's official hook to use the OpenCodex service user's operating-system permissions. ZCode file tools remain workspace-scoped, so use Bash for paths outside the configured workspace and do not report a host path missing until Bash has checked it.]"; +const HOST_EXECUTION_REMINDER = "[OpenCodex bridge reminder: use Bash for paths outside the configured workspace; its managed host-execution setting is applied automatically.]"; +const SAFE_ACCOUNT_REFRESH_ERRORS = new Set(["account_login_required", "account_identity_mismatch", "native_oauth_failed"]); +export interface ZcodeAdapterDeps { + settings?: () => ZcodeSettings; + client?: (settings: ZcodeSettings) => Client; + // True is the explicit fence that permits a persisted refresh to advance settings.scope. + refreshAccount?: (id: string) => Promise; + timeoutMs?: number; +} + +// ZCode persists model selection in its settings file: serialize the whole profile, not just a +// session. Each turn owns a child; completing/cancelling it cannot kill another conversation. +const locks = new Map>(); +const sessions = new Map(); +const reservationsByScope = new Map(); +let reservations = 0; +const MAX_RESERVATIONS = 32; +const MAX_RESERVATIONS_PER_SCOPE = 24; +/** Stop only this caller's wait; the official refresh may be shared by another request. */ +function waitForSharedRefresh(work: Promise, signal?: AbortSignal): Promise { + if (!signal) return work; + if (signal.aborted) { void work.catch(() => {}); return Promise.reject(new Error(CANCELLED_BEFORE_DISPATCH)); } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error(CANCELLED_BEFORE_DISPATCH)); + signal.addEventListener("abort", onAbort, { once: true }); + work.then( + value => { signal.removeEventListener("abort", onAbort); resolve(value); }, + error => { signal.removeEventListener("abort", onAbort); reject(error); }, + ); + }); +} +async function lock(key: string, signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) throw new Error(CANCELLED_BEFORE_DISPATCH); + const scopeReservations = reservationsByScope.get(key) ?? 0; + if (reservations >= MAX_RESERVATIONS || scopeReservations >= MAX_RESERVATIONS_PER_SCOPE) { + throw new Error("ZCode profile queue is full."); + } + reservations++; + reservationsByScope.set(key, scopeReservations + 1); + const previous = locks.get(key) ?? Promise.resolve(); + let unlock!: () => void; + const current = new Promise(resolve => { unlock = resolve; }); + const tail = previous.then(() => current); + locks.set(key, tail); + let released = false; + const release = () => { + if (released) return; + released = true; reservations--; + const remaining = (reservationsByScope.get(key) ?? 1) - 1; + if (remaining > 0) reservationsByScope.set(key, remaining); + else reservationsByScope.delete(key); + unlock(); + void tail.then(() => { if (locks.get(key) === tail) locks.delete(key); }); + }; + let onAbort = () => {}; + try { + await Promise.race([previous, new Promise((_, reject) => { + onAbort = () => reject(new Error(CANCELLED_BEFORE_DISPATCH)); + signal?.addEventListener("abort", onAbort, { once: true }); + })]); + if (signal?.aborted) throw new Error(CANCELLED_BEFORE_DISPATCH); + } catch (error) { release(); throw error; } + finally { signal?.removeEventListener("abort", onAbort); } + return release; +} + +function textualContent(content: string | OcxContentPart[]): string { + if (typeof content === "string") return content; + return content.map(part => { + if (part.type === "text") return part.text; + if (part.type === "image") { + throw new Error("ZCode image input was not converted to text before native dispatch."); + } + throw new Error("ZCode video input is unsupported by the native Desktop bridge."); + }).join("\n"); +} + +function safeLabel(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, 128) || "unknown"; +} + +/** + * Project Responses history onto the app-server's text-only `session/send` wire. + * + * Starting a ZCode session after another provider means the replay can legitimately contain + * assistant reasoning, tool calls and their results. Those are history, not new client tool + * instructions: omit hidden reasoning, render calls as inert labels, and keep textual results. + * Images must already have been described/stripped by the shared vision pipeline. + */ +function transcriptLine(message: OcxMessage): string | undefined { + if (message.role === "assistant") { + const content = message.content.flatMap(part => { + if (part.type === "text") return part.text ? [part.text] : []; + if (part.type === "toolCall") return [`[historical tool call: ${safeLabel(part.name)}]`]; + // Never disclose or replay another provider's hidden chain of thought. + return []; + }).join("\n"); + return content.trim() ? `assistant: ${content}` : undefined; + } + if (message.role === "toolResult") { + const content = textualContent(message.content); + const status = message.isError ? " error" : " result"; + return `tool ${safeLabel(message.toolName)}${status}: ${content}`; + } + return `${message.role}: ${textualContent(message.content)}`; +} + +function transcriptLines(messages: OcxMessage[]): string[] { + return messages.map(transcriptLine).filter((line): line is string => line !== undefined && line.trim().length > 0); +} + +function joinPrompt(lines: string[]): string { + return lines.join("\n\n"); +} + +/** Retain the current turn and newest complete history entries within the native bridge cap. */ +function boundedPrompt(system: string[], history: string[], current: string[]): string { + const full = joinPrompt([...system, ...history, ...current]); + if (full.length <= MAX_ZCODE_INPUT_CHARS) return full; + + const fixed = [...system, HISTORY_TRUNCATED, ...current]; + const fixedPrompt = joinPrompt(fixed); + if (fixedPrompt.length > MAX_ZCODE_INPUT_CHARS) { + throw new Error("ZCode current input and system instructions exceed the native bridge limit."); + } + + const retained: string[] = []; + let length = fixedPrompt.length; + for (let index = history.length - 1; index >= 0; index--) { + const line = history[index]!; + // Inserting one complete history line adds its content and one prompt separator. + const added = line.length + 2; + if (length + added > MAX_ZCODE_INPUT_CHARS) break; + retained.unshift(line); + length += added; + } + return joinPrompt([...system, HISTORY_TRUNCATED, ...retained, ...current]); +} + +function textInput(parsed: OcxParsedRequest, resumed: boolean, hostExecution = false): string { + const messages = parsed.context.messages; + const continuation = parsed._continuationConversationMessageIndex; + const boundary = continuation ?? Math.max(0, messages.length - 1); + const history = resumed ? [] : transcriptLines(messages.slice(0, boundary)); + const requestSystem = (parsed.context.systemPrompt ?? []).filter(line => line.trim().length > 0); + const requestCurrent = transcriptLines(messages.slice(resumed ? continuation ?? -1 : boundary)); + if (!joinPrompt([...requestSystem, ...history, ...requestCurrent]).trim()) throw new Error("ZCode input is empty."); + const current = [...requestCurrent, + ...(hostExecution ? [HOST_EXECUTION_REMINDER] : [])]; + const system = [...(hostExecution ? [HOST_EXECUTION_POLICY] : []), + ...requestSystem]; + const prompt = boundedPrompt(system, history, current); + return prompt; +} + +/** The managed bootstrap rejects oversized NDJSON before parsing; fail before any native dispatch. */ +function assertSessionSendFits(content: string, modelParams: JsonObject): void { + let length = Infinity; + try { + length = JSON.stringify({ + id: Number.MAX_SAFE_INTEGER, + method: "session/send", + params: { sessionId: MAX_ZCODE_SESSION_ID, content, ...modelParams }, + }).length; + } catch { /* normalized below without exposing model/profile material */ } + if (length > MAX_ZCODE_PROTOCOL_LINE_CHARS) { + throw new Error("ZCode input exceeds the native bridge serialization limit."); + } +} + +export function createZcodeAdapter(provider: OcxProviderConfig, deps: ZcodeAdapterDeps = {}): ProviderAdapter { + return { + name: "zcode", + replaySafe: false, + allowExternalSidecars: false, + allowVisionSidecar: true, + buildRequest() { throw new Error("ZCode uses its local app-server transport, not HTTP."); }, + async *parseStream() { yield { type: "error", message: "ZCode requires runTurn.", retryable: false }; }, + async runTurn(parsed, incoming, emit) { + let client: Client | undefined; + let release: (() => void) | undefined; + let timer: ReturnType | undefined; + let heartbeat: ReturnType | undefined; + let sent = false; + let sessionId: string | undefined; + let sessionKey: string | undefined; + let stop = () => {}; + const cancelled = () => stop(); + try { + if (incoming.abortSignal?.aborted) throw new Error(CANCELLED_BEFORE_DISPATCH); + const refreshSavedAccount = async (): Promise => { + if (!provider.zcodeAccountId || (deps.settings && !deps.refreshAccount)) return false; + try { + const refresh = (deps.refreshAccount ?? refreshAccount)(provider.zcodeAccountId); + return (await waitForSharedRefresh(refresh, incoming.abortSignal)) === true; + } + catch (error) { + if (incoming.abortSignal?.aborted) throw new Error(CANCELLED_BEFORE_DISPATCH); + const code = error instanceof Error && SAFE_ACCOUNT_REFRESH_ERRORS.has(error.message) + ? error.message : "account_refresh_failed"; + throw new Error(code); + } + }; + await refreshSavedAccount(); + let settings: ZcodeSettings; + try { settings = (deps.settings ?? (() => loadZcodeSettings(process.env, provider.zcodeAccountId)))(); } + catch { throw new Error("ZCode native execution is unavailable. Configure the isolated launcher, home, workspace and explicit opt-in."); } + if (provider.authMode !== "local") throw new Error("ZCode requires local authentication mode; log in using the isolated ZCode client."); + if (!readZcodeModels(settings).some(item => item.id === parsed.modelId)) { + throw new Error("ZCode model is unavailable in the isolated settings."); + } + if (parsed.options.toolChoice && parsed.options.toolChoice !== "auto") { + throw new Error("ZCode owns its tools and does not support client tool_choice constraints."); + } + release = await lock(settings.lockKey, incoming.abortSignal); + // Reject a reconnect/revocation that happened while waiting. Only the refresh owned below + // may advance the generation once this turn holds the physical-profile lock. + const queued = (deps.settings ?? (() => loadZcodeSettings(process.env, provider.zcodeAccountId)))(); + if (queued.lockKey !== settings.lockKey || queued.scope !== settings.scope) { + throw new Error("ZCode connection or profile changed while this turn was queued."); + } + // The refresh cache can expire while this turn waits behind a long native child. Refresh + // again under the stable profile lock, then reload the connection before dispatch. + const refreshedAfterQueue = await refreshSavedAccount(); + const current = (deps.settings ?? (() => loadZcodeSettings(process.env, provider.zcodeAccountId)))(); + if (current.lockKey !== settings.lockKey + || (!refreshedAfterQueue && current.scope !== queued.scope)) { + throw new Error("ZCode connection or profile changed while this turn was queued."); + } + settings = current; + const model = readZcodeModels(settings).find(item => item.id === parsed.modelId); + if (!model) throw new Error("ZCode model is unavailable in the isolated settings."); + const scope = createHash("sha256").update(JSON.stringify([ + settings.scope, provider.baseUrl, parsed._reasoningReplayScope?.current?.providerName, + parsed._cursorIdentityScope, parsed.modelId, + ])).digest("hex"); + const compaction = parsed._compactionRequest === true; + sessionKey = !compaction && parsed._clientThreadId && !parsed._cursorIsolateConversation + ? createHash("sha256").update(scope + parsed._clientThreadId).digest("hex") : undefined; + const previous = compaction ? {} : record(parsed._providerContinuation?.zcode); + sessionId = previous.scope === scope && typeof previous.sessionId === "string" + && /^sess_[a-zA-Z0-9-]{1,80}$/.test(previous.sessionId) ? previous.sessionId + : sessionKey ? sessions.get(sessionKey) : undefined; + const content = textInput(parsed, Boolean(sessionId), settings.hostExecution === true); + // The Desktop bootstrap resolves credentials inside the official runtime (and the optional + // sandbox when enabled). The parent only sends + // public model identity; it never receives Desktop API keys or synthesizes vendor auth. + const modelParams: JsonObject = settings.desktopModels + ? { _zcodeModel: { providerId: model.providerId, modelId: model.modelId } } + : { runtimeModel: model.runtimeModel }; + assertSessionSendFits(content, modelParams); + client = (deps.client ?? (s => new ZcodeClient(s)))(settings); + const active = client; + const thoughtLevel = zcodeThoughtLevel(model.modelId, parsed.options.reasoning); + const thoughtParams = thoughtLevel ? { thoughtLevel } : {}; + const toolParams: JsonObject = compaction + ? { mcpServers: [], toolAllowlist: [] } + : { mcpServers: [], toolDenylist: ["Task", "TaskOutput", "TaskStop"] }; + const controller = Promise.withResolvers(); + // Attach a handler immediately: failures can happen during session materialization. + void controller.promise.catch(() => {}); + stop = () => { controller.reject(new Error("ZCode turn cancelled or timed out.")); void active.close(); }; + incoming.abortSignal?.addEventListener("abort", cancelled, { once: true }); + if (incoming.abortSignal?.aborted) throw new Error(CANCELLED_BEFORE_DISPATCH); + timer = setTimeout(stop, deps.timeoutMs ?? 300_000); + heartbeat = setInterval(() => emit({ type: "heartbeat" }), 5_000); + active.onFailure = error => controller.reject(error); + let textSeen = false; + active.onEvent = message => { + const params = record(message.params); + if (message.method !== "session/event") return; + if (typeof params.sessionId !== "string" || params.sessionId !== sessionId) return; + const payload = record(params.payload); + if (params.type === "model.streaming") { + if ((payload.kind === "text_delta" || payload.kind === "text_start") && typeof payload.delta === "string") { + textSeen ||= payload.delta.length > 0; + emit({ type: "text_delta", text: payload.delta, phase: "final_answer" }); + } else if ((payload.kind === "reasoning_delta" || payload.kind === "reasoning_start") && typeof payload.delta === "string") { + emit({ type: "thinking_delta", thinking: payload.delta }); + } + } else if (params.type === "tool.updated") { + if (compaction) { + controller.reject(new Error("ZCode compaction attempted native tool execution.")); + void active.close(); + return; + } + // Informational only: no tool_call_* event can cause a client to repeat native work. + if (payload.kind === "started" || payload.kind === "result") emit({ type: "text_delta", + text: payload.kind === "started" ? "\n[ZCode: native tool started]\n" : "\n[ZCode: native tool finished]\n", + phase: "commentary" }); + } else if (params.type === "turn.completed") { + if (!textSeen && typeof payload.response === "string" && payload.response.length > 0) { + textSeen = true; + emit({ type: "text_delta", text: payload.response, phase: "final_answer" }); + } + if (textSeen) controller.resolve(); + else controller.reject(new Error("ZCode completed without a model answer.")); + } else if (params.type === "turn.failed") { + controller.reject(new Error("ZCode agent turn failed. Inspect the interactive ZCode client for authentication, quota or model errors.")); + } + }; + if (sessionId) { + await active.request("session/resume", { sessionId, ...modelParams, ...thoughtParams, ...toolParams }); + } else { + const result = await active.request("session/create", { + workspace: { workspacePath: settings.workspace, workspaceKey: settings.workspace }, + mode: settings.nativePermissionMode ?? "edit", ...modelParams, ...thoughtParams, titleGenerationEnabled: false, + ...toolParams, + }); + const id = record(result.session).sessionId; + if (typeof id !== "string" || !/^sess_[a-zA-Z0-9-]{1,80}$/.test(id)) throw new Error("ZCode returned an invalid session identity."); + sessionId = id; + } + await active.request("session/subscribe", { sessionId, deliveryKind: "desktop-continuous" }); + if (incoming.abortSignal?.aborted) throw new Error(CANCELLED_BEFORE_DISPATCH); + // Commit visible output before dispatch, so streaming combo routing cannot replay an + // accepted task that already changed files. Post-send failure is non-retryable incomplete. + emit({ type: "text_delta", text: compaction + ? "[ZCode: summarizing without native tools]\n" + : settings.hostExecution + ? "[ZCode: running native tools with host-user access]\n" + : "[ZCode: running native tools in the configured launcher]\n", phase: "commentary" }); + sent = true; + await active.request("session/send", { sessionId, content, ...modelParams }); + await controller.promise; + if (sessionKey) { + if (sessions.size >= 128) sessions.delete(sessions.keys().next().value!); + sessions.set(sessionKey, sessionId); + } + emit({ type: "done", endTurn: true, + ...(compaction ? {} : { providerState: { zcode: { sessionId, scope } } }) }); + } catch (error) { + if (sessionKey) sessions.delete(sessionKey); + const message = error instanceof Error ? error.message : "ZCode bridge failed."; + if (sent) emit({ type: "incomplete", reason: "zcode_agent_interrupted", message, + retryable: false, endTurn: true }); + else emit({ type: "error", message, retryable: false, status: 400 }); + } finally { + incoming.abortSignal?.removeEventListener("abort", cancelled); + clearTimeout(timer); clearInterval(heartbeat); + if (client) { + if (sent && sessionId) await client.request("session/stop", { sessionId }, 500).catch(() => {}); + await client.close(); + } + release?.(); + } + }, + }; +} diff --git a/src/adapters/zcode/client.ts b/src/adapters/zcode/client.ts new file mode 100644 index 0000000000..e14e9b3bad --- /dev/null +++ b/src/adapters/zcode/client.ts @@ -0,0 +1,205 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { homedir } from "node:os"; +import { readJsonLines } from "../coding-agent/protocol"; +import { record, type JsonObject, type ZcodeSettings } from "./settings"; +import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"; + +export type ZcodeSpawn = typeof spawn; +type Pending = { resolve: (value: JsonObject) => void; reject: (error: Error) => void; timer: ReturnType }; +const HOST_TOOL_ENV = new Set([ + "ALL_PROXY", "all_proxy", "BROWSER", "COLORTERM", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", + "GIT_ASKPASS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_SSH", "GIT_SSH_COMMAND", + "GH_CONFIG_DIR", "GPG_AGENT_INFO", "GNUPGHOME", "GPG_TTY", "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", "LANG", "LC_ALL", "LOGNAME", "NODE_EXTRA_CA_CERTS", "NO_COLOR", + "NO_PROXY", "no_proxy", "PATH", "SHELL", "SSH_AGENT_PID", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE", + "SSH_AUTH_SOCK", "SSL_CERT_DIR", "SSL_CERT_FILE", "TERM", "TMPDIR", "TZ", "USER", "WAYLAND_DISPLAY", + "XAUTHORITY", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", +]); + +/** Host native tools need the user's normal config/agent sockets, but never unrelated provider secrets. */ +export function zcodeChildEnvironment(settings: ZcodeSettings, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + if (!settings.hostExecution) return { HOME: settings.home, PATH: source.PATH ?? "/usr/bin:/bin", + XDG_CONFIG_HOME: `${settings.home}/.config`, XDG_CACHE_HOME: `${settings.home}/.cache` }; + const result: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if (value !== undefined && (HOST_TOOL_ENV.has(key) || key.startsWith("LC_"))) result[key] = value; + } + result.HOME = homedir(); + result.PATH = source.PATH ?? "/usr/bin:/bin"; + result.ZCODE_DATA_BASE_DIR = settings.home; + return result; +} +const desktopClients = new Set(); +const accountIdleWaiters = new Map void>>(); +export const hasZcodeAccountClients = (id: string) => [...desktopClients].some(client => client.accountId === id); +function notifyAccountIdle(id?: string): void { + if (!id || hasZcodeAccountClients(id)) return; + const waiters = accountIdleWaiters.get(id); + accountIdleWaiters.delete(id); + for (const resolve of waiters ?? []) resolve(); +} +/** Wait for every official app-server child using this saved profile to close. */ +export function waitForZcodeAccountClients(id: string, signal?: AbortSignal): Promise { + if (!hasZcodeAccountClients(id)) return Promise.resolve(); + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const waiters = accountIdleWaiters.get(id) ?? new Set<() => void>(); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; signal?.removeEventListener("abort", abort); + waiters.delete(finish); if (!waiters.size) accountIdleWaiters.delete(id); + resolve(); + }; + const abort = () => { + if (settled) return; + settled = true; waiters.delete(finish); if (!waiters.size) accountIdleWaiters.delete(id); + reject(signal?.reason); + }; + waiters.add(finish); accountIdleWaiters.set(id, waiters); + signal?.addEventListener("abort", abort, { once: true }); + // Registration and the second check are synchronous, closing the check/subscribe race. + if (!hasZcodeAccountClients(id)) finish(); + }); +} +export async function closeZcodeDesktopClients(): Promise { + await Promise.all([...desktopClients].filter(client => !client.accountId).map(client => client.disconnect())); +} + +/** ZCode 0.16.5 uses request/result NDJSON, without a jsonrpc field. */ +export class ZcodeClient { + private child: ChildProcessWithoutNullStreams; + private pending = new Map(); + private nextId = 0; + private closed = false; + private terminalError?: Error; + private reading: Promise; + private exited: Promise; + private closePromise?: Promise; + private detachShutdown: () => void; + private closeGraceMs: number; + onEvent: (message: JsonObject) => void = () => {}; + onFailure: (error: Error) => void = () => {}; + + readonly accountId?: string; + constructor(settings: ZcodeSettings, spawnProcess: ZcodeSpawn = spawn) { + this.accountId = settings.accountId; + this.closeGraceMs = settings.hostExecution ? 1_000 : 500; + const [command, ...args] = settings.command; + this.child = spawnProcess(command!, [...args, "app-server"], { + cwd: settings.home, shell: false, stdio: ["pipe", "pipe", "pipe"], + env: zcodeChildEnvironment(settings), + }) as ChildProcessWithoutNullStreams; + this.exited = new Promise(resolve => { + this.child.once("exit", () => resolve()); + // A spawn failure has no process to fence. Later child errors (for example a failed + // signal) must not masquerade as exit while the official runtime can still be alive. + this.child.once("error", () => { if (this.child.pid === undefined) resolve(); }); + }); + // Discard vendor diagnostics: stderr may contain account or request data. + this.child.stderr.resume(); + this.child.stdin.on("error", () => this.fail(new Error("ZCode protocol input closed."))); + this.child.once("error", () => this.fail(new Error("ZCode launcher could not start."))); + this.child.once("exit", () => this.fail(new Error("ZCode app server exited before completing the turn."))); + this.reading = this.read(); + this.detachShutdown = registerOptionalShutdownHook(`zcode-${crypto.randomUUID()}`, () => { void this.close(); }); + if (settings.desktopModels) desktopClients.add(this); + } + + async disconnect(): Promise { + this.fail(new Error("ZCode Desktop was disconnected or reconnected.")); + await this.close(); + } + + private fail(error: Error): void { + if (this.closed || this.terminalError) return; + this.terminalError = error; + for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(error); } + this.pending.clear(); + this.onFailure(error); + } + + private write(frame: JsonObject): void { + if (this.closed || this.terminalError) throw this.terminalError ?? new Error("ZCode client is closed."); + this.child.stdin.write(JSON.stringify(frame) + "\n"); + } + + private async read(): Promise { + try { + for await (const message of readJsonLines(this.child.stdout)) { + if (typeof message.method === "string") { + if (message.id !== undefined) { + if (message.method === "session/requestRuntimePreferences") { + this.write({ id: message.id, result: { + nativeSearchEnhancementsEnabled: false, memoryEnabled: false, + askUserQuestionAutoResolutionEnabled: false, modelContextBudgetStrategy: "preflight-v1", + } }); + } else if (message.method === "interaction/requestPermission") { + // Permission decisions cannot be safely represented by all OpenCodex clients. + // Native non-interactive actions still run under ZCode's edit mode and any operator/harness sandbox. + this.write({ id: message.id, result: { decision: "deny", reason: "Interactive approval is unavailable through this bridge." } }); + } else if (message.method === "interaction/requestUserInput") { + this.write({ id: message.id, result: { action: "cancel" } }); + this.fail(new Error("ZCode requires user input; continue in the interactive ZCode client.")); + } else { + this.write({ id: message.id, error: { code: -32601, message: "Unsupported ZCode client request." } }); + } + } else this.onEvent(message); + } else if (typeof message.id === "number") { + const item = this.pending.get(message.id); + if (!item) continue; + clearTimeout(item.timer); + this.pending.delete(message.id); + // Never forward raw error messages/stacks: the vendor includes prompts and paths. + if (message.error) item.reject(new Error("ZCode rejected the protocol request. Check the isolated login and model configuration.")); + else item.resolve(record(message.result)); + } + } + this.fail(new Error("ZCode protocol output closed.")); + } catch { + this.fail(new Error("ZCode returned an invalid or oversized protocol stream.")); + } + } + + request(method: string, params: JsonObject, timeoutMs = 30_000): Promise { + if (this.terminalError || this.closed) return Promise.reject(this.terminalError ?? new Error("ZCode client is closed.")); + const id = ++this.nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error("ZCode protocol request timed out.")); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { this.write({ id, method, params }); } + catch { clearTimeout(timer); this.pending.delete(id); reject(new Error("ZCode protocol write failed.")); } + }); + } + + close(): Promise { + this.closePromise ??= this.performClose(); + return this.closePromise; + } + + private async performClose(): Promise { + this.closed = true; + this.detachShutdown(); + for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error("ZCode client closed.")); } + this.pending.clear(); + this.child.stdin.destroy(); + try { this.child.kill("SIGTERM"); } catch { /* launcher already exited */ } + const killTimer = setTimeout(() => { + try { this.child.kill("SIGKILL"); } catch { /* launcher already exited */ } + }, this.closeGraceMs); + try { + // The direct bootstrap owns descendant cleanup. Keep the account busy until that bootstrap + // really exits; timing out here would reopen a profile-mutation window while tools unwind. + await this.exited; + } finally { + clearTimeout(killTimer); + this.child.stdout.destroy(); + this.child.stderr.destroy(); + desktopClients.delete(this); + notifyAccountIdle(this.accountId); + } + } +} diff --git a/src/adapters/zcode/desktop-bootstrap.cjs b/src/adapters/zcode/desktop-bootstrap.cjs new file mode 100644 index 0000000000..457b969d6c --- /dev/null +++ b/src/adapters/zcode/desktop-bootstrap.cjs @@ -0,0 +1,171 @@ +// Runs on the host by default, or inside an explicitly enabled OS sandbox. Desktop credentials/configuration are mounted read-only. +// Only the official runtime consumes provider keys. The compatible settings file is private and +// turn-scoped; it never reaches management responses, logs or the Desktop source profile. +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); + +function hostExecutionHooks() { + return { + enabled: true, + maxOutputBytes: 2 * 1024 * 1024, + events: { + PreToolUse: [{ + matcher: "^Bash$", + hooks: [{ + type: "process", + command: process.execPath, + args: [require.resolve("./desktop-host-tool-hook.cjs")], + timeoutMs: 2_000, + }], + }], + }, + }; +} + +function normalizeDesktopConfig(input, options = {}) { + const provider = {}; + for (const [id, raw] of Object.entries(input?.provider ?? {})) { + if (!raw || typeof raw !== "object" || raw.enabled === false || id === "opencodex") continue; + // Desktop's built-in Z.AI profiles only. Do not import arbitrary proxy chains/custom providers. + if (!id.startsWith("builtin:zai")) continue; + const options = raw.options ?? {}; + if (options.apiKeyRequired !== false && !(typeof options.apiKey === "string" && options.apiKey.trim())) continue; + let url; + try { url = new URL(options.baseURL); } catch { continue; } + if (url.protocol !== "https:" || url.hostname !== "api.z.ai" || url.username || url.password) continue; + const cleanOptions = { baseURL: url.href }; + if (typeof options.apiKey === "string" && options.apiKey.trim()) cleanOptions.apiKey = options.apiKey; + if (typeof options.apiKeyRequired === "boolean") cleanOptions.apiKeyRequired = options.apiKeyRequired; + provider[id] = { + name: typeof raw.name === "string" ? raw.name : id, + kind: raw.kind === "anthropic" ? "anthropic" : "openai-compatible", + source: "custom", enabled: true, options: cleanOptions, + models: raw.models && typeof raw.models === "object" && !Array.isArray(raw.models) ? raw.models : {}, + }; + } + const config = { provider }; + const model = desktopModelCatalog(config)[0]?.id; + return { + ...config, + ...(model ? { model: { main: model, lite: model } } : {}), + // This is an official ZCode user-config hook, not a vendor-runtime patch. Managed host + // consent makes Bash deterministic even when the model omits the per-call flag. The + // optional outer Bubblewrap path never installs it and remains a hard confinement layer. + ...(options.hostExecution ? { hooks: hostExecutionHooks() } : {}), + }; +} + +function desktopModelCatalog(config) { + return Object.entries(config.provider).flatMap(([providerId, p]) => + Object.entries(p.models).filter(([id, m]) => id && !/[\x00-\x20]/.test(id) && m && typeof m === "object").slice(0, 200).map(([modelId, m]) => ({ + id: `${providerId}/${modelId}`, providerId, modelId, + label: `${p.name} / ${typeof m.name === "string" ? m.name : modelId}`.slice(0, 240), + ...(Number.isFinite(m.limit?.context) && m.limit.context > 0 ? { contextWindow: m.limit.context } : {}), + }))); +} +module.exports = { normalizeDesktopConfig, desktopModelCatalog }; +if (require.main === module) { + let temporaryHome; + let child; + let host = false; + let terminating = false; + let forceTimer; + let exitTimer; + const cleanup = () => { if (temporaryHome) fs.rmSync(temporaryHome, { recursive: true, force: true }); }; + const signalChildTree = signal => { + if (!child) return; + if (host && process.platform !== "win32" && child.pid) { + try { process.kill(-child.pid, signal); return; } catch { /* fall back to the direct child */ } + } + try { child.kill(signal); } catch { /* the child already exited */ } + }; + const terminateChildTree = () => { + if (terminating) return; + terminating = true; + try { child?.stdin.destroy(); } catch { /* already closed */ } + signalChildTree("SIGTERM"); + // The official runtime and every non-detached native tool share this process group. + // Escalate before the outer client can force-kill this bootstrap, so its exit cleanup runs. + forceTimer = setTimeout(() => signalChildTree("SIGKILL"), 250); + exitTimer = setTimeout(() => process.exit(1), 450); + }; + process.on("exit", () => { signalChildTree("SIGKILL"); cleanup(); }); + try { + const args = process.argv.slice(2); + host = args[0] === "--host"; + if (host ? args.length !== 6 || args[5] !== "app-server" : args.length !== 1 || args[0] !== "app-server") throw new Error("unsupported command"); + const path = host ? args[2] : "/desktop/config.json"; + if (fs.statSync(path).size > 4 * 1024 * 1024) throw new Error("oversized"); + const input = JSON.parse(fs.readFileSync(path, "utf8")); + const config = normalizeDesktopConfig(input, { hostExecution: host }); + let env = process.env; + let settingsPath = `${env.HOME}/.zcode/cli/config.json`; + if (host) { + // ZCode reads user config from os.homedir() and exposes no config-path CLI flag. Patch + // that lookup only in the official runtime process; process.env.HOME stays real for + // the native tools it starts. This is state separation, not filesystem confinement. + const paths = require("node:path"); + const stateHome = args[4]; + if (!paths.isAbsolute(stateHome) || stateHome.includes("\0")) throw new Error("invalid state home"); + const stableDb = paths.join(stateHome, ".zcode/cli/db"); + temporaryHome = fs.mkdtempSync(paths.join(stateHome, "turn-")); + fs.mkdirSync(paths.join(temporaryHome, ".zcode/cli"), { recursive: true, mode: 0o700 }); + fs.symlinkSync(stableDb, paths.join(temporaryHome, ".zcode/cli/db"), "dir"); + settingsPath = paths.join(temporaryHome, ".zcode/cli/config.json"); + config.storage = { dir: paths.join(stateHome, ".zcode") }; + env = { ...process.env, ZCODE_DATA_BASE_DIR: stateHome, OCX_ZCODE_RUNTIME_HOME: temporaryHome }; + } + fs.writeFileSync(settingsPath, JSON.stringify(config), { mode: 0o600, flag: "wx" }); + const childArgs = host + ? ["--require", require.resolve("./desktop-host-preload.cjs"), args[1], "app-server"] + : ["/runtime/zcode.cjs", "app-server"]; + child = spawn(host ? process.execPath : "/usr/bin/node", childArgs, { + stdio: ["pipe", "inherit", "inherit"], shell: false, env, + ...(host ? { cwd: args[3], detached: process.platform !== "win32" } : {}), + }); + const lines = require("node:readline").createInterface({ input: process.stdin }); + lines.on("line", line => { + try { + if (line.length > 1024 * 1024) throw new Error("oversized request"); + const frame = JSON.parse(line); + if (frame.method === "opencodex/desktopModels") { + const models = desktopModelCatalog(config); + process.stdout.write(JSON.stringify({ id: frame.id, result: { models } }) + "\n"); + return; + } + if (["workspace/readState", "session/create", "session/resume", "session/send"].includes(frame.method)) { + const params = frame.params ?? {}; + const ref = params._zcodeModel ?? params.model; + const providerId = ref?.providerId ?? Object.keys(config.provider)[0]; + const provider = config.provider[providerId]; + const modelId = ref?.modelId ?? Object.keys(provider?.models ?? {})[0]; + if (!provider || !Object.hasOwn(provider.models, modelId)) throw new Error("missing model"); + delete params._zcodeModel; + params.runtimeModel = { + revision: "opencodex-desktop-v1", generatedAt: Date.now(), model: { providerId, modelId }, + provider: { providerId, kind: provider.kind, label: provider.name, source: provider.source, + baseURL: provider.options.baseURL, apiKeyRequired: provider.options.apiKeyRequired, + ...(provider.options.apiKey ? { apiKey: { source: "inline", value: provider.options.apiKey } } : {}), + models: Object.entries(provider.models).map(([id, m]) => ({ modelId: id, + contextWindow: m.limit?.context, maxOutputTokens: m.limit?.output })), + }, + }; + frame.params = params; + } + child.stdin.write(JSON.stringify(frame) + "\n"); + } catch { terminateChildTree(); } + }); + lines.on("close", () => child.stdin.end()); + child.stdin.on("error", terminateChildTree); + process.on("SIGTERM", terminateChildTree); + child.on("error", () => process.exit(1)); + child.on("exit", code => { + clearTimeout(forceTimer); clearTimeout(exitTimer); + process.exit(code ?? 1); + }); + } catch { + // Never expose parser excerpts, provider keys or paths from Desktop's configuration. + process.stderr.write("ZCode Desktop configuration is unavailable or incompatible.\n"); + process.exitCode = 1; + } +} diff --git a/src/adapters/zcode/desktop-host-preload.cjs b/src/adapters/zcode/desktop-host-preload.cjs new file mode 100644 index 0000000000..91dd39a709 --- /dev/null +++ b/src/adapters/zcode/desktop-host-preload.cjs @@ -0,0 +1,27 @@ +// ZCode has no public CLI flag for selecting its user config. Keep the official +// runtime's state/config in OpenCodex's private home while leaving process.env.HOME +// untouched for the native tools that the official runtime starts. +const fs = require("node:fs"); +const { syncBuiltinESMExports } = require("node:module"); +const os = require("node:os"); +const path = require("node:path"); + +const key = "OCX_ZCODE_RUNTIME_HOME"; +const requested = process.env[key]; +delete process.env[key]; + +// A process spawned by ZCode may inherit execArgv in unusual launchers. The +// one-shot environment marker makes this preload a no-op outside the owned root. +if (requested !== undefined) { + if (!path.isAbsolute(requested) || requested.includes("\0")) { + throw new Error("ZCode private runtime home is invalid."); + } + const resolved = fs.realpathSync(requested); + const metadata = fs.lstatSync(resolved); + if (!metadata.isDirectory() || metadata.uid !== process.getuid?.() || (metadata.mode & 0o077) !== 0) { + throw new Error("ZCode private runtime home is invalid."); + } + Object.defineProperty(os, "homedir", { configurable: true, enumerable: true, + value: () => resolved, writable: true }); + syncBuiltinESMExports(); +} diff --git a/src/adapters/zcode/desktop-host-tool-hook.cjs b/src/adapters/zcode/desktop-host-tool-hook.cjs new file mode 100644 index 0000000000..ac5c88456f --- /dev/null +++ b/src/adapters/zcode/desktop-host-tool-hook.cjs @@ -0,0 +1,38 @@ +// Official ZCode PreToolUse hook installed only for an explicitly consented managed host +// connection. It changes no command text and emits no profile, credential or tool output. +function forceHostBashInput(event) { + if (!event || typeof event !== "object" || Array.isArray(event) + || event.hook_event_name !== "PreToolUse" || event.tool_name !== "Bash" + || !event.tool_input || typeof event.tool_input !== "object" || Array.isArray(event.tool_input)) { + throw new Error("invalid hook input"); + } + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + updatedInput: { ...event.tool_input, dangerouslyDisableSandbox: true }, + }, + }; +} + +module.exports = { forceHostBashInput }; + +if (require.main === module) { + let input = ""; + let size = 0; + process.stdin.on("data", chunk => { + size += chunk.length; + if (size > 1024 * 1024) { + process.stderr.write("OpenCodex host-tool policy input exceeded its safe limit.\n"); + process.exit(1); + } + input += chunk.toString("utf8"); + }); + process.stdin.on("end", () => { + try { + process.stdout.write(JSON.stringify(forceHostBashInput(JSON.parse(input))) + "\n"); + } catch { + process.stderr.write("OpenCodex host-tool policy input was invalid.\n"); + process.exitCode = 1; + } + }); +} diff --git a/src/adapters/zcode/desktop-node.ts b/src/adapters/zcode/desktop-node.ts new file mode 100644 index 0000000000..af000905c1 --- /dev/null +++ b/src/adapters/zcode/desktop-node.ts @@ -0,0 +1,56 @@ +import { spawnSync } from "node:child_process"; +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { delimiter, isAbsolute, join } from "node:path"; + +// Bridge support floor, not a claim about every historical vendor release. +// Verify the complete official workspace/readState protocol at this floor as well. +export const DESKTOP_NODE_MIN_MAJOR = 24; +const checks = new Map(); +const script = ` +try { + if (process.release.name !== 'node' || process.versions.bun || + Number(process.versions.node.split('.')[0]) < ${DESKTOP_NODE_MIN_MAJOR} || + typeof Object.hasOwn !== 'function' || typeof fetch !== 'function') process.exit(1); + const db = new (require('node:sqlite').DatabaseSync)(':memory:'); + if (db.prepare('SELECT 1 AS value').get().value !== 1) process.exit(1); + db.close(); + process.stdout.write('compatible'); +} catch { process.exit(1); } +`; + +/** Check each absolute PATH entry, rather than trusting the first node or Bun's launcher. */ +export function resolveDesktopNode(path = process.env.PATH ?? ""): string { + let found = false; + const seen = new Set(); + const deadline = Date.now() + 3_000; + for (const directory of [...new Set(path.split(delimiter))].filter(isAbsolute).slice(0, 64)) { + let node: string; + let key: string; + try { + node = realpathSync(join(directory, "node")); + if (seen.has(node)) continue; + seen.add(node); + accessSync(node, constants.X_OK); + const st = statSync(node); + if (!st.isFile()) continue; + found = true; + key = JSON.stringify([node, st.dev, st.ino, st.size, st.mtimeMs, st.ctimeMs]); + } catch { continue; } + let compatible = checks.get(key); + if (compatible === undefined) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + // No inherited NODE_OPTIONS, preload hooks, credentials or vendor stderr. + const result = spawnSync(node, ["--no-warnings", "-e", script], { + env: { PATH: "/usr/bin:/bin" }, cwd: "/", encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], timeout: Math.min(1_000, remaining), + killSignal: "SIGKILL", maxBuffer: 128, + }); + compatible = !result.error && result.status === 0 && result.stdout === "compatible"; + if (checks.size >= 64) checks.clear(); + checks.set(key, compatible); + } + if (compatible) return node; + } + throw new Error(found ? "node_incompatible" : "node_missing"); +} diff --git a/src/adapters/zcode/desktop-sandbox.ts b/src/adapters/zcode/desktop-sandbox.ts new file mode 100644 index 0000000000..2b2d7f002e --- /dev/null +++ b/src/adapters/zcode/desktop-sandbox.ts @@ -0,0 +1,28 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; + +// Process-local success only; never persist a probe performed in another security domain. +const successes = new Map(); +export function verifyDesktopSandbox(executable: string): void { + try { + const path = realpathSync(executable), st = statSync(path); + const key = JSON.stringify([path, st.ino, st.mtimeMs, st.ctimeMs]); + if ((successes.get(key) ?? 0) > Date.now()) return; + const args = ["--unshare-all", "--share-net", "--die-with-parent", "--new-session", + "--ro-bind", "/usr", "/usr"]; + for (const path of ["/bin", "/lib", "/lib64", "/sbin"]) { + if (!existsSync(path)) continue; + args.push(...(lstatSync(path).isSymbolicLink() + ? ["--symlink", readlinkSync(path), path] : ["--ro-bind", path, path])); + } + args.push("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", + "--clearenv", "--chdir", "/", "/usr/bin/true"); + // No vendor, credentials, workspace or inference; diagnostics never leave the child. + const result = spawnSync(path, args, { + env: { PATH: "/usr/bin:/bin" }, stdio: "ignore", timeout: 1500, + }); + if (result.error || result.status !== 0 || result.signal) throw new Error(); + if (successes.size > 32) successes.clear(); + successes.set(key, Date.now() + 10_000); + } catch { throw new Error("sandbox_unavailable"); } +} diff --git a/src/adapters/zcode/desktop.ts b/src/adapters/zcode/desktop.ts new file mode 100644 index 0000000000..057acbaf46 --- /dev/null +++ b/src/adapters/zcode/desktop.ts @@ -0,0 +1,342 @@ +import { accountRoot, accountProfile, readAccount } from "./accounts"; +import { constants, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; +import { createHash, randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { getConfigDir } from "../../config/paths"; +import { hasZcodeAccountClients, closeZcodeDesktopClients, waitForZcodeAccountClients, ZcodeClient } from "./client"; +import type { ZcodeSettings } from "./settings"; +import { verifyDesktopSandbox } from "./desktop-sandbox"; +import { resolveDesktopNode } from "./desktop-node"; + +export interface DesktopModel { id: string; providerId: string; modelId: string; label: string; contextWindow?: number } +interface Connection { + version: 1; connected: boolean; generation: string; + runtime: string; workspace: string; models: DesktopModel[]; +} +export class DesktopSetupError extends Error { + constructor(public code: string) { super(code); } +} +const fail = (code: string): never => { throw new DesktopSetupError(code); }; +const root = (accountId?: string) => accountId ? accountRoot(accountId) : join(getConfigDir(), "zcode-desktop"); +const connectionPath = (accountId?: string) => join(root(accountId), "connection.json"); +export const defaultDesktopWorkspace = (accountId?: string) => join(root(accountId), "workspace"); +const canonicalExistingOrResolved = (path: string): string => { + try { return realpathSync(path); } catch { return resolve(path); } +}; +export const isDefaultDesktopWorkspace = (path: string): boolean => + canonicalExistingOrResolved(path) === canonicalExistingOrResolved(defaultDesktopWorkspace()); + +function readConnection(accountId?: string): Connection | undefined { + if (accountId) readAccount(accountId); + const path = connectionPath(accountId); + if (!existsSync(path)) return undefined; + try { + const st = lstatSync(path); + if (!st.isFile() || st.size > 256 * 1024 || (st.mode & 0o077) !== 0 || st.uid !== process.getuid?.()) fail("connection_invalid"); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const c = JSON.parse(readFileSync(fd, "utf8")) as Connection; + if (c.version !== 1 || typeof c.connected !== "boolean" || typeof c.generation !== "string" || !/^[a-f0-9-]{36}$/.test(c.generation) || typeof c.runtime !== "string" || typeof c.workspace !== "string" || !Array.isArray(c.models)) fail("connection_invalid"); + return c; + } finally { closeSync(fd); } + } catch { return fail("connection_invalid"); } +} + +/** Public native IDs only: routing must work before live discovery warms the cache. + * No profile reads, process launches, or config mutations on the routing path. */ +export function desktopRoutingModelIds(accountId?: string): string[] { + try { + const connection = readConnection(accountId); + if (!connection?.connected) return []; + return connection.models.flatMap(model => + model && typeof model.id === "string" && typeof model.providerId === "string" + && model.providerId.startsWith("builtin:zai") && typeof model.modelId === "string" + && model.id === `${model.providerId}/${model.modelId}` && !/[\x00-\x20]/.test(model.id) + ? [model.id] : []); + } catch { return []; } // Invalid/revoked state is still rejected by the adapter. +} + +function persist(connection: Connection, accountId?: string): void { + mkdirSync(root(accountId), { recursive: true, mode: 0o700 }); + const temp = join(root(accountId), `${randomUUID()}.tmp`); + writeFileSync(temp, JSON.stringify(connection), { mode: 0o600, flag: "wx" }); + renameSync(temp, connectionPath(accountId)); +} + +/** Only an installed Desktop layout, never an arbitrary executable or shell command. */ +export function resolveDesktopRuntime(path: string): string { + if (typeof path !== "string" || !isAbsolute(path) || path.length > 4096 || path.includes("\0")) return fail("desktop_missing"); + for (const candidate of [path, join(path, "resources/glm/zcode.cjs"), join(path, "Contents/Resources/glm/zcode.cjs")]) { + try { + const real = realpathSync(candidate); + if (basename(real) !== "zcode.cjs" || basename(dirname(real)) !== "glm") continue; + const resources = dirname(dirname(real)); + if (!existsSync(join(resources, "app.asar")) || !statSync(real).isFile()) continue; + return real; + } catch { /* try the next installed layout */ } + } + return fail("desktop_missing"); +} + +export function detectDesktopRuntimes(): string[] { + const candidates = ["/opt/ZCode", "/opt/zcode", "/Applications/ZCode.app", join(homedir(), "Applications/ZCode.app")]; + if (process.platform === "linux") { + try { + for (const pid of readdirSync("/proc").filter(p => /^\d+$/.test(p)).slice(0, 4096)) { + try { + if (statSync(`/proc/${pid}`).uid !== process.getuid?.()) continue; + const exe = readlinkSync(`/proc/${pid}/exe`); + if (/^zcode(?:-bin)?$/i.test(basename(exe))) candidates.push(dirname(exe)); + } catch { /* process exited or belongs to another user */ } + } + } catch { /* no process discovery */ } + } + const found = new Set(); + for (const candidate of candidates) { try { found.add(resolveDesktopRuntime(candidate)); } catch { /* absent */ } } + return [...found].slice(0, 8); +} + +export function validateDesktopWorkspace(path: string, accountId?: string): string { + if (!path || !isAbsolute(path) || path.includes("\0") || path.length > 4096) return fail("workspace_invalid"); + const defaultPath = defaultDesktopWorkspace(accountId); + if (resolve(path) === resolve(defaultPath)) mkdirSync(path, { recursive: true, mode: 0o700 }); + let workspace: string; + try { workspace = realpathSync(path); if (!statSync(workspace).isDirectory()) return fail("workspace_invalid"); } + catch { return fail("workspace_invalid"); } + if (!desktopSandboxEnabled()) return workspace; + const home = realpathSync(homedir()); + if (workspace === home || home.startsWith(workspace.endsWith(sep) ? workspace : workspace + sep)) return fail("workspace_invalid"); + const configRoot = canonicalExistingOrResolved(getConfigDir()); + const managedDefault = canonicalExistingOrResolved(defaultPath); + const isManagedDefault = workspace === managedDefault && managedDefault.startsWith(configRoot + sep); + if (!isManagedDefault) { + for (const rawPath of [join(home, ".zcode"), join(home, ".codex"), join(home, ".ssh"), join(home, ".config"), getConfigDir(), root()]) { + const protectedPath = canonicalExistingOrResolved(rawPath); + if (workspace === protectedPath || workspace.startsWith(protectedPath + sep) || protectedPath.startsWith(workspace + sep)) return fail("workspace_invalid"); + } + } + return workspace; +} + +export function desktopFolders(path?: string) { + const home = realpathSync(homedir()); + const current = path ? realpathSync(path) : home; + if (current !== home && !current.startsWith(home + sep)) return fail("workspace_invalid"); + if (current !== home) validateDesktopWorkspace(current); + const folders = readdirSync(current, { withFileTypes: true }).filter(entry => entry.isDirectory() && !entry.name.startsWith(".")) + .slice(0, 200).map(entry => ({ name: entry.name, path: join(current, entry.name) })); + return { current, parent: current === home ? null : dirname(current), folders }; +} + +export const desktopSandboxEnabled = () => process.env.OCX_ZCODE_SANDBOX === "1"; + +function prerequisites(): { bwrap?: string; node: string } { + if (process.platform !== "linux") return fail("platform_unsupported"); + const bwrap = desktopSandboxEnabled() ? Bun.which("bwrap", { PATH: process.env.PATH }) : undefined; + if (desktopSandboxEnabled() && !bwrap) return fail("sandbox_missing"); + let node: string; + try { node = resolveDesktopNode(); } + catch (error) { return fail(error instanceof Error && error.message === "node_missing" ? "node_missing" : "node_incompatible"); } + try { if (bwrap) verifyDesktopSandbox(bwrap); } catch { return fail("sandbox_unavailable"); } + return { bwrap: bwrap ? realpathSync(bwrap) : undefined, node: realpathSync(node) }; +} + +export function desktopProfile(accountId?: string): { config: string; credentials?: string } { + const base = join(accountId ? accountProfile(accountId) : homedir(), ".zcode/v2"); + const config = join(base, "config.json"); + try { if (!lstatSync(config).isFile() || statSync(config).size > 4 * 1024 * 1024) return fail("profile_missing"); } + catch { return fail("profile_missing"); } + const credentials = join(base, "credentials.json"); + return { config, ...(existsSync(credentials) && lstatSync(credentials).isFile() ? { credentials } : {}) }; +} + +function settingsFor(connection: Connection, accountId?: string): ZcodeSettings { + const { bwrap, node } = prerequisites(); + const runtime = resolveDesktopRuntime(connection.runtime); + const workspace = validateDesktopWorkspace(connection.workspace, accountId); + const profile = desktopProfile(accountId); + // Do not carry a native conversation across Desktop login/profile changes. Metadata-only + // fencing is conservative (a refresh may start a new session) and never reads credential bytes. + const profileStamp = createHash("sha256").update(JSON.stringify([profile.config, profile.credentials].filter(Boolean).map(path => { + const st = statSync(path!); return [st.dev, st.ino, st.size, st.mtimeMs, st.ctimeMs]; + }))).digest("hex"); + // Connection/profile generations fence continuations, but all generations backed by the same + // physical Desktop profile must still share one process-serialization queue. + const lockKey = createHash("sha256").update(JSON.stringify(["desktop", profile.config])).digest("hex"); + const privateHome = join(root(accountId), "home", connection.generation, profileStamp); + const db = join(privateHome, ".zcode/cli/db"); + mkdirSync(db, { recursive: true, mode: 0o700 }); + if (!bwrap) return { accountId, + command: [node, fileURLToPath(new URL("./desktop-bootstrap.cjs", import.meta.url)), + "--host", runtime, profile.config, workspace, privateHome], + home: privateHome, workspace, settingsPath: "", + lockKey, scope: `desktop:${connection.generation}:${profileStamp}:host`, desktopModels: connection.models, + desktopRuntime: runtime, hostExecution: true, nativePermissionMode: "yolo", + }; + const sandboxHome = homedir(); // preserve the official credential cipher's HOME/username identity + const args = ["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--ro-bind", "/usr", "/usr"]; + for (const path of ["/bin", "/lib", "/lib64", "/sbin"]) { + if (!existsSync(path)) continue; + const st = lstatSync(path); + args.push(...(st.isSymbolicLink() ? ["--symlink", readlinkSync(path), path] : ["--ro-bind", path, path])); + } + args.push("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/etc"); + for (const path of ["/etc/passwd", "/etc/resolv.conf", "/etc/ssl", "/etc/pki"]) if (existsSync(path)) args.push("--ro-bind", path, path); + args.push("--ro-bind", dirname(runtime), "/runtime", "--ro-bind", node, "/usr/bin/node", + "--ro-bind", fileURLToPath(new URL("./desktop-bootstrap.cjs", import.meta.url)), "/bridge/desktop-bootstrap.cjs", + "--bind", privateHome, sandboxHome, "--bind", workspace, "/workspace", + "--ro-bind", profile.config, "/desktop/config.json", + "--tmpfs", `${sandboxHome}/.zcode/cli`, "--bind", db, `${sandboxHome}/.zcode/cli/db`); + if (profile.credentials) args.push("--ro-bind", profile.credentials, "/desktop/.zcode/v2/credentials.json"); + args.push("--clearenv", "--setenv", "HOME", sandboxHome, "--setenv", "PATH", "/usr/bin:/bin", + "--setenv", "ZCODE_DATA_BASE_DIR", "/desktop", "--chdir", "/workspace", + "/usr/bin/node", "/bridge/desktop-bootstrap.cjs"); + return { accountId, command: [bwrap, ...args], home: privateHome, workspace: "/workspace", settingsPath: "", + lockKey, scope: `desktop:${connection.generation}:${profileStamp}:sandbox`, desktopModels: connection.models, + desktopRuntime: runtime, + nativePermissionMode: "yolo" }; +} + +/** Persisted GUI consent is separate from provider config; data-plane requests cannot set it. */ +export function loadDesktopSettings(accountId?: string): ZcodeSettings | undefined { + if (connecting.has(accountId ?? "desktop")) return fail("busy"); + const connection = readConnection(accountId); + if (!connection) { if (accountId) return fail("disconnected"); return undefined; } + if (!connection.connected) return fail("disconnected"); + return settingsFor(connection, accountId); +} + +export function desktopStatus(accountId?: string, detectedRuntimes?: readonly string[]) { + let issue: string | undefined; + try { prerequisites(); desktopProfile(accountId); } catch (e) { issue = e instanceof DesktopSetupError ? e.code : "connection_invalid"; } + let connection: Connection | undefined; + try { connection = readConnection(accountId); } catch { issue = "connection_invalid"; } + const runtimes = detectedRuntimes ? [...detectedRuntimes] : detectDesktopRuntimes(); + if (connection?.connected) { + try { resolveDesktopRuntime(connection.runtime); validateDesktopWorkspace(connection.workspace, accountId); } + catch (e) { issue = e instanceof DesktopSetupError ? e.code : "connection_invalid"; } + } + return { ...(accountId ? { accountId } : {}), configured: connection?.connected === true, + connected: connection?.connected === true && !issue, issue, runtimes, + runtime: connection?.runtime ?? runtimes[0] ?? "", workspace: connection?.workspace ?? defaultDesktopWorkspace(accountId), + models: connection?.models ?? [], sandbox: desktopSandboxEnabled(), platform: process.platform }; +} + +const connecting = new Set(); +const connectionIdleWaiters = new Map void>>(); + +function notifyConnectionIdle(key: string): void { + if (connecting.has(key)) return; + const waiters = connectionIdleWaiters.get(key); + connectionIdleWaiters.delete(key); + for (const resolve of waiters ?? []) resolve(); +} + +function waitForConnectionIdle(key: string, signal?: AbortSignal): Promise { + if (!connecting.has(key)) return Promise.resolve(); + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const waiters = connectionIdleWaiters.get(key) ?? new Set<() => void>(); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; signal?.removeEventListener("abort", abort); + waiters.delete(finish); if (!waiters.size) connectionIdleWaiters.delete(key); + resolve(); + }; + const abort = () => { + if (settled) return; + settled = true; waiters.delete(finish); if (!waiters.size) connectionIdleWaiters.delete(key); + reject(signal?.reason); + }; + waiters.add(finish); connectionIdleWaiters.set(key, waiters); + signal?.addEventListener("abort", abort, { once: true }); + if (!connecting.has(key)) finish(); + }); +} + +/** Refresh jobs reserve the account first, then use this to wait out an already-running turn. */ +export async function waitForDesktopAccountIdle(id: string, signal?: AbortSignal): Promise { + while (desktopAccountBusy(id)) { + if (connecting.has(id)) await waitForConnectionIdle(id, signal); + else await waitForZcodeAccountClients(id, signal); + } +} + +function normalizeProtocolModels(value: unknown): DesktopModel[] { + if (!Array.isArray(value) || !value.length || value.length > 1000) return fail("models_missing"); + const models = value.filter((m: DesktopModel) => typeof m.id === "string" && typeof m.providerId === "string" + && m.providerId.startsWith("builtin:zai") && typeof m.modelId === "string" && !/[\x00-\x20]/.test(m.id) + && m.id === `${m.providerId}/${m.modelId}`).map((m: DesktopModel) => ({ + id: m.id, providerId: m.providerId, modelId: m.modelId, label: String(m.label).slice(0, 240), + ...(typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) && m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}), + })); + if (!models.length) return fail("models_missing"); + return models; +} + +async function verifyProtocol(settings: ZcodeSettings, requiredModel?: string, signal?: AbortSignal): Promise { + const client = new ZcodeClient(settings); + const cancel = () => { void client.close(); }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + if (signal?.aborted) return fail("runtime_failed"); + const result = await client.request("opencodex/desktopModels", {}, 15_000); + const models = normalizeProtocolModels(result.models); + if (requiredModel && !models.some(model => model.id === requiredModel)) return fail("models_missing"); + // Protocol metadata only: never create/send a model turn or permit native tool execution. + await client.request("workspace/readState", { workspace: { workspacePath: settings.workspace, workspaceKey: settings.workspace } }, 15_000); + return models; + } finally { signal?.removeEventListener("abort", cancel); await client.close(); } +} + +export async function connectDesktop(runtime: string, workspace: string, accountId?: string): Promise> { + if (connecting.has(accountId ?? "desktop")) return fail("busy"); + if (accountId && hasZcodeAccountClients(accountId)) return fail("busy"); + connecting.add(accountId ?? "desktop"); + try { + const connection: Connection = { version: 1, connected: true, generation: randomUUID(), + runtime: resolveDesktopRuntime(runtime), workspace: validateDesktopWorkspace(workspace, accountId), models: [] }; + const settings = settingsFor(connection, accountId); + connection.models = await verifyProtocol(settings); + if (!accountId) await closeZcodeDesktopClients(); + persist(connection, accountId); + return desktopStatus(accountId); + } catch (e) { if (e instanceof DesktopSetupError) throw e; return fail("runtime_failed"); } + finally { + const key = accountId ?? "desktop"; + connecting.delete(key); notifyConnectionIdle(key); + } +} + +/** Recheck the official protocol without creating a model turn or exposing native tools. */ +export async function verifyDesktopProtocol(requiredModel: string, signal?: AbortSignal, accountId?: string): Promise { + const key = accountId ?? "desktop"; + if (connecting.has(key) || (accountId && hasZcodeAccountClients(accountId))) return fail("busy"); + connecting.add(key); + const generation = randomUUID(); + try { + const connection = readConnection(accountId); + if (!connection?.connected) return fail("disconnected"); + const settings = settingsFor({ ...connection, generation }, accountId); + await verifyProtocol(settings, requiredModel, signal); + } catch (error) { + if (error instanceof DesktopSetupError) throw error; + return fail("runtime_failed"); + } finally { + rmSync(join(root(accountId), "home", generation), { recursive: true, force: true }); + connecting.delete(key); notifyConnectionIdle(key); + } +} + +export async function disconnectDesktop(accountId?: string): Promise { + if (connecting.has(accountId ?? "desktop")) return fail("busy"); + if (accountId && hasZcodeAccountClients(accountId)) return fail("busy"); + const previous = readConnection(accountId); + persist({ version: 1, connected: false, generation: randomUUID(), runtime: previous?.runtime ?? "", workspace: previous?.workspace ?? defaultDesktopWorkspace(), models: [] }, accountId); + if (!accountId) await closeZcodeDesktopClients(); +} + +export const desktopAccountBusy = (id: string) => connecting.has(id) || hasZcodeAccountClients(id); diff --git a/src/adapters/zcode/native-oauth.ts b/src/adapters/zcode/native-oauth.ts new file mode 100644 index 0000000000..1f191db7c3 --- /dev/null +++ b/src/adapters/zcode/native-oauth.ts @@ -0,0 +1,101 @@ +import { verifyDesktopSandbox } from "./desktop-sandbox"; +import { spawn } from "node:child_process"; +import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { desktopSandboxEnabled, resolveDesktopRuntime } from "./desktop"; + +export type NativeOAuthEvent = + | { type: "capabilities"; supported: true } + | { type: "authorization"; url: string } + | { type: "authenticated"; subjectHash: string } + | { type: "error"; code: "login_expired" | "native_oauth_failed" | "session_restore_failed" | "model_setup_failed" | "account_identity_mismatch" }; + +/** Strict projection even if a vendor/bootstrap version emits unexpected fields. */ +export function parseNativeOAuthEvent(value: unknown): NativeOAuthEvent { + if (!value || typeof value !== "object") throw new Error("native_oauth_failed"); + const row = value as Record; + if (row.type === "capabilities" && row.supported === true) return { type: "capabilities", supported: true }; + if (row.type === "authorization" && typeof row.url === "string" && row.url.length <= 8_192) { + let url: URL; + try { url = new URL(row.url); } catch { throw new Error("native_oauth_failed"); } + if (url.protocol === "https:" && url.hostname === "chat.z.ai" && !url.username && !url.password) return { type: "authorization", url: url.href }; + } + if (row.type === "authenticated" && typeof row.subjectHash === "string" && /^[a-f0-9]{64}$/.test(row.subjectHash)) { + return { type: "authenticated", subjectHash: row.subjectHash }; + } + if (row.type === "error") return { type: "error", code: + row.code === "login_expired" || row.code === "session_restore_failed" || row.code === "model_setup_failed" || row.code === "account_identity_mismatch" + ? row.code : "native_oauth_failed" }; + throw new Error("native_oauth_failed"); +} + +/** The caller supplies a newly allocated private profile, never the user's Desktop home. */ +export function nativeOAuthCommand(runtime: string, profileHome: string, mode: "login" | "capabilities" | "refresh", expectedSubjectHash?: string): string[] { + if (process.platform !== "linux") throw new Error("platform_unsupported"); + const profile = realpathSync(profileHome), st = lstatSync(profileHome); + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid?.() || (st.mode & 0o077) || profile === realpathSync(homedir())) { + throw new Error("profile_invalid"); + } + const runtimeRoot = dirname(dirname(dirname(resolveDesktopRuntime(runtime)))); + if (!lstatSync(join(runtimeRoot, "zcode")).isFile()) throw new Error("desktop_missing"); + if (mode === "refresh" && !/^[a-f0-9]{64}$/.test(expectedSubjectHash ?? "")) throw new Error("account_identity_mismatch"); + if (!desktopSandboxEnabled()) return [join(runtimeRoot, "zcode"), + fileURLToPath(new URL("./oauth-bootstrap.cjs", import.meta.url)), mode, runtimeRoot, ...(expectedSubjectHash ? [expectedSubjectHash] : [])]; + const bwrap = Bun.which("bwrap", { PATH: process.env.PATH }); + if (!bwrap) throw new Error("sandbox_missing"); + verifyDesktopSandbox(bwrap); + const args = [realpathSync(bwrap), "--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--ro-bind", "/usr", "/usr"]; + for (const path of ["/bin", "/lib", "/lib64", "/sbin"]) if (existsSync(path)) { + args.push(...(lstatSync(path).isSymbolicLink() ? ["--symlink", readlinkSync(path), path] : ["--ro-bind", path, path])); + } + args.push("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/etc"); + for (const path of ["/etc/passwd", "/etc/resolv.conf", "/etc/ssl", "/etc/pki"]) if (existsSync(path)) args.push("--ro-bind", path, path); + args.push("--ro-bind", runtimeRoot, "/zcode", "--bind", profile, homedir(), + "--ro-bind", fileURLToPath(new URL("./oauth-bootstrap.cjs", import.meta.url)), "/bridge.cjs", + "--clearenv", "--setenv", "HOME", homedir(), "--setenv", "PATH", "/usr/bin:/bin", + "--setenv", "ELECTRON_RUN_AS_NODE", "1", "--chdir", homedir(), "/zcode/zcode", "/bridge.cjs", mode, "/zcode", + ...(expectedSubjectHash ? [expectedSubjectHash] : [])); + return args; +} + +export async function runNativeOAuth(options: { + runtime: string; profileHome: string; mode: "login" | "capabilities" | "refresh"; + expectedSubjectHash?: string; + signal: AbortSignal; onEvent: (event: NativeOAuthEvent) => void; +}): Promise { + if (options.signal.aborted) throw new Error("login_cancelled"); + const [executable, ...args] = nativeOAuthCommand(options.runtime, options.profileHome, options.mode, options.expectedSubjectHash); + const child = spawn(executable!, args, { env: desktopSandboxEnabled() ? { PATH: "/usr/bin:/bin" } : { + PATH: "/usr/bin:/bin", HOME: homedir(), ELECTRON_RUN_AS_NODE: "1", ZCODE_DATA_BASE_DIR: options.profileHome, + }, stdio: ["ignore", "pipe", "pipe"] }); + child.stderr.resume(); + let invalid = false, finished = false, buffer = "", total = 0; + const stop = () => child.kill("SIGKILL"); + options.signal.addEventListener("abort", stop, { once: true }); + if (options.signal.aborted) stop(); + const timer = setTimeout(stop, options.mode === "login" ? 310_000 : 20_000); + try { + await new Promise((resolve, reject) => { + child.once("error", () => reject(new Error("native_oauth_failed"))); + child.stdout.on("data", chunk => { + total += chunk.length; + if (total > 16_384 || invalid) { invalid = true; stop(); return; } + buffer += chunk.toString("utf8"); + let end: number; + while ((end = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + try { + const event = parseNativeOAuthEvent(JSON.parse(line)); + if (finished || event.type === "error") { invalid = true; stop(); } + finished ||= event.type === "authenticated" || event.type === "capabilities"; + options.onEvent(event); + } catch { invalid = true; stop(); } + } + }); + child.once("close", code => code === 0 && finished && !invalid && !buffer && !options.signal.aborted + ? resolve() : reject(new Error(options.signal.aborted ? "login_cancelled" : "native_oauth_failed"))); + }); + } finally { clearTimeout(timer); options.signal.removeEventListener("abort", stop); stop(); } +} diff --git a/src/adapters/zcode/oauth-bootstrap.cjs b/src/adapters/zcode/oauth-bootstrap.cjs new file mode 100644 index 0000000000..2dca79e267 --- /dev/null +++ b/src/adapters/zcode/oauth-bootstrap.cjs @@ -0,0 +1,91 @@ +// Runs with a private profile (optional OS sandbox) using the unmodified official Desktop host. +// OAuth HTTP, polling, credentials and refresh belong exclusively to ZCode. +const fs = require("node:fs"); +const { EventEmitter } = require("node:events"); +const { createHash } = require("node:crypto"); +const write = process.stdout.write.bind(process.stdout); +process.stdout.write = process.stderr.write.bind(process.stderr); +const send = value => write(JSON.stringify(value) + "\n"); +let stage = "native_oauth_failed"; +const parent = new EventEmitter(); +parent.postMessage = () => {}; +process.parentPort = parent; +const timer = setTimeout(() => { send({ type: "error", code: "login_expired" }); process.exit(1); }, 300_000); +const subjectHash = result => { + if (result?.kind !== "session" || result.provider !== "zai" || typeof result.userInfo?.id !== "string" || !result.userInfo.id) throw new Error(); + return createHash("sha256").update("zai\0" + result.userInfo.id).digest("hex"); +}; +/** Validate identity before the official model provider can mutate this profile. */ +async function materializeSession(result, models, expectedSubjectHash) { + const identity = subjectHash(result); + if (expectedSubjectHash && identity !== expectedSubjectHash) throw new Error("account_identity_mismatch"); + await models.call("getAll", []); + await models.call("refreshCodingPlanApiKey", ["builtin:zai-coding-plan"]); + return identity; +} +async function main() { + fs.mkdirSync((process.env.ZCODE_DATA_BASE_DIR || process.env.HOME) + "/.zcode/v2", { recursive: true, mode: 0o700 }); + const dir = (process.argv[3] || "/zcode") + "/resources/app.asar/out/host"; + let classes = []; + for (const name of fs.readdirSync(dir).filter(n => n.startsWith("chunk-") && n.endsWith(".js"))) { + const source = fs.readFileSync(dir + "/" + name, "utf8"); + if (source.includes('"ChannelClient"') && source.includes('"MessagePortProtocol"')) { + classes = Object.values(await import(dir + "/" + name)); break; + } + } + const Client = classes.find(v => v?.name === "ChannelClient"); + const Protocol = classes.find(v => v?.name === "MessagePortProtocol"); + const left = new EventEmitter(), right = new EventEmitter(); + for (const [a, b] of [[left, right], [right, left]]) { + a.postMessage = data => queueMicrotask(() => b.emit("message", { data, ports: [] })); + a.start = () => {}; a.close = () => a.emit("close"); + a.addEventListener = a.on.bind(a); a.removeEventListener = a.off.bind(a); + } + const client = new Client(new Protocol(left)); + await import(dir + "/index.js"); + parent.emit("message", { data: { type: "init-local" }, ports: [right] }); + const oauth = client.getChannel("oauth"); + if (process.argv[2] === "capabilities") { + const providers = await oauth.call("getProviders", []); + if (!providers.some(p => (p.id ?? p.providerId) === "zai" && p.enabled)) throw new Error(); + send({ type: "capabilities", supported: true }); return; + } + const publish = async (result, expectedSubjectHash) => { + stage = "session_restore_failed"; + stage = "model_setup_failed"; + const models = client.getChannel("model-provider"); + let identity; + try { identity = await materializeSession(result, models, expectedSubjectHash); } + catch (error) { + if (error?.message === "account_identity_mismatch") stage = "account_identity_mismatch"; + throw error; + } + send({ type: "authenticated", subjectHash: identity }); + }; + if (process.argv[2] === "refresh") { + stage = "session_restore_failed"; + const session = await oauth.call("restoreCachedSessionState", []); + if (session?.status !== "authenticated") throw new Error(); + const expected = process.argv[4]; + if (!/^[a-f0-9]{64}$/.test(expected || "")) { stage = "account_identity_mismatch"; throw new Error(); } + await publish({ kind: "session", provider: "zai", userInfo: session.userInfo }, expected); return; + } + if (process.argv[2] !== "login") throw new Error(); + const flow = await oauth.call("startOAuthWithPolling", ["zai"]); + const url = new URL(flow.authorizeUrl); + if (url.protocol !== "https:" || url.hostname !== "chat.z.ai" || url.username || url.password) throw new Error(); + send({ type: "authorization", url: url.href }); + while (true) { + await new Promise(resolve => setTimeout(resolve, 2_000)); + const result = await oauth.call("pollPendingOAuth", []); + if (!result) continue; + if (result.kind !== "session" || result.provider !== "zai" || typeof result.userInfo?.id !== "string" || !result.userInfo.id) throw new Error(); + await publish(result); + return; + } +} +module.exports = { materializeSession, subjectHash }; +if (require.main === module) main().then(() => { clearTimeout(timer); process.exit(0); }).catch(() => { + send({ type: "error", code: stage }); process.exit(1); +}); +else clearTimeout(timer); diff --git a/src/adapters/zcode/quota-bootstrap.cjs b/src/adapters/zcode/quota-bootstrap.cjs new file mode 100644 index 0000000000..637d6d94dc --- /dev/null +++ b/src/adapters/zcode/quota-bootstrap.cjs @@ -0,0 +1,41 @@ +// Runs in a private state directory; an OS sandbox is optional. Native host and RPC modules are unmodified official ZCode. +// No HTTP implementation, quota reset or model inference here. All diagnostics stay off stdout. +const {EventEmitter}=require('node:events'); +const fs=require('node:fs'); +const parent=new EventEmitter(); parent.postMessage=()=>{}; process.parentPort=parent; +const output=process.stdout.write.bind(process.stdout); process.stdout.write=process.stderr.write.bind(process.stderr); const originalLog=value=>output(value+'\n'); +const host=process.env.OCX_ZCODE_QUOTA_HOST==='1'; +const stateHome=host?process.env.ZCODE_DATA_BASE_DIR:process.env.HOME; +const profile=host?process.env.OCX_ZCODE_QUOTA_CONFIG:'/desktop/config.json'; +const credentials=host?process.env.OCX_ZCODE_QUOTA_CREDENTIALS:'/desktop/credentials.json'; +fs.mkdirSync(stateHome+'/.zcode/v2',{recursive:true}); +const input=JSON.parse(fs.readFileSync(profile,'utf8')); +if(process.argv[2]==='advanced') { + const raw=input.provider?.[process.argv[3]]; + const url=new URL(raw?.options?.baseURL); + if(url.protocol!=='https:' || url.hostname!=='api.z.ai' || url.username || url.password || !raw.options.apiKey) throw new Error('unsupported profile'); + fs.writeFileSync(stateHome+'/.zcode/v2/config.json',JSON.stringify({provider:{'builtin:zai-coding-plan':{...raw,name:'Z.ai - Coding Plan',enabled:true}}}),{mode:0o600}); +} else { + fs.copyFileSync(profile,stateHome+'/.zcode/v2/config.json'); + if(credentials && fs.existsSync(credentials)) fs.copyFileSync(credentials,stateHome+'/.zcode/v2/credentials.json'); +} +setTimeout(()=>{originalLog(JSON.stringify({error:'timeout'}));process.exit(1)},40000); +(async()=>{ + const dir=(host?process.env.OCX_ZCODE_QUOTA_RUNTIME:'/zcode')+'/resources/app.asar/out/host'; + let classes; + for(const name of fs.readdirSync(dir).filter(n=>n.startsWith('chunk-')&&n.endsWith('.js'))){ + const source=fs.readFileSync(dir+'/'+name,'utf8');if(source.includes('"ChannelClient"')&&source.includes('"MessagePortProtocol"')){classes=Object.values(await import(dir+'/'+name));break;} + } + const Client=classes.find(v=>v?.name==='ChannelClient');const Protocol=classes.find(v=>v?.name==='MessagePortProtocol'); + const left=new EventEmitter(),right=new EventEmitter(); + for(const [a,b]of [[left,right],[right,left]]){a.postMessage=data=>queueMicrotask(()=>b.emit('message',{data,ports:[]}));a.start=()=>{};a.close=()=>a.emit('close');a.addEventListener=a.on.bind(a);a.removeEventListener=a.off.bind(a);} + const client=new Client(new Protocol(left)); + await import(dir+'/index.js'); + parent.emit('message',{data:{type:'init-local'},ports:[right]}); + const result=await client.getChannel('usage-stats').call('getEntitlementSnapshot',[{preferredProviderId:'builtin:zai-coding-plan',requirePreferredProvider:true,allowEnvApiKey:false,includeSubscription:false}]); + const limits=Array.isArray(result.quota?.limits)?result.quota.limits.slice(0,16).map(row=>({ + type: ['CREDIT_LIMIT','TOKENS_LIMIT','TIME_LIMIT'].includes(row.type)?row.type:'unknown', + ...Object.fromEntries(['unit','number','usage','currentValue','remaining','percentage','nextResetTime'].filter(k=>typeof row[k]==='number'&&Number.isFinite(row[k])).map(k=>[k,row[k]])), +})):[]; + originalLog(JSON.stringify({generatedAt:result.generatedAt,limits}));process.exit(0); +})().catch(()=>{originalLog(JSON.stringify({error:'failed'}));process.exit(1)}); diff --git a/src/adapters/zcode/quota.ts b/src/adapters/zcode/quota.ts new file mode 100644 index 0000000000..cee950e437 --- /dev/null +++ b/src/adapters/zcode/quota.ts @@ -0,0 +1,158 @@ +import { refreshAccount } from "./account-runtime"; +import { verifyDesktopSandbox } from "./desktop-sandbox"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; +import { dirname, join, sep } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; +import type { OcxProviderConfig } from "../../types"; +import type { ProviderQuota } from "../../providers/quota-types"; +import { getConfigDir } from "../../config/paths"; +import { desktopProfile, desktopSandboxEnabled, resolveDesktopRuntime } from "./desktop"; +import { loadZcodeSettings } from "./settings"; + +export interface QuotaContext { identity: string; runtimeRoot: string; config: string; credentials?: string; sourceProvider: string; managed: boolean } +export type ZcodeQuotaSnapshot = { kind: "quota"; quota: ProviderQuota } | { kind: "empty" }; +const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +/** Distinguish a valid empty entitlement from an unavailable or malformed probe. */ +export function parseZcodeQuotaSnapshot(value: unknown, now = Date.now()): ZcodeQuotaSnapshot | null { + if (!value || typeof value !== "object") return null; + const v = value as { generatedAt?: unknown; limits?: unknown }; + if (!finite(v.generatedAt) || v.generatedAt > now + 60_000 || now - v.generatedAt > 30 * 60_000 + || !Array.isArray(v.limits) || v.limits.length > 256) return null; + const result: ProviderQuota = { updatedAt: v.generatedAt }; + const seen = new Set(); + for (const row of v.limits) { + if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.type !== "string") return null; + if (!["CREDIT_LIMIT", "TOKENS_LIMIT"].includes(row.type)) continue; + const key = row.unit === 3 && row.number === 5 ? "fiveHour" : row.unit === 6 && row.number === 1 ? "weekly" : undefined; + if (!key) continue; + if (seen.has(key)) return null; // ambiguous windows are not interchangeable + seen.add(key); + let percent: number | undefined; + if (finite(row.usage) && row.usage > 0 && finite(row.remaining) && row.remaining >= 0 && row.remaining <= row.usage) { + percent = 100 * (1 - row.remaining / row.usage); + } else if (row.usage === undefined && row.remaining === undefined && finite(row.percentage) && row.percentage >= 0 && row.percentage <= 100) { + percent = row.percentage; + } + if (percent === undefined) return null; + result[`${key}Percent`] = percent; + if (finite(row.nextResetTime) && row.nextResetTime > 0 && row.nextResetTime <= 8.64e15) { + result[`${key}ResetAt`] = row.nextResetTime > 1e11 ? row.nextResetTime : row.nextResetTime * 1000; + } + } + return result.fiveHourPercent !== undefined || result.weeklyPercent !== undefined + ? { kind: "quota", quota: result } + : { kind: "empty" }; +} + +/** Translate consumption into the existing shared quota contract. Missing is never zero. */ +export function parseZcodeQuota(value: unknown, now = Date.now()): ProviderQuota | null { + const result = parseZcodeQuotaSnapshot(value, now); + return result?.kind === "quota" ? result.quota : null; +} + +function context(provider: OcxProviderConfig): QuotaContext { + if (provider.adapter !== "zcode" || provider.authMode !== "local" || provider.disabled || process.platform !== "linux") throw new Error("unavailable"); + const settings = loadZcodeSettings(process.env, provider.zcodeAccountId); + const managed = settings.desktopModels !== undefined; + // Managed settings already resolved and validated the persisted Desktop runtime. Do not repeat + // status discovery here: quota cache-key reads are synchronous and discovery scans /proc. + const runtime = managed ? settings.desktopRuntime : process.env.OCX_ZCODE_DESKTOP_RUNTIME; + if (!runtime) throw new Error("unavailable"); + const runtimeRoot = dirname(dirname(dirname(resolveDesktopRuntime(runtime)))); + const config = managed ? desktopProfile(provider.zcodeAccountId).config : settings.settingsPath; + if (!lstatSync(config).isFile() || statSync(config).size > 4 * 1024 * 1024) throw new Error("unavailable"); + if (!managed && !realpathSync(config).startsWith(realpathSync(settings.home) + sep)) throw new Error("unavailable"); + const credentialFile = managed ? desktopProfile(provider.zcodeAccountId).credentials : undefined; + const credentials = managed && credentialFile && existsSync(credentialFile) ? credentialFile : undefined; + if (credentials && (!lstatSync(credentials).isFile() || statSync(credentials).size > 1024 * 1024)) throw new Error("unavailable"); + const sourceProvider = provider.defaultModel?.split("/")[0] || (managed ? "builtin:zai-coding-plan" : "zai"); + if (managed && sourceProvider !== "builtin:zai-coding-plan") throw new Error("unavailable"); + const executable = join(runtimeRoot, "zcode"); + if (!lstatSync(executable).isFile()) throw new Error("unavailable"); + const identity = createHash("sha256").update(JSON.stringify([settings.scope, runtimeRoot, sourceProvider, + ...[config, credentials, executable, join(runtimeRoot, "resources/app.asar")].filter(Boolean).map(p => { + const st = statSync(p!); return [p, st.dev, st.ino, st.size, st.mtimeMs, st.ctimeMs]; + })])).digest("hex"); + return { identity, runtimeRoot, config, credentials, sourceProvider, managed }; +} + +export function zcodeQuotaIdentity(provider: OcxProviderConfig): string { + try { return context(provider).identity; } catch { return "unavailable"; } +} + +function command(c: QuotaContext): string[] { + const args = ["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--ro-bind", "/usr", "/usr"]; + for (const p of ["/bin", "/lib", "/lib64", "/sbin"]) if (existsSync(p)) { + args.push(...(lstatSync(p).isSymbolicLink() ? ["--symlink", readlinkSync(p), p] : ["--ro-bind", p, p])); + } + args.push("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/etc"); + for (const p of ["/etc/passwd", "/etc/resolv.conf", "/etc/ssl", "/etc/pki"]) if (existsSync(p)) args.push("--ro-bind", p, p); + args.push("--ro-bind", c.runtimeRoot, "/zcode", "--tmpfs", homedir(), "--ro-bind", c.config, "/desktop/config.json"); + if (c.credentials) args.push("--ro-bind", c.credentials, "/desktop/credentials.json"); + args.push("--ro-bind", fileURLToPath(new URL("./quota-bootstrap.cjs", import.meta.url)), "/bridge.cjs", + "--clearenv", "--setenv", "HOME", homedir(), "--setenv", "PATH", "/usr/bin:/bin", "--setenv", "ELECTRON_RUN_AS_NODE", "1", + "--chdir", homedir(), "/zcode/zcode", "/bridge.cjs", c.managed ? "desktop" : "advanced", c.sourceProvider); + return args; +} + +/** One short-lived official host, without a writable user workspace or persistent credentials. */ +async function probe(c: QuotaContext): Promise { + const sandbox = desktopSandboxEnabled(); + const bwrap = sandbox ? Bun.which("bwrap", { PATH: process.env.PATH }) : undefined; + if (sandbox && !bwrap) return null; + try { if (bwrap) verifyDesktopSandbox(bwrap); } catch { return null; } + let temporaryHome: string | undefined; + try { + if (!sandbox) { + const directory = join(getConfigDir(), "zcode-desktop", "quota"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + temporaryHome = mkdtempSync(join(directory, "probe-")); + } + return await new Promise(resolve => { + const child = spawn(bwrap || join(c.runtimeRoot, "zcode"), bwrap ? command(c) : [ + fileURLToPath(new URL("./quota-bootstrap.cjs", import.meta.url)), c.managed ? "managed" : "advanced", c.sourceProvider, + ], { stdio: ["ignore", "pipe", "pipe"], env: bwrap ? { PATH: "/usr/bin:/bin" } : { + PATH: "/usr/bin:/bin", HOME: homedir(), ELECTRON_RUN_AS_NODE: "1", + ZCODE_DATA_BASE_DIR: temporaryHome!, OCX_ZCODE_QUOTA_HOST: "1", + OCX_ZCODE_QUOTA_RUNTIME: c.runtimeRoot, OCX_ZCODE_QUOTA_CONFIG: c.config, + ...(c.credentials ? { OCX_ZCODE_QUOTA_CREDENTIALS: c.credentials } : {}), + } }); + let output = ""; + let invalid = false; + const timer = setTimeout(() => { invalid = true; child.kill("SIGKILL"); }, 45_000); + child.stderr.resume(); + child.stdout.on("data", chunk => { + if (invalid) return; + if (Buffer.byteLength(output) + chunk.length > 32_000) { invalid = true; child.kill("SIGKILL"); return; } + output += chunk.toString("utf8"); + }); + child.once("error", () => { clearTimeout(timer); resolve(null); }); + child.once("close", code => { + clearTimeout(timer); + if (code || invalid) return resolve(null); + try { resolve(parseZcodeQuotaSnapshot(JSON.parse(output))); } catch { resolve(null); } + }); + }); + } finally { if (temporaryHome) rmSync(temporaryHome, { recursive: true, force: true }); } +} + +const inflight = new Map>(); +export async function readZcodeQuota(provider: OcxProviderConfig, deps: { context?: typeof context; probe?: typeof probe } = {}): Promise<({ identity: string } & ZcodeQuotaSnapshot) | null> { + let c: QuotaContext; + try { if (provider.zcodeAccountId && !deps.context) await refreshAccount(provider.zcodeAccountId); c = (deps.context ?? context)(provider); } catch { return null; } + // Coalesce per profile, without letting the first account starve every other account. + let pending = inflight.get(c.identity); + if (!pending) { + if (inflight.size >= 20) return null; + pending = (deps.probe ?? probe)(c).catch(() => null); + inflight.set(c.identity, pending); + void pending.finally(() => { if (inflight.get(c.identity) === pending) inflight.delete(c.identity); }); + } + const snapshot = await pending; + try { return snapshot && (deps.context ?? context)(provider).identity === c.identity ? { identity: c.identity, ...snapshot } : null; } + catch { return null; } +} diff --git a/src/adapters/zcode/reasoning.ts b/src/adapters/zcode/reasoning.ts new file mode 100644 index 0000000000..ac0cfc7740 --- /dev/null +++ b/src/adapters/zcode/reasoning.ts @@ -0,0 +1,36 @@ +/** + * ZCode's current GLM-5.3 family exposes three effective thought levels. `ultra` + * remains a Codex orchestration tier: codex-rs delegates at `max`, while the + * official Desktop runtime receives only a real ZCode level. + */ +export const ZCODE_GLM53_REASONING_EFFORTS = ["low", "high", "max"] as const; + +export interface ZcodeReasoningContract { + reasoningEfforts: string[]; + defaultReasoningEffort: string; +} + +/** Match the native model component while retaining the Desktop provider prefix. */ +export function isZcodeGlm53Model(modelId: string): boolean { + const native = modelId.trim().toLowerCase().split("/").at(-1); + return native === "glm-5.3" || native === "glm-5.3[1m]" || native === "glm-5.3-flash"; +} + +/** Undefined means that ZCode has no audited Codex effort contract for this model. */ +export function zcodeReasoningContract(modelId: string): ZcodeReasoningContract | undefined { + if (!isZcodeGlm53Model(modelId)) return undefined; + return { reasoningEfforts: [...ZCODE_GLM53_REASONING_EFFORTS], defaultReasoningEffort: "max" }; +} + +/** + * Normalize direct/stale client labels to the three values accepted by the + * official Desktop runtime. The normal Codex path already changes ultra to max; + * handling it here as well keeps the app-server boundary fail-safe. + */ +export function zcodeThoughtLevel(modelId: string, requested: string | undefined): string | undefined { + if (!requested || !isZcodeGlm53Model(modelId)) return undefined; + if (requested === "low" || requested === "minimal") return "low"; + if (requested === "high" || requested === "medium") return "high"; + if (requested === "max" || requested === "xhigh" || requested === "ultra") return "max"; + return undefined; +} diff --git a/src/adapters/zcode/settings.ts b/src/adapters/zcode/settings.ts new file mode 100644 index 0000000000..f38b6517e7 --- /dev/null +++ b/src/adapters/zcode/settings.ts @@ -0,0 +1,166 @@ +import { accountRoot } from "./accounts"; +import { closeSync, constants, fstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join, sep } from "node:path"; +import { createHash } from "node:crypto"; +import { loadDesktopSettings, type DesktopModel } from "./desktop"; + +export type JsonObject = Record; +export function record(value: unknown): JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; +} + +export interface ZcodeSettings { + accountId?: string; + command: string[]; + home: string; + workspace: string; + settingsPath: string; + /** Stable physical-profile identity used only for process serialization. */ + lockKey: string; + /** Connection and credential generation used for continuations and freshness checks. */ + scope: string; + /** Content generation for advanced settings; prevents sessions crossing credential changes. */ + profileGeneration?: string; + desktopModels?: DesktopModel[]; + /** Resolved official Desktop runtime persisted by the managed connection setup. */ + desktopRuntime?: string; + /** Managed host mode preserves the user's ordinary tool environment while keeping ZCode state private. */ + hostExecution?: boolean; + /** Managed Desktop consent authorizes non-interactive native tools; advanced launchers retain edit mode. */ + nativePermissionMode?: "edit" | "yolo"; +} + +/** Execution authority is operator environment or persisted GUI consent, never data-plane input. */ +export function loadZcodeSettings(env: NodeJS.ProcessEnv = process.env, accountId?: string): ZcodeSettings { + if (accountId !== undefined) accountRoot(accountId); + // Explicit env fixtures remain hermetic. Production prefers the GUI's persisted connection. + if (env === process.env) { + const desktop = loadDesktopSettings(accountId); + if (desktop) return desktop; + } + if (env.OCX_ZCODE_NATIVE_TOOLS !== "1") { + throw new Error("ZCode native execution is disabled. Configure an isolated launcher and opt in with OCX_ZCODE_NATIVE_TOOLS=1."); + } + let command: unknown; + try { command = JSON.parse(env.OCX_ZCODE_COMMAND ?? "null"); } catch { /* validated below */ } + if (!Array.isArray(command) || !command.length || command.length > 32 + || command.some(v => typeof v !== "string" || v.includes("\0") || v.length > 4096) + || !isAbsolute(command[0])) { + throw new Error("OCX_ZCODE_COMMAND must be a JSON argv array starting with an absolute isolated launcher path."); + } + const home = env.OCX_ZCODE_HOME; + const workspace = env.OCX_ZCODE_WORKSPACE; + if (!home || !workspace || !isAbsolute(home) || !isAbsolute(workspace)) { + throw new Error("ZCode requires explicit absolute OCX_ZCODE_HOME and OCX_ZCODE_WORKSPACE paths."); + } + const realHome = realpathSync(home); + if (env.HOME && realHome === realpathSync(env.HOME)) throw new Error("ZCode must use a separate home, not the proxy home."); + if (!statSync(realHome).isDirectory()) throw new Error("ZCode home is not a directory."); + const settingsPath = join(realHome, ".zcode", "cli", "config.json"); + const lockKey = createHash("sha256").update(JSON.stringify([command, realHome, workspace])).digest("hex"); + const profileGeneration = createHash("sha256").update(readSettingsBytes(realHome, settingsPath)).digest("hex"); + return { + command: command as string[], home: realHome, workspace, settingsPath, + lockKey, profileGeneration, + scope: createHash("sha256").update(JSON.stringify([lockKey, profileGeneration])).digest("hex"), + }; +} + +export interface ZcodeModel { + id: string; + providerId: string; + modelId: string; + label: string; + contextWindow?: number; + runtimeModel: JsonObject; +} + +/** Local settings only: no credential import, cloud discovery or writes to ZCode Desktop. */ +export function readZcodeModels(settings: ZcodeSettings): ZcodeModel[] { + if (settings.desktopModels) return settings.desktopModels.map(model => ({ ...model, runtimeModel: {} })); + try { return readModels(settings); } + catch { throw new Error("ZCode isolated model settings are unavailable, invalid or exceed the size limit."); } +} + +function readSettingsBytes(homePath: string, settingsPath: string): Buffer { + const home = realpathSync(homePath); + const resolved = realpathSync(settingsPath); + if (!resolved.startsWith(home + sep)) throw new Error("Settings escaped the isolated home."); + const fd = openSync(settingsPath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + // Verify the opened object, not just the path checked before open (Linux sandbox host). + if (process.platform === "linux" && !realpathSync(`/proc/self/fd/${fd}`).startsWith(home + sep)) { + throw new Error("Settings escaped the isolated home."); + } + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error("Invalid settings file."); + const bytes = Buffer.allocUnsafe(4 * 1024 * 1024 + 1); + let length = 0; + while (length < bytes.length) { + const count = readSync(fd, bytes, length, bytes.length - length, null); + if (!count) break; + length += count; + } + if (length === bytes.length) throw new Error("Settings grew beyond the size limit."); + return bytes.subarray(0, length); + } finally { closeSync(fd); } +} + +function readSettings(settings: ZcodeSettings): JsonObject { + const bytes = readSettingsBytes(settings.home, settings.settingsPath); + if (settings.profileGeneration + && createHash("sha256").update(bytes).digest("hex") !== settings.profileGeneration) { + throw new Error("ZCode settings changed during request setup."); + } + return record(JSON.parse(bytes.toString("utf8"))); +} + +function readModels(settings: ZcodeSettings): ZcodeModel[] { + const config = readSettings(settings); + const result: ZcodeModel[] = []; + for (const [providerId, value] of Object.entries(record(config.provider))) { + const provider = record(value); + if (provider.enabled === false || providerId === "opencodex") continue; + const options = record(provider.options); + // Never route an agent back into this proxy (including a differently named local entry). + if (typeof options.baseURL === "string") { + let url: URL; + try { url = new URL(options.baseURL); } catch { continue; } + if (url.protocol !== "https:" || url.username || url.password + || /^(localhost|127\.|0\.|10\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[|.*\.localhost$)/i.test(url.hostname)) continue; + } + const models = Object.entries(record(provider.models)); + for (const [modelId, raw] of models) { + if (!providerId || !modelId || providerId.includes("/") || /[\x00-\x20]/.test(providerId + modelId)) continue; + const model = record(raw); + const limit = record(model.limit); + const contextWindow = typeof limit.context === "number" && limit.context > 0 ? limit.context : undefined; + result.push({ + id: `${providerId}/${modelId}`, providerId, modelId, + label: `${String(provider.name ?? providerId)} / ${String(model.name ?? modelId)}`, + contextWindow, + runtimeModel: { + revision: "opencodex-zcode-v1", generatedAt: Date.now(), model: { providerId, modelId }, + provider: { + providerId, kind: provider.kind ?? "openai-compatible", label: provider.name ?? providerId, + source: provider.source ?? "custom", baseURL: options.baseURL, + ...(provider.apiFormat ? { apiFormat: provider.apiFormat } : {}), + ...(typeof options.apiKey === "string" && options.apiKey + ? { apiKey: { source: "inline", value: options.apiKey } } : {}), + apiKeyRequired: options.apiKeyRequired, + models: models.map(([id, item]) => ({ modelId: id, + contextWindow: record(record(item).limit).context, + maxOutputTokens: record(record(item).limit).output })), + }, + }, + }); + if (result.length > 2000) throw new Error("ZCode model catalog exceeds the size limit."); + } + } + return result; +} + +/** Never include runtimeModel (which may contain a key) in discovery or management output. */ +export function discoverZcodeModels(accountId?: string): Array<{ id: string; label: string; contextWindow?: number }> { + return readZcodeModels(loadZcodeSettings(process.env, accountId)).map(({ id, label, contextWindow }) => ({ id, label, contextWindow })); +} diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index f76e0b98fb..fbee9b76aa 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1,3 +1,5 @@ +import { discoverZcodeModels } from "../../adapters/zcode/settings"; +import { zcodeReasoningContract } from "../../adapters/zcode/reasoning"; import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { execFileSync } from "node:child_process"; @@ -883,8 +885,11 @@ export function catalogHintsFromProviderConfig( contextCap?: number, metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, + contextWindow?: number, ): Partial { - const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias); + const hinted = applyProviderConfigHints(name, prov, { + id, provider: name, ...(contextWindow ? { contextWindow } : {}), + }, contextCap, metadataModelIdCaseFold, effectiveAlias); const { provider: _provider, id: _id, ...hints } = hinted; return hints; } @@ -1659,6 +1664,28 @@ async function fetchProviderModelsWithAuth( ? [...models, vertexDefaultSeed] : models ); + if (prov.adapter === "zcode") { + try { + const models = discoverZcodeModels(prov.zcodeAccountId).map(model => { + const hints = catalogHintsFromProviderConfig( + name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, + model.contextWindow, + ); + const reasoning = zcodeReasoningContract(model.id); + return { + id: model.id, provider: name, ...hints, + // The official Desktop catalog is authoritative for current GLM-5.3 levels. Unknown + // future models expose no generic picker unless the operator configured one explicitly. + ...(reasoning ?? (hints.reasoningEfforts === undefined ? { reasoningEfforts: [] } : {})), + displayName: hints.displayName ?? model.label, + inputModalities: hints.inputModalities ?? ["text"], + } as CatalogModel; + }); + return observed(withConfiguredRetention(models), "authoritative"); + } catch { + return observed([], "degraded"); + } + } if (prov.adapter === "qoder") { if (!apiKey) return observed(configured, "degraded"); const profile = resolveQoderProfile(prov.baseUrl); diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts index 274d07feec..29f824098a 100644 --- a/src/providers/quota-routing-cache.ts +++ b/src/providers/quota-routing-cache.ts @@ -48,6 +48,8 @@ export function replaceCachedProviderQuotas( ): void { quotaCache.clear(); for (const report of reports) { + // Native Desktop account snapshots are display-only, not an automatic routing policy. + if (report.source === "zcode-desktop") continue; quotaCache.set(report.provider, { quota: report.quota, routing: routingEvidence?.get(report) }); } } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 1ab17a3603..fc052b8d5f 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,3 +1,4 @@ +import { readZcodeQuota, zcodeQuotaIdentity } from "../adapters/zcode/quota"; import { createHash } from "node:crypto"; import { effectiveCodexAuthAccountId, @@ -186,7 +187,7 @@ function cacheKey(config: OcxConfig): string { ? resolveProviderApiKey(provider.apiKey)?.trim() : undefined; const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none"; - return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`; + return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}${provider.adapter === "zcode" ? `:${zcodeQuotaIdentity(provider)}` : ""}`; }) .sort() .join("|"); @@ -3063,6 +3064,15 @@ async function maybeFetchProviderQuota( ): Promise { if (provider.disabled === true) return null; try { + if (provider.adapter === "zcode") { + const result = await readZcodeQuota(provider); + if (!result) return null; + if (result.kind === "empty") return AUTHORITATIVE_EMPTY_QUOTA; + const report: ProviderQuotaReport = { provider: name, label: "ZCode", source: "zcode-desktop", + quota: result.quota, updatedAt: result.quota.updatedAt, reverseEngineered: true }; + accountReportCurrent.set(report, () => zcodeQuotaIdentity(provider) === result.identity); + return report; + } if (isBuiltInChatGptForwardProvider(name, provider)) { return fetchChatGptForwardQuota(config, name, provider, forceRefresh, prefetchedCodexSnapshot); } @@ -3122,6 +3132,7 @@ let pendingProviderObservation: Promise = Promise.resolve(); * mirrors that discriminator instead of inventing a second notion of identity. */ function providerObservationAccountKey(provider: string, config: OcxConfig): string { + if (config.providers[provider]?.adapter === "zcode") return `${provider}\u0000zcode:${zcodeQuotaIdentity(config.providers[provider])}`; const oauthAccountId = getAccountSet(provider)?.activeAccountId; if (oauthAccountId !== undefined) return `${provider}\u0000${oauthAccountId}`; const providerConfig = config.providers[provider]; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e724b93533..4b2f10acc3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2144,6 +2144,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, + { + id: "zcode", label: "ZCode (local agent)", adapter: "zcode", + baseUrl: "https://zcode.z.ai", authKind: "local", liveModels: true, dashboardPreset: true, + allowBaseUrlOverride: false, + note: "Connect the official runtime and Z.AI models configured in ZCode Desktop from the provider setup panel. On Linux, official ZCode runs with host user permissions by default; OCX_ZCODE_SANDBOX=1 optionally enables workspace isolation; no API key or environment variables to paste. The bridge does not rewrite Desktop settings. ZCode owns its tools; text input only. Advanced isolated launchers remain supported.", + }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, diff --git a/src/responses/provider-continuation.ts b/src/responses/provider-continuation.ts index cbb6efc7ab..fea83150b4 100644 --- a/src/responses/provider-continuation.ts +++ b/src/responses/provider-continuation.ts @@ -54,7 +54,7 @@ export function isValidProviderContinuationOwner( && bounded(owner.adapterName, 128) && bounded(owner.modelId, 512) && typeof owner.credentialIdentity === "string" - && /^(key|oauth|codex):[0-9a-f]{64}$/.test(owner.credentialIdentity); + && /^(key|oauth|codex|local):[0-9a-f]{64}$/.test(owner.credentialIdentity); } /** Reuse the exact process-local route identity already established for reasoning replay. */ diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index f28dcc072e..c331d1d46b 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -347,7 +347,7 @@ export function durableReplayCredentialIdentity( /** Produce a non-reversible process-local identity for credential material. */ export function reasoningReplayCredentialIdentity( - kind: "key" | "oauth" | "codex", + kind: "key" | "oauth" | "codex" | "local", material: string | undefined, headers?: Record, ): string | undefined { diff --git a/src/router.ts b/src/router.ts index 70e427b74b..9e395d7899 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,3 +1,4 @@ +import { desktopRoutingModelIds } from "./adapters/zcode/desktop"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types"; import { getCombo, @@ -111,6 +112,9 @@ export function knownModelIdsForProvider( ): string[] { const ids = new Set(); for (const id of prov.models ?? []) ids.add(id); + if (prov.adapter === "zcode") { + for (const id of desktopRoutingModelIds(prov.zcodeAccountId)) ids.add(id); + } if (prov.defaultModel) ids.add(prov.defaultModel); const registry = providerMatchesRegistryTransportWithStaticGuards(provName, prov) ? PROVIDER_REGISTRY.find(entry => entry.id === provName) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d72d0ff0aa..c7540ac38c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -672,6 +672,10 @@ export function providerManagementConfigError( return "provider must be a plain object"; } const raw = provider as Record; + if (raw.zcodeAccountId !== undefined && (raw.adapter !== "zcode" || typeof raw.zcodeAccountId !== "string" + || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(raw.zcodeAccountId))) { + return "zcodeAccountId must identify a saved ZCode account."; + } const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities); if (capabilitiesError) return capabilitiesError; const pinsError = providerReasoningPinsConfigError(raw); @@ -895,7 +899,8 @@ export function copyIfDefined( type ProviderConfigFieldPolicy = "editor" | "redacted" | "runtime"; const PROVIDER_CONFIG_FIELD_POLICY = { - alias: "editor", + zcodeAccountId: "editor", + alias: "editor", modelAliases: "editor", modelDisplayNames: "editor", defaultAliases: "editor", diff --git a/src/server/index.ts b/src/server/index.ts index 577e8189f1..7a16cc817e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -71,6 +71,7 @@ import { runModelRenameStartupMigration } from "../providers/model-rename-startu import { runDevinProviderMergeStartupMigration } from "../providers/devin-provider-merge-migration"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; +import { comboPublicModelId } from "../combos/identifiers"; import type { StorageCleanupPolicy } from "../types"; import { MAX_CONFIGURABLE_INBOUND_BODY_BYTES, @@ -255,7 +256,7 @@ import { } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; import { readyProtocolMetadata } from "../remote/protocol"; -import { modelCapabilityFields } from "./models-capabilities"; +import { catalogRowSupportsToolUse, modelCapabilityFields } from "./models-capabilities"; import { recordCursorSeen } from "../integrations/cursor-seen"; import { detectCursorInstalls } from "../integrations/cursor-detect"; import { loadCursorEffortTable } from "../integrations/cursor-effort-table"; @@ -2322,6 +2323,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server + comboPublicModelId(id, value) === publicId)?.[1] : undefined; const effective = provider ? (await import("../providers/default-aliases")).effectiveModelAliases( config, @@ -2342,6 +2345,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const zcodeAccountsPrefix = "/api/zcode-accounts"; + const zcodeDesktopPrefix = "/api/zcode-desktop"; + if (url.pathname === zcodeAccountsPrefix || url.pathname.startsWith(`${zcodeAccountsPrefix}/`)) { + const { handleZcodeAccountRoutes } = await import("./zcode-account-routes"); + return handleZcodeAccountRoutes(ctx); + } + if (url.pathname === zcodeDesktopPrefix || url.pathname.startsWith(`${zcodeDesktopPrefix}/`)) { + const { handleZcodeDesktopRoutes } = await import("./zcode-desktop-routes"); + return handleZcodeDesktopRoutes(ctx); + } if (url.pathname === "/api/provider-quotas" && req.method === "GET") { const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; @@ -1551,6 +1562,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; identity?: string; error?: string; controller: AbortController; timer: ReturnType }; +const jobs = new Map(); +// A failed OAuth job still owns its hidden profile and account reservation until the user +// explicitly cancels it (or the bounded expiry removes it). This makes reconnect failure +// idempotent instead of allowing an unbounded stack of hidden drafts for the same account. +const pendingFor = (id: string) => [...jobs.values()].some(j => (j.accountId === id || j.replaceId === id) && j.phase !== "finished"); +const safeJob = (job: Job) => ({ jobId: job.id, accountId: job.replaceId ?? job.accountId, + phase: job.phase, ...(job.url ? { url: job.url } : {}), ...(job.error ? { error: job.error } : {}) }); +const fail = (code: string): never => { throw new Error(code); }; +function selectorUsesNamespace(value: string, providerNamespaces: ReadonlySet, modelAliases: ReadonlySet): boolean { + const normalized = value.trim().toLowerCase(); + if (providerNamespaces.has(normalized) || modelAliases.has(normalized)) return true; + const slash = normalized.indexOf("/"); + return slash > 0 && providerNamespaces.has(normalized.slice(0, slash)); +} +function configReferencesNamespaces(config: OcxConfig, providerNamespaces: ReadonlySet, modelAliases: ReadonlySet): boolean { + const uses = (value: unknown) => typeof value === "string" + && selectorUsesNamespace(value, providerNamespaces, modelAliases); + const usesProvider = (value: unknown) => typeof value === "string" + && providerNamespaces.has(value.trim().toLowerCase()); + const usesAny = (values: readonly unknown[] | undefined) => values?.some(uses) === true; + const claude = config.claudeCode; + if (usesAny([ + config.injectionModel, config.shadowCallIntercept?.model, + config.agentTaskRecovery?.model, config.tokenGuardian?.codexWarmupModel, + config.webSearchSidecar?.model, config.visionSidecar?.model, + claude?.model, claude?.smallFastModel, claude?.classifierModel, + claude?.webSearchSidecar?.model, claude?.visionSidecar?.model, + ])) return true; + if (usesProvider(config.defaultProvider) || usesProvider(config.images?.provider)) return true; + if ([config.disabledModels, config.subagentModels, config.modelPickerOrder, + config.subagentModelFallback, claude?.classifierFallbacks, config.shadowCallIntercept?.sourceModels] + .some(usesAny)) return true; + if ([claude?.modelMap, claude?.tierModels] + .some(record => usesAny(Object.values(record ?? {})))) return true; + if (Object.entries(config.subagentModelFallbackByModel ?? {}) + .some(([model, fallbacks]) => uses(model) || usesAny(fallbacks))) return true; + if (Object.entries(config.blockedModelRedirects ?? {}) + .some(([model, replacement]) => uses(model) || uses(replacement))) return true; + if (Object.keys(config.modelPinnedEfforts ?? {}).some(uses)) return true; + const desktopProfile = claude?.desktopProfile; + if (usesAny(Object.keys(desktopProfile?.assignments ?? {})) + || usesAny(Object.values(desktopProfile?.defaults ?? {}))) return true; + if ((config.customModels ?? []).some(model => usesProvider(model.provider))) return true; + if (Object.values(config.combos ?? {}).some(combo => combo.targets.some(target => usesProvider(target.provider)))) return true; + if (Object.values(config.routingProfiles ?? {}).some(profile => profile.candidates.some(candidate => usesProvider(candidate.provider)))) return true; + return Object.values(config.providers).some(provider => uses(provider.autoReviewModel) + || usesAny(Object.values(provider.autoReviewModelOverrides ?? {}))); +} +const safeErrors = new Set(["account_invalid", "account_limit", "account_busy", "account_referenced", + "account_duplicate", "account_identity_mismatch", "account_login_required", "job_invalid", "native_oauth_failed", + "desktop_missing", "workspace_invalid", "node_missing", "node_incompatible", "sandbox_missing", "sandbox_unavailable", + "account_removal_partial", "catalog_update_failed", "provider_registration_failed", "runtime_failed", "models_missing", + "platform_unsupported", "profile_missing", "connection_invalid", "busy"]); + +const services = { connectDesktop, desktopStatus, disconnectDesktop, runNativeOAuth, resolveDesktopRuntime, + validateDesktopWorkspace, accountRuntimeBusy, readDesktopCatalogSlugs }; +export function resetZcodeAccountJobsForTests(): void { + for (const job of jobs.values()) { job.controller.abort(); clearTimeout(job.timer); } + jobs.clear(); +} +export async function handleZcodeAccountRoutes(ctx: ManagementContext, deps = services): Promise { + const { connectDesktop, desktopStatus, disconnectDesktop, runNativeOAuth, resolveDesktopRuntime, + validateDesktopWorkspace, accountRuntimeBusy, readDesktopCatalogSlugs } = deps; + const path = ctx.url.pathname; + if (!path.startsWith("/api/zcode-accounts")) return null; + // URLs and account operations belong to a real GUI principal, not data/admin-token callers. + if (ctx.principal !== "gui-session") return jsonResponse({ error: "dashboard_required" }, 403); + try { + // Reconnect drafts are intentionally hidden and owned by in-memory jobs. A restart drops those + // jobs, so reconcile before every authenticated account operation without touching active jobs. + reconcileAccountDrafts(new Set([...jobs.values()].map(job => job.accountId))); + if (path === "/api/zcode-accounts" && ctx.req.method === "GET") { + const accounts = listAccounts(); + let sharedRuntimes: string[] | undefined; + let catalogSlugs: string[] | undefined; + let catalogReadable = true; + try { catalogSlugs = readDesktopCatalogSlugs(); } catch { catalogReadable = false; } + const readSharedCatalogSlugs = () => { + if (!catalogReadable) throw new Error("catalog unavailable"); + return catalogSlugs ?? []; + }; + return jsonResponse({ accounts: accounts.map(({ id, label }) => { + const desktop = desktopStatus(id, sharedRuntimes); + sharedRuntimes ??= desktop.runtimes; + const status = desktopActivation(ctx, desktop, readSharedCatalogSlugs); + return { id, label, connected: status.connected, activation: status.activation, providerName: status.providerName, + busy: accountRuntimeBusy(id) || pendingFor(id) }; + }) }); + } + if (path === "/api/zcode-accounts/login" && ctx.req.method === "GET") { + const job = jobs.get(ctx.url.searchParams.get("jobId") ?? ""); + if (!job) return jsonResponse({ error: "job_invalid" }, 404); + return jsonResponse(safeJob(job)); + } + if (ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); + const raw = await readBoundedJsonRequestBody(ctx.req, 12_000, undefined, { signal: AbortSignal.any([ctx.req.signal, AbortSignal.timeout(5000)]) }); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return jsonResponse({ error: "invalid_request" }, 400); + const body = raw as Record; + if (body.consent !== true) return jsonResponse({ error: "consent_required" }, 400); + + if (path === "/api/zcode-accounts/login") { + if ([...jobs.values()].filter(job => job.phase !== "finished").length >= 8) return jsonResponse({ error: "account_busy" }, 409); + if (typeof body.label !== "string" || typeof body.runtime !== "string" || typeof body.workspace !== "string") return fail("account_invalid"); + if (body.accountId !== undefined && typeof body.accountId !== "string") return fail("account_invalid"); + const replaceId = typeof body.accountId === "string" ? readAccount(body.accountId).id : undefined; + const runtime = resolveDesktopRuntime(body.runtime); + if (replaceId && (accountRuntimeBusy(replaceId) || pendingFor(replaceId))) return fail("account_busy"); + const account = allocateAccount(body.label, replaceId); + const targetId = replaceId ?? account.id; + let workspace: string; + try { + const requested = isDefaultDesktopWorkspace(body.workspace) + ? defaultDesktopWorkspace(targetId) : body.workspace; + // Validate against the account that connectDesktop will eventually use. In optional + // sandbox mode the global managed workspace is not an account workspace. + workspace = validateDesktopWorkspace(requested, targetId); + } catch (error) { + removeAccountFiles(account.id); + throw error; + } + const job: Job = { id: randomUUID(), accountId: account.id, replaceId, runtime, workspace, + phase: "waiting", controller: new AbortController(), timer: undefined! }; + job.timer = setTimeout(() => { + if (job.phase === "completing") return; + job.controller.abort(); jobs.delete(job.id); + void job.task?.then(() => { + if (!desktopStatus(job.accountId).connected && !readAccount(job.accountId).subjectHash) removeAccountFiles(job.accountId); + }).catch(() => {}); + }, 10 * 60_000); + job.timer.unref?.(); jobs.set(job.id, job); + job.task = runNativeOAuth({ runtime, profileHome: accountProfile(account.id), mode: "login", signal: job.controller.signal, + onEvent: event => { + if (event.type === "authorization") job.url = event.url; + if (event.type === "authenticated") job.identity = event.subjectHash; + if (event.type === "error") job.error = event.code; + } }).then(() => { + delete job.url; + if (!job.identity || job.controller.signal.aborted) throw new Error(); + job.phase = "authenticated"; + }).catch(() => { delete job.url; job.phase = "failed"; job.error ??= "native_oauth_failed"; }); + return jsonResponse(safeJob(job)); + } + if (path === "/api/zcode-accounts/cancel") { + const job = jobs.get(String(body.jobId)); + if (!job || job.phase === "completing") return fail("job_invalid"); + job.controller.abort(); await job.task; clearTimeout(job.timer); jobs.delete(job.id); + if (!desktopStatus(job.accountId).connected) removeAccountFiles(job.accountId); + return jsonResponse({ ok: true }); + } + if (path === "/api/zcode-accounts/complete") { + const job = jobs.get(String(body.jobId)); + if (!job || !job.identity || job.controller.signal.aborted || !["authenticated", "finished"].includes(job.phase)) return fail("job_invalid"); + if (job.phase === "finished") return jsonResponse(await activateDesktopProvider(ctx, desktopStatus(job.replaceId ?? job.accountId), readDesktopCatalogSlugs)); + if ([...jobs.values()].some(other => other.id !== job.id && other.phase === "completing" && other.identity === job.identity)) return fail("account_busy"); + const duplicate = listAccounts().find(a => a.subjectHash === job.identity && a.id !== job.accountId && a.id !== job.replaceId); + if (duplicate) return jsonResponse({ error: "account_duplicate", accountId: duplicate.id }, 409); + const id = job.replaceId ?? job.accountId; + if (accountRuntimeBusy(id)) return fail("account_busy"); + if (job.replaceId && readAccount(id).subjectHash !== job.identity) return fail("account_identity_mismatch"); + job.phase = "completing"; + let backup: string | undefined; + try { + if (job.replaceId) { + backup = join(accountRoot(id), "profile-previous-" + randomUUID()); + renameSync(accountProfile(id), backup); + renameSync(accountProfile(job.accountId), accountProfile(id)); + } + const status = await connectDesktop(job.runtime, job.workspace, id); + const saved = readAccount(id); + writeAccount({ id: saved.id, label: saved.label, subjectHash: job.identity }); + invalidateAccountRefresh(id); + if (backup) { rmSync(backup, { recursive: true, force: true }); backup = undefined; removeAccountFiles(job.accountId); } + job.phase = "finished"; clearGatherRoutedModelsInflight(); + return jsonResponse(await activateDesktopProvider(ctx, status, readDesktopCatalogSlugs)); + } catch (error) { + if (backup && existsSync(backup)) { + renameSync(accountProfile(id), accountProfile(job.accountId)); renameSync(backup, accountProfile(id)); + } + job.phase = "authenticated"; throw error; + } + } + const id = typeof body.accountId === "string" ? readAccount(body.accountId).id : fail("account_invalid"); + if (accountRuntimeBusy(id) || pendingFor(id)) return fail("account_busy"); + if (path === "/api/zcode-accounts/activate") return jsonResponse(await activateDesktopProvider(ctx, desktopStatus(id), readDesktopCatalogSlugs)); + if (path === "/api/zcode-accounts/rename") { + if (typeof body.label !== "string") return fail("account_invalid"); + const previous = readAccount(id), next = { ...previous, label: body.label.trim() }; + const status = desktopStatus(id); + withConfigMutationLockSync(() => { + const providers = ctx.config.providers; + try { + writeAccount(next); + ctx.config.providers = Object.fromEntries(Object.entries(providers).map(([name, provider]) => { + if (provider.zcodeAccountId !== id) return [name, provider]; + const modelDisplayNames = { ...provider.modelDisplayNames }; + for (const model of status.models) { + if (modelDisplayNames[model.id] === `${previous.label} / ${model.label}`) { + modelDisplayNames[model.id] = `${next.label} / ${model.label}`; + } + } + return [name, { ...provider, modelDisplayNames }]; + })); + (ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(ctx.config); + } catch { + ctx.config.providers = providers; writeAccount(previous); + return fail("provider_registration_failed"); + } + }); + clearGatherRoutedModelsInflight(); + return jsonResponse(await activateDesktopProvider(ctx, status, readDesktopCatalogSlugs)); + } + if (path === "/api/zcode-accounts/remove") { + const names = Object.keys(ctx.config.providers).filter(name => ctx.config.providers[name]?.zcodeAccountId === id); + const remainingProviders = Object.fromEntries( + Object.entries(ctx.config.providers).filter(([name]) => !names.includes(name)), + ); + const providerNamespaces = new Set(names.flatMap(name => { + const provider = ctx.config.providers[name]; + const alias = provider?.alias?.trim(); + return [name, ...(alias ? [alias] : [])]; + }).map(name => name.toLowerCase())); + const modelAliases = new Set(names.flatMap(name => Object.values(ctx.config.providers[name]?.modelAliases ?? {})) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map(value => value.trim().toLowerCase())); + if (configReferencesNamespaces({ ...ctx.config, providers: remainingProviders }, providerNamespaces, modelAliases)) return fail("account_referenced"); + // Revoke first. A failed config/catalog save is explicit and cannot silently use another account. + await disconnectDesktop(id); + try { + withConfigMutationLockSync(() => { + const previous = { ...ctx.config.providers }; + try { + for (const name of names) delete ctx.config.providers[name]; + (ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(ctx.config); + } catch (error) { ctx.config.providers = previous; throw error; } + }); + for (const name of names) clearModelCache(name); + reconcileLiveStateStores(); clearGatherRoutedModelsInflight(); + } catch { + // Revocation already persisted. Keep the account visible and require an explicit, + // idempotent retry instead of reporting a generic OAuth failure or reconnecting it. + return fail("account_removal_partial"); + } + let result: Awaited>; + try { result = await ctx.convergeCodexCatalog(); } + catch { return fail("catalog_update_failed"); } + if (result.status !== "committed") return fail("catalog_update_failed"); + try { removeAccountFiles(id); invalidateAccountRefresh(id); } + catch { return fail("account_removal_partial"); } + return jsonResponse({ ok: true }); + } + return jsonResponse({ error: "not_found" }, 404); + } catch (error) { + const code = error instanceof Error && safeErrors.has(error.message) ? error.message : "native_oauth_failed"; + return jsonResponse({ error: code }, 400); + } +} diff --git a/src/server/management/zcode-desktop-activation.ts b/src/server/management/zcode-desktop-activation.ts new file mode 100644 index 0000000000..ca8c13b60d --- /dev/null +++ b/src/server/management/zcode-desktop-activation.ts @@ -0,0 +1,109 @@ +import { readAccount } from "../../adapters/zcode/accounts"; +import { saveConfigPreservingClaudeCode, withConfigMutationLockSync } from "../../config"; +import { readCatalog, readCodexCatalogPath, catalogModelSlug, applyProviderConfigHints, filterCatalogVisibleModels } from "../../codex/catalog"; +import { clearModelCache } from "../../codex/model-cache"; +import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; +import type { ManagementContext } from "./context"; +import type { desktopStatus } from "../../adapters/zcode/desktop"; + +type DesktopStatus = ReturnType; +export const readDesktopCatalogSlugs = (): string[] => (readCatalog(readCodexCatalogPath())?.models ?? []) + .filter(row => row.visibility === "list").map(row => String(row.slug)); + +function providerNames(ctx: ManagementContext, accountId?: string): string[] { + return Object.keys(ctx.config.providers).filter(name => ctx.config.providers[name]?.adapter === "zcode" && ctx.config.providers[name]?.zcodeAccountId === accountId); +} + +function providerName(ctx: ManagementContext, accountId?: string): string | undefined { + const matches = providerNames(ctx, accountId); + return matches.length === 1 ? matches[0] : undefined; +} + +/** Read actual persisted catalog evidence, not just a successful protocol or cache flush. */ +export function desktopActivation(ctx: ManagementContext, status: DesktopStatus, readSlugs = readDesktopCatalogSlugs) { + const name = providerName(ctx, status.accountId); + const provider = name ? ctx.config.providers[name] : undefined; + const registered = !!provider && provider.disabled !== true && provider.authMode === "local"; + let catalogReady = false; + try { + const slugs = new Set(readSlugs()); + const expected = registered ? filterCatalogVisibleModels(status.models.map(model => + applyProviderConfigHints(name!, provider!, { id: model.id, provider: name! })), ctx.config) : []; + catalogReady = registered && status.models.length > 0 && expected.every(model => slugs.has(catalogModelSlug(model))); + } catch { /* Unreadable catalog is partial, never success. */ } + return { ...status, providerName: name, providerRegistered: registered, + activation: !status.connected ? "disconnected" : !registered ? "provider_pending" : !catalogReady ? "catalog_pending" : "ready" }; +} + +export async function activateDesktopProvider(ctx: ManagementContext, status: DesktopStatus, readSlugs = readDesktopCatalogSlugs) { + if (!status.connected) return desktopActivation(ctx, status, readSlugs); + let name: string; + try { + withConfigMutationLockSync(() => { + name = providerName(ctx, status.accountId) ?? (status.accountId ? `zcode-${status.accountId}` : "zcode"); + const existing = ctx.config.providers[name]; + if ((!providerName(ctx, status.accountId) && Object.values(ctx.config.providers).some(p => p.adapter === "zcode" && p.zcodeAccountId === status.accountId)) + || (existing && (existing.adapter !== "zcode" || existing.authMode !== "local" || existing.zcodeAccountId !== status.accountId))) throw new Error("provider conflict"); + // Reconnection only enables the existing provider. Never replace its custom settings, + // explicit model filters, pricing, alias or defaults with a form/preset payload. + const next = existing ? { ...existing, disabled: false } + : { adapter: "zcode" as const, authMode: "local" as const, baseUrl: "https://zcode.z.ai", disabled: false, ...(status.accountId ? { zcodeAccountId: status.accountId, modelDisplayNames: Object.fromEntries(status.models.map(m => [m.id, `${readAccount(status.accountId!).label} / ${m.label}`])) } : {}) }; + try { + ctx.config.providers[name] = next; + (ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(ctx.config); + } catch (error) { + if (existing) ctx.config.providers[name] = existing; else delete ctx.config.providers[name]; + throw error; + } + }); + reconcileLiveStateStores(); + clearModelCache(name!); + } catch { + return { ...desktopActivation(ctx, status, readSlugs), activation: "provider_pending", error: "provider_registration_failed" }; + } + try { + const result = await ctx.convergeCodexCatalog(); + const current = desktopActivation(ctx, status, readSlugs); + if (result.status !== "committed" || current.activation !== "ready") { + return { ...current, activation: "catalog_pending", error: "catalog_update_failed" }; + } + return current; + } catch { + return { ...desktopActivation(ctx, status, readSlugs), activation: "catalog_pending", error: "catalog_update_failed" }; + } +} + +/** Revoke catalog visibility without deleting customized provider settings. */ +export async function deactivateDesktopProvider(ctx: ManagementContext, status: DesktopStatus, readSlugs = readDesktopCatalogSlugs) { + const names = providerNames(ctx, status.accountId); + if (names.length) { + try { + const enabled = names.filter(name => ctx.config.providers[name]?.disabled !== true); + if (enabled.length) { + withConfigMutationLockSync(() => { + const previous = new Map(enabled.map(name => [name, ctx.config.providers[name]!])); + try { + for (const [name, provider] of previous) ctx.config.providers[name] = { ...provider, disabled: true }; + (ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(ctx.config); + } catch (error) { + for (const [name, provider] of previous) ctx.config.providers[name] = provider; + throw error; + } + }); + reconcileLiveStateStores(); + } + for (const name of names) clearModelCache(name); + } catch { + return { ...desktopActivation(ctx, status, readSlugs), error: "provider_registration_failed" }; + } + } + try { + const result = await ctx.convergeCodexCatalog(); + if (result.status !== "committed") { + return { ...desktopActivation(ctx, status, readSlugs), error: "catalog_update_failed" }; + } + return desktopActivation(ctx, status, readSlugs); + } catch { + return { ...desktopActivation(ctx, status, readSlugs), error: "catalog_update_failed" }; + } +} diff --git a/src/server/management/zcode-desktop-routes.ts b/src/server/management/zcode-desktop-routes.ts new file mode 100644 index 0000000000..c43e42ed39 --- /dev/null +++ b/src/server/management/zcode-desktop-routes.ts @@ -0,0 +1,63 @@ +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; +import { connectDesktop, desktopStatus, desktopFolders, DesktopSetupError, disconnectDesktop, verifyDesktopProtocol } from "../../adapters/zcode/desktop"; +import { invalidateCodexModelsCache } from "../../codex/catalog"; +import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch"; +import { readBoundedJsonRequestBody } from "../request-decompress"; +import { activateDesktopProvider, deactivateDesktopProvider, desktopActivation, readDesktopCatalogSlugs } from "./zcode-desktop-activation"; + +const services = { connectDesktop, desktopStatus, disconnectDesktop, verifyDesktopProtocol, readDesktopCatalogSlugs }; +let activating = false; +let testing = false; +export async function handleZcodeDesktopRoutes(ctx: ManagementContext, deps = services): Promise { + const { req, url } = ctx; + if (!url.pathname.startsWith("/api/zcode-desktop")) return null; + // Runtime/workspace paths are local-machine metadata. Every Desktop endpoint requires an + // actual GUI session; an admin token may manage providers but must not inspect host paths. + if (ctx.principal !== "gui-session") return jsonResponse({ error: "dashboard_required" }, 403); + if (url.pathname === "/api/zcode-desktop" && req.method === "GET") return jsonResponse(desktopActivation(ctx, deps.desktopStatus(), deps.readDesktopCatalogSlugs)); + if (url.pathname === "/api/zcode-desktop/folders" && req.method === "GET") { + try { return jsonResponse(desktopFolders(url.searchParams.get("path") ?? undefined)); } + catch { return jsonResponse({ error: "workspace_invalid" }, 400); } + } + if (req.method !== "POST") return null; + try { + const raw = await readBoundedJsonRequestBody(req, 12_000, undefined, { signal: AbortSignal.any([req.signal, AbortSignal.timeout(5_000)]) }); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return jsonResponse({ error: "invalid_request" }, 400); + const body = raw as Record; + if (url.pathname === "/api/zcode-desktop/connect" && req.method === "POST" + || url.pathname === "/api/zcode-desktop/activate" && req.method === "POST") { + if (body.consent !== true || typeof body.runtime !== "string" || typeof body.workspace !== "string") return jsonResponse({ error: "consent_required" }, 400); + if (activating) return jsonResponse({ error: "busy" }, 409); + activating = true; + try { + const status = url.pathname.endsWith("/activate") ? deps.desktopStatus() : await deps.connectDesktop(body.runtime, body.workspace); + if (url.pathname.endsWith("/activate") && (status.runtime !== body.runtime || status.workspace !== body.workspace)) return jsonResponse({ error: "invalid_request" }, 409); + clearGatherRoutedModelsInflight(); + return jsonResponse(await activateDesktopProvider(ctx, status, deps.readDesktopCatalogSlugs)); + } finally { activating = false; } + } + if (url.pathname === "/api/zcode-desktop/disconnect" && req.method === "POST") { + if (activating) return jsonResponse({ error: "busy" }, 409); + activating = true; + try { + await deps.disconnectDesktop(); invalidateCodexModelsCache(); clearGatherRoutedModelsInflight(); + return jsonResponse(await deactivateDesktopProvider(ctx, deps.desktopStatus(), deps.readDesktopCatalogSlugs)); + } finally { activating = false; } + } + if (url.pathname === "/api/zcode-desktop/test" && req.method === "POST") { + if (testing) return jsonResponse({ error: "busy" }, 409); + const status = deps.desktopStatus(); + if (body.consent !== true || !status.connected || !status.models.some(m => m.id === body.model)) return jsonResponse({ error: "invalid_request" }, 400); + testing = true; + try { + await deps.verifyDesktopProtocol(String(body.model), AbortSignal.any([req.signal, AbortSignal.timeout(35_000)])); + return jsonResponse({ ok: true }); + } finally { testing = false; } + } + return jsonResponse({ error: "not_found" }, 404); + } catch (e) { + return jsonResponse({ error: e instanceof DesktopSetupError ? e.code + : url.pathname === "/api/zcode-desktop/test" ? "protocol_failed" : "runtime_failed" }, 400); + } +} diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index b619d162e8..2043814151 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -1,4 +1,5 @@ import type { CursorEffortTable } from "../integrations/cursor-effort-table"; +import type { OcxComboConfig, OcxConfig, OcxProviderConfig } from "../types"; /** * Extended capability advertisement for the OpenAI-shape `GET /v1/models` list. @@ -105,6 +106,8 @@ export function predictCursorEffort( } export interface ModelCapabilityInput { + /** False for agent-owned transports that cannot execute the caller's tool catalog. */ + supportsToolUse?: boolean; reasoningEfforts?: readonly string[]; contextWindow?: number; /** @@ -126,7 +129,7 @@ export interface ModelCapabilityFields { /** Cursor's extended-row filter REQUIRES this to contain "text"; every route emits text. */ output_modalities: string[]; input_modalities?: string[]; - supports_tool_use: true; + supports_tool_use: boolean; supports_streaming: true; supports_reasoning: boolean; supports_vision?: boolean; @@ -140,6 +143,16 @@ export interface ModelCapabilityFields { pricing?: { overrides: Array<{ min_prompt_tokens: number }> }; } +/** Agent-owned ZCode targets cannot execute the caller's tool catalog, directly or via a combo. */ +export function catalogRowSupportsToolUse( + provider: OcxProviderConfig | undefined, + combo: OcxComboConfig | undefined, + providers: OcxConfig["providers"], +): boolean { + return provider?.adapter !== "zcode" + && combo?.targets.some(target => providers[target.provider]?.adapter === "zcode") !== true; +} + function positiveInt(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; const floored = Math.floor(value); @@ -168,7 +181,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil // include "text"; omitting the key drops the row from the extended catalog. output_modalities: ["text"], ...(modalities !== undefined && modalities.length > 0 ? { input_modalities: [...modalities] } : {}), - supports_tool_use: true, + supports_tool_use: input.supportsToolUse !== false, supports_streaming: true, supports_reasoning: efforts.length > 0, ...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3fc8917c5d..0b9ceac278 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -29,6 +29,7 @@ import { bindReasoningReplayScope, commitReasoningReplayServingIdentity, reasoningReplayCodexCredentialIdentity, + reasoningReplayCredentialIdentity, reasoningReplayDestinationIdentity, durableReplayDestinationIdentity, durableReplayCredentialIdentity, @@ -484,7 +485,7 @@ const runTurnAdapterSseResponses = new WeakSet(); * Adapters whose continuation state must survive Codex's store:false requests. */ export function adapterNeedsForcedContinuation(name: string): boolean { - return name === "kiro" || name === "cursor"; + return name === "kiro" || name === "cursor" || name === "zcode"; } export function sidecarOutcomeRecorder( @@ -633,6 +634,14 @@ function bindRouteReasoningReplayScope(args: { provider.headers, durableSalt, ); + } else if (provider.authMode === "local" && adapterName === "zcode") { + // ZCode credentials stay inside the official local runtime. The non-secret provider/account + // slot is enough to owner-fence persisted continuation metadata; the adapter's runtime scope + // independently rejects sessions after a Desktop reconnect or account/profile change. + credentialIdentity = reasoningReplayCredentialIdentity( + "local", + provider.zcodeAccountId ?? "desktop", + ); } else if (provider.authMode !== "local") { credentialIdentity = reasoningReplayKeyCredentialIdentity(provider); credentialDurableIdentity = durableReplayCredentialIdentity( @@ -4765,12 +4774,16 @@ async function handleResponsesInner( } let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined; + // Native agents retain tool execution. An explicit vision-only capability allows the + // configured describer to turn input images into text, without enabling other helper tools. + const nativeAgentOwnsExecution = adapter.allowExternalSidecars === false; + const allowsVisionSidecar = !nativeAgentOwnsExecution || adapter.allowVisionSidecar === true; const visionDescribeTerminal = options.visionDescribeTerminal === true; const routedCompaction = parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(route.provider); - const needsOpenAiVision = !visionDescribeTerminal + const needsOpenAiVision = allowsVisionSidecar && !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); - const needsOpenAiSearch = !routedCompaction && !adapter.runTurn + const needsOpenAiSearch = !nativeAgentOwnsExecution && !routedCompaction && !adapter.runTurn && (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough) || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough)); if (needsOpenAiVision || needsOpenAiSearch) { @@ -4836,7 +4849,7 @@ async function handleResponsesInner( // call must never plan another describe. The flag arrives from the Chat // surface (whose bridge rebuilds headers) or as the raw header for native // Responses callers. Marked + text-only routed model → strip, depth cap 1. - const visionPlan = visionDescribeTerminal + const visionPlan = visionDescribeTerminal || !allowsVisionSidecar ? undefined : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, { admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, @@ -4851,7 +4864,7 @@ async function handleResponsesInner( recordSidecarOutcome, translatorBudget, ); - } else if (isModelTextOnly(route.provider, route.modelId)) { + } else if (allowsVisionSidecar && isModelTextOnly(route.provider, route.modelId)) { // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar // disabled): fail closed — never forward raw images to a text-only upstream. stripImagesInPlace(parsed, translatorBudget); @@ -6815,13 +6828,13 @@ async function handleResponsesInner( // - non-runTurn: web-search wins over image when both eligible (documented priority) // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn // can proceed for web-search-only turns - const wsPlan = !routedCompaction + const wsPlan = !routedCompaction && !nativeAgentOwnsExecution ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, }) : undefined; - const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; - const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; + const imgPlan = !routedCompaction && !nativeAgentOwnsExecution ? await planImageBridge(config, parsed, route.provider) : undefined; + const vidPlan = !routedCompaction && !nativeAgentOwnsExecution ? await planVideoBridge(config, parsed, route.provider) : undefined; const canRunWebSearch = !!wsPlan && !adapter.runTurn; const rotateSidecarProviderOn429 = async ( retryAfter: string | null, @@ -7121,6 +7134,7 @@ async function handleResponsesInner( // intentionally outside this guard and retain their existing one-send wire behavior. const emptyCompletionGuardEnabled = emptyCompletionRetryEnabled(config) + && adapter.replaySafe !== false && !options.comboAttempt && !routedCompaction; diff --git a/src/types/provider.ts b/src/types/provider.ts index 49c9132b0a..722e86bda4 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -237,6 +237,8 @@ export interface ModelCapabilities { } export interface OcxProviderConfig { + /** Explicit manual ZCode account binding; never participates in an implicit pool. */ + zcodeAccountId?: string; /** Optional short provider namespace used only at request/catalog presentation time. */ alias?: string; /** Native model id -> short, slash-free request alias. */ diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index bec455fc7d..c1123d6e8d 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -77,9 +77,13 @@ type EnrichedProviderCache = Map; * not a text-only model and must not be widened to image through the vision sidecar. */ export function isModelVisionSidecarConsumer( - provider: Pick, + provider: Pick + & Partial>, modelId: string, ): boolean { + // This transport accepts text only, regardless of a vendor model's native capability. + // Keep catalog, input adaptation and describer eligibility aligned for every account/alias. + if (provider.adapter === "zcode") return true; const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId) ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined; if (declared !== undefined) return declared.includes("text") && !declared.includes("image"); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 1952e3615f..c2297c6c1f 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,6 +63,20 @@ changing contract inheritance or generic Responses Lite handling; see ## Extension policy +`zcode` uses the direct `zcode` wire and `agent-owned-with-explicit-opt-in` mutation contract. +Unlike routed function tools, native ZCode actions are informational output only. Its `runTurn` +sets `replaySafe: false`; accepted failures terminate incomplete rather than becoming automatic +failover candidates. Launcher authority comes from either the operator environment or a separately persisted, +GUI-consented Desktop connection. Data-plane requests and ordinary provider configuration cannot +set command/workspace paths. The managed Desktop bootstrap keeps credential-bearing runtime +descriptors inside the official child process; the parent sees public model identities only. +Subscription quota is separately read through the official Desktop host entitlement RPC in a +short-lived private profile copy, optionally inside Bubblewrap when enabled. Managed quota cache +identity reuses the runtime already resolved and validated while loading Desktop settings instead +of repeating synchronous host process discovery. Only numeric quota windows leave that process. An advanced launcher +requires explicit `OCX_ZCODE_DESKTOP_RUNTIME` authority and reuses its own isolated model key; +quota discovery must not silently import another Desktop account or affect routing policy. + Adding a production adapter requires: 1. one `ADAPTER_REGISTRY` entry with its factory; @@ -141,3 +155,97 @@ medium/high/max UID before accepting a suffix already present in the model id. The merged `devin` provider uses this resolver for every account, whichever login path minted the credential. Omitted effort preserves an explicit variant; unrelated model families retain their existing suffix precedence. + +ZCode native tool execution in `src/adapters/zcode/desktop.ts` uses host user permissions by default, +not client-side tool dispatch. `OCX_ZCODE_SANDBOX=1` explicitly enables the optional +Bubblewrap workspace boundary; harness restrictions apply where the native process runs. Explicitly +consented managed connections use the official runtime's non-interactive `yolo` mode because the +bridge cannot project interactive approval prompts; advanced operator launchers retain `edit` mode. +The managed host bootstrap writes an official `PreToolUse` process hook into the disposable private +ZCode config. It preserves Bash input and deterministically sets the supported +`dangerouslyDisableSandbox=true` field; this is configuration through the vendor hook surface, not +a patch to the official runtime. ZCode's file-oriented tools remain workspace-scoped, so a +bridge-owned reminder directs outside-workspace access through Bash before reporting a host path +absent. The optional Bubblewrap path and advanced launchers receive neither the hook nor reminder. +The host child inherits only a named allowlist of tool environment variables and keeps ZCode-owned +credentials/session state under its private data root. Because the official app server has no +config-path option, a one-shot Node preload redirects only its internal `os.homedir()` lookup to a +turn-scoped private home; the process environment retains the user's real `HOME`, so native host +tools work without copying unrelated provider secrets into the child. On cancellation, the host +bootstrap signals the official runtime and inherited native tools as one POSIX process group, +escalates to a bounded forced stop, and removes the disposable turn home before returning. +The dashboard's optional recheck repeats only model-catalog and `workspace/readState` protocol +operations; it never creates or sends a model turn, starts a native tool or consumes inference quota. + +## ZCode saved accounts + +`src/adapters/zcode/accounts.ts` stores private UUID-scoped metadata and official profiles. +`src/adapters/zcode/native-oauth.ts` and `src/adapters/zcode/oauth-bootstrap.cjs` invoke only +ZCode's installed host OAuth/credential services; authorization URLs and safe stage codes are +projected to the browser, never tokens. The official cached-session restore and Coding Plan +refresh run before account use. Disabled/unavailable vendor profiles remain unavailable. + +The provider's `zcodeAccountId` is an exact binding through routing, catalog discovery, +app-server settings, session/DB scope and quota reads. An invalid or revoked binding fails +closed, never to the legacy Desktop profile or another account. Legacy unbound `zcode` +retains its previous profile. Account providers do not participate in an implicit pool. +Native tools still run on the host by default; optional OS sandboxing is independent of +profile separation and does not turn the latter into a security boundary. Refresh failures +cross the adapter boundary only as bounded public codes, never filesystem or account paths. An +expired refresh reserves the account before waiting for an active official turn to close, then +revalidates account/connection state. The adapter checks the official refresh both before joining +the stable profile queue and again after acquiring it; if the short cache expired during a long +wait, it reloads the refreshed generation before dispatch instead of using the earlier snapshot. A caller +waiting on that shared official refresh races only its own abort signal and returns promptly without +cancelling the refresh for sibling requests. Authenticated account operations also delete valid +hidden new-account and reconnect drafts whose in-memory job disappeared after restart, while preserving active +drafts and visible accounts. Active standalone drafts reserve their eventual account-limit slot without becoming +visible; restart reconciliation releases abandoned reservations. A failed OAuth job continues to +own its hidden draft and capacity reservation until explicit cancellation or bounded expiry, so a +retry cannot stack drafts for the same account. Global Desktop controls are hidden for account-bound provider settings, +transient completion failures keep the OAuth job authenticated for retry, and canonical +global-default aliases remap to the account default before validation. +Provider activation updates config and catalog without invalidating the quota view; entitlement +refresh remains a separate, explicitly requested action rather than a side effect of connection. +Account-list snapshots reuse one Desktop runtime discovery and one persisted-catalog read across all +saved accounts. Removal checks only route-bearing selector fields (including aliases and provider +reviewer targets), then revokes the official profile before deleting provider/catalog state. A later +durability or catalog failure remains visible as a bounded partial-removal state; retry is idempotent +and cannot reconnect or select another account. +Native turn admission retains a 32-request process ceiling and a 24-request per-profile ceiling, +so one stalled profile cannot consume every reservation needed by independent saved accounts. +Closing saved-account clients remain registered as busy until the direct bootstrap actually exits; +concurrent close callers share the same shutdown promise, so refresh, reconnect and removal cannot +enter while inherited native tools are still unwinding. +Headerless `previous_response_id` chains persist owner-fenced ZCode state even with `store:false`; +the non-secret local account slot identifies the owner, while the connection/profile generation in +the adapter scope rejects stale sessions after reconnect. Internal Codex compaction always starts a +fresh official session with an empty tool allowlist, publishes no replacement ZCode session, and +fails closed on an unexpected tool event. `session/send` is sized after JSON serialization before +the native client starts, matching the managed bootstrap's one-MiB NDJSON limit. +Advanced model descriptors reject loopback, private and link-local destinations before the +official runtime receives them. +Advanced launcher scope includes a bounded content generation for its isolated configuration and +rejects a stale generation at model-read time; queued turns recheck the complete scope before +starting a child, so an in-place account or credential change cannot resume the prior session. +Account removal recognizes both the generated provider name and its configured alias in routed +selectors outside the provider record. Activation/catalog readiness applies the same +`selectedModels` and `disabledModels` visibility policy as catalog convergence, so intentionally +hidden Desktop models do not leave a connection permanently pending. + +## ZCode vision input adaptation + +`src/adapters/base.ts` exposes a vision-only exception to the native-agent sidecar gate. +ZCode sets `allowVisionSidecar: true` while retaining `allowExternalSidecars: false`. +`src/server/responses/core.ts` resolves the configured vision helper and rewrites images before +calling the official agent; search/image/video generation remain native-agent-owned. +`src/vision/eligibility.ts` classifies every ZCode transport model as a sidecar consumer, +including renamed/account-bound providers and Flash. The shared catalog predicate advertises +sidecar-backed image input and excludes these models from describer selection. Disabled or +unavailable vision uses explicit omission markers; recursion protection and quota/cancellation +bounds remain in the shared vision path. This does not implement native image support in ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. diff --git a/structure/catalog.md b/structure/catalog.md index 8cce6268b0..77b624189a 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -336,3 +336,20 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara ## Renamed destination reasoning metadata `src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. +Live Desktop discovery uses the official model label only as a fallback; configured +`modelDisplayNames` remain authoritative for renamed accounts and operator customizations. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](adapters/registry.md#zcode-saved-accounts). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 76fef247f4..ca870a6419 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -145,3 +145,18 @@ The [explicit model-capability contract](../config.md#explicit-per-model-capabil Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/codex-home.md b/structure/codex-home.md index 1f1edfd7da..035f91ea0f 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -273,3 +273,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. diff --git a/structure/config.md b/structure/config.md index 9a07952c4e..525e9a7dfd 100644 --- a/structure/config.md +++ b/structure/config.md @@ -273,3 +273,9 @@ Display-name validation retains prototype-shaped model IDs as data; reviewer-tar `modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. +Their process-local continuation owner is derived from the explicit account slot rather than any +Desktop credential; stale runtime generations still fail closed in the adapter scope. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index fdbdee6b32..b658cdd0d6 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -118,3 +118,18 @@ Live sideband admission and its bounded upstream handshake follow the [runtime c The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 8b27dd5688..4168e43cfa 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -247,3 +247,24 @@ Translated Chat request construction uses the [inline-image budget](../transport The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. + +ZCode native tool execution in `src/adapters/zcode/desktop.ts` uses host user permissions by default, +not client-side tool dispatch. `OCX_ZCODE_SANDBOX=1` explicitly enables the optional +Bubblewrap workspace boundary; harness restrictions apply where the native process runs. +Managed host cancellation uses a bounded process-group TERM-to-KILL ladder for the official runtime +and inherited native tools, then removes the disposable turn home before returning. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. A cancelled caller stops waiting for a shared official account refresh without cancelling the refresh for other requests, and the next authenticated account operation removes reconnect drafts orphaned by restart. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 81975c08e4..d3f58a08b1 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -52,3 +52,12 @@ The pairing panel names the hub, offers an origin-specific command to run on tha Cline uses the existing file-integration page, tabs, status badge and rollback dialogs. Its localized semantics identify both files and the required stop/restart boundary before users mutate them. Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c445858e6b..affed6a97f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -131,7 +131,7 @@ this document owns is which module holds which area and what invariant that area | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | -| Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | +| Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. A ZCode test with an empty local model catalog reports explicit failure and no success message. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | @@ -594,7 +594,7 @@ Live sideband admission and its bounded upstream handshake follow the [runtime c Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. - Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. - The raw provider editor round-trips `autoReviewModel` and `autoReviewModelOverrides` through editor-owned DTO fields. POST/PATCH/PUT share validation; PUT copies schema-normalized values into the persisted and live candidate before adoption. Canonical `openai` rejects these fields, including clear forms. Field-masked writes (PATCH, editor PUT, reload) pin every registry-seed key and ignore operator overlays the seed never defines, most commonly `selectedModels`; POST keeps the exact-key comparison. Canonical `openai` still rejects `allowPrivateNetwork`, which must not short-circuit destination DNS checks on the ChatGPT forward row. Existing authentication, origin checks and stale-baseline protection still govern the writes. See [reviewer projection](catalog.md#provider-scoped-approval-reviewer). +## ZCode manual account lifecycle +`gui/src/components/ZcodeAccountsPane.tsx` and `src/server/management/zcode-account-routes.ts` own GUI-session-only saved-account login, polling, activation retry, rename and removal. Explicit consent starts the official OAuth job in a private profile; canonical aliases of the displayed managed workspace are rebound and validated in the eventual account scope before OAuth begins. Completion verifies identity/protocol, registers one unique account-bound provider and converges the catalog without changing defaults, sending inference or forcing an entitlement refresh from the caller's stale configuration snapshot. Any successful activation response treats the secondary local account-list refresh as best effort independently of a parent callback; transient completion errors keep the job authenticated for polling retry. If partial completion succeeds server-side but the local account refresh fails, the pane retains the finished job ID and retries idempotent `/complete` directly instead of hiding recovery or repeating OAuth. When these controls run inside either Add Provider or an existing provider's Settings tab, every successful or partial provider/catalog mutation notifies the Providers owner to reload config and refresh model rows; a removal that was persisted before catalog convergence failed performs the same invalidation on its bounded partial error. Failed OAuth jobs retain their hidden draft and capacity reservation until explicit Cancel or bounded expiry, so Add/Reconnect stays disabled and retries cannot stack drafts for the same account. Account-bound provider settings hide the unrelated global Desktop controls. Reconnect preserves custom settings and rejects a different identity; removal refuses clients whose direct bootstrap is still exiting and recognizes generated provider names plus configured provider/model aliases in routed selectors across both top-level routing settings and every provider that remains configured before deleting an account. Authenticated account operations prune valid hidden reconnect drafts whose transient jobs disappeared on restart without touching active drafts or visible accounts. `src/server/management/zcode-desktop-routes.ts` serializes connect, activation and disconnect across their complete provider/catalog transition; disconnect disables the legacy provider and converges its catalog rows while preserving customized settings. Persisted `configured` state keeps Disconnect available even when a missing prerequisite makes the connection temporarily unusable. Activation readiness filters the Desktop roster through the same `selectedModels` and `disabledModels` policy as convergence, so an intentionally hidden row is not a false partial failure. Its optional verification action uses protocol metadata only and cannot start inference or tools. Input images use the configured vision describer as the sole native-agent sidecar and may consume its quota; main inference remains in official ZCode. Exact active-session events, canonical optional-sandbox paths, honest unavailable quota, session-only Desktop metadata and no caller-tool capability for combos harden the boundary. Owner-fenced `store:false` continuation, fresh tool-free compaction sessions and serialized native-frame admission follow [the ZCode runtime contract](adapters/registry.md#zcode-saved-accounts). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 17e54e3664..7faeaba751 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -375,3 +375,11 @@ Exact [model input declarations](../config.md#explicit-per-model-capability-decl Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 8f414780c8..301116b4cb 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -179,3 +179,18 @@ Live sideband admission and its bounded upstream handshake follow the [runtime c The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/overview.md b/structure/overview.md index 0151115adc..11ea13d4be 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -147,3 +147,12 @@ Translated Chat request construction uses the [inline-image budget](transports/s The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 2362e46bcd..4600408e82 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -292,3 +292,24 @@ byte-limit boundaries. Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +ZCode native tool execution in `src/adapters/zcode/desktop.ts` uses host user permissions by default, +not client-side tool dispatch. `OCX_ZCODE_SANDBOX=1` explicitly enables the optional +Bubblewrap workspace boundary; harness restrictions apply where the native process runs. +Managed host cancellation stops the official runtime and inherited native tools as one process +group and cleans the disposable turn home before the outer client can force-stop the bootstrap. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. A cancelled caller stops waiting for a shared official account refresh without cancelling the refresh for other requests, and the next authenticated account operation removes reconnect drafts orphaned by restart. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 55f017685b..d34fe4f19f 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -106,3 +106,24 @@ Combo child requests normalize effort and thinking controls against the selected `src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy. Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +ZCode native tool execution in `src/adapters/zcode/desktop.ts` uses host user permissions by default, +not client-side tool dispatch. `OCX_ZCODE_SANDBOX=1` explicitly enables the optional +Bubblewrap workspace boundary; harness restrictions apply where the native process runs. +Managed host cancellation stops the official runtime and inherited native tools as one process +group and cleans the disposable turn home before the outer client can force-stop the bootstrap. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. A cancelled caller stops waiting for a shared official account refresh without cancelling the refresh for other requests, and the next authenticated account operation removes reconnect drafts orphaned by restart. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index c6d915852d..f66f37c848 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -69,3 +69,7 @@ positive value overwrites an earlier one. Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried `tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently ignored because a credit is not a token count. + +The shared `src/responses/provider-continuation.ts` owner validator also admits process-local +`local:` identities for ZCode's credential-free route binding. Kiro retains its existing OAuth/key +owner derivation; the added identity kind does not broaden Kiro continuation matching. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 527aacba6b..ff41afd0d1 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -518,3 +518,7 @@ The history read API reports a median effective token estimate and interval samp Live bindings obey the existing cache-affinity release policy: with `pool.cacheAffinity`, threshold crossing alone retains a healthy account. Manual preference, scoped health and shared-cursor guards remain authoritative. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibility facade and used by both `/api/pool/settings` and the legacy Codex settings route. Generic and Anthropic parsers reject reset-first. The dashboard offers it only for Codex; API, CLI and translated guides preserve the same contract. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 6213ce1449..265b74f73a 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -57,6 +57,21 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay > Decision record: [ADR-0059](../decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md) +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). + ### Grok Reset Coupons (Billing API Parity) - **Upstream RPCs:** `prod_mc_billing.ConsumerUiSvc/GetRemainingResets` (inspection) and `prod_mc_billing.ConsumerUiSvc/RedeemReset` (redemption). diff --git a/structure/runtime.md b/structure/runtime.md index a09d007d7b..c1c99b7bd0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -350,3 +350,24 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. + +ZCode native tool execution in `src/adapters/zcode/desktop.ts` uses host user permissions by default, +not client-side tool dispatch. `OCX_ZCODE_SANDBOX=1` explicitly enables the optional +Bubblewrap workspace boundary; harness restrictions apply where the native process runs. +Managed host cancellation signals the official runtime and its inherited native-tool process group, +escalates to a bounded forced stop, and cleans the disposable turn home before the client returns. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. A cancelled caller stops waiting for a shared official account refresh without cancelling the refresh for other requests, and the next authenticated account operation removes reconnect drafts orphaned by restart. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](adapters/registry.md#zcode-saved-accounts). diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..32261715d6 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -371,3 +371,18 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](adapters/registry.md#zcode-saved-accounts); adding one never changes defaults or runs inference. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](adapters/registry.md#zcode-saved-accounts). diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 830d1af7a9..bcdf0a5400 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -22,6 +22,13 @@ partial-event, injection/drop, and EOF behavior. Output admission precedes its s failed enqueue and cancellation release the reservation without re-entering a disposed rewrite. Old/new buffer overlap remains charged against the same translator cap. +A saved-account ZCode refresh in `src/adapters/zcode/adapter.ts` reserves the account, waits for an active turn and its direct bootstrap to release the official profile, and completes before native dispatch or output admission. The cache is checked again after the stable profile queue is acquired, so a refresh that becomes due during a long wait reloads settings and model identity before any client is created. Caller cancellation stops only that request's wait on the shared official refresh, emits no native stream bytes, and does not transfer cancellation ownership to sibling requests; authenticated orphan-draft reconciliation is likewise metadata-only, and default-workspace aliases are canonicalized before account-scope validation. Advanced settings are read through the same bounded four-MiB file reader used for model materialization, and the resulting content generation joins the session scope so in-place profile changes cannot retain a prior continuation. Process serialization uses a separate stable physical-profile key, so a credential-generation change cannot open a concurrent queue against the same profile. +The same adapter constructs a maximum-length session-id envelope and measures the actual JSON +serialization of each prospective `session/send` frame before it creates the native client. Frames +over the managed bootstrap's one-MiB NDJSON line limit fail as pre-dispatch input errors; escaped +control characters therefore cannot expand a nominally bounded prompt into an accepted turn that +later dies in the bridge. + `src/adapters/openai-responses.ts` counts new compaction fragments, including surrogate pairs formed across deltas, while retaining snapshot/done/delta precedence and existing terminal ownership. Serialized request and buffered-response observations use byte counts without measurement arrays. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 10b5be6a6b..d08530fead 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -142,3 +142,26 @@ The [explicit model-capability contract](../config.md#explicit-per-model-capabil Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. + +ZCode native tool execution in `src/adapters/zcode/desktop.ts` uses host user permissions by default, +not client-side tool dispatch. `OCX_ZCODE_SANDBOX=1` explicitly enables the optional +Bubblewrap workspace boundary; harness restrictions apply where the native process runs. Managed +connections pair that explicit consent with ZCode's non-interactive permission mode and a bounded +host-tool environment; advanced launchers keep their existing `edit` semantics. Managed host +cancellation terminates the official runtime and inherited tools as one process group, then cleans +the disposable turn home before the outer client hard-stop deadline. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults, runs inference or forces an entitlement probe. An expired refresh reserves the account and waits for an active official turn before refreshing; a cancelled caller stops waiting for that shared work without cancelling it for other requests, and the next authenticated account operation removes reconnect drafts orphaned by restart. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1f1bd3cd2f..24c328f111 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -597,4 +597,4 @@ Translated Chat request construction uses the [inline-image budget](streaming-he The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. -Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. ZCode requests are the explicit native-agent exception: `src/server/responses/core.ts` permits only the configured vision describer for the text-only `src/adapters/zcode/desktop.ts` path, keeps web-search/caller tools with the official runtime, runs managed tools with host-user access unless `OCX_ZCODE_SANDBOX=1`, and leaves saved-account/provider/catalog ownership to [the adapter registry](../adapters/registry.md#zcode-saved-accounts); image description may consume the configured sidecar quota, while main inference remains in official ZCode. Managed host config installs an official private `PreToolUse` Bash hook that applies the supported unsandboxed input deterministically, while a prompt reminder routes outside-workspace access away from workspace-scoped file tools; optional Bubblewrap installs neither. Headerless `previous_response_id` chains force local continuation retention despite `store:false` and bind it to a process-local digest of the explicit ZCode account slot. The adapter's connection/profile-content scope remains the final stale-session fence, while a separate stable physical-profile key serializes every credential generation through one queue. Routed compaction creates a fresh, separate official session with an empty native-tool allowlist, never replaces the main session, and aborts on an unexpected tool event. Managed host cancellation terminates the official runtime's inherited native-tool process group with a bounded TERM-to-KILL ladder and cleans the disposable turn home before returning; a closing saved-account client remains busy until that direct bootstrap exits. An expired saved-account refresh reserves the account and waits for the active official turn before refreshing; a cancelled caller stops waiting for that shared work without cancelling it for other requests. The next authenticated account operation removes reconnect drafts orphaned by restart, default-workspace aliases are canonicalized before account-scope validation, account removal recognizes route aliases, and catalog readiness applies configured model visibility. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 0b54c97058..22991d2bc8 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -249,3 +249,18 @@ Live sideband admission and its bounded upstream handshake follow the [runtime c The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. + +ZCode saved accounts use explicit provider bindings, separate from native OpenAI pools and +client integration exports. Their profile, catalog and transport contract is maintained in +[ZCode saved accounts](../adapters/registry.md#zcode-saved-accounts); adding one never changes defaults, runs inference or forces an entitlement probe. Expired pre-use refreshes reserve the account and wait for active official turns before refreshing. + +For ZCode, input-image description is the explicit exception to native-agent helper exclusion; +see [ZCode vision input adaptation](../adapters/registry.md#zcode-vision-input-adaptation). The configured +vision provider may consume its own quota; main inference remains in official ZCode. + +The hardened ZCode boundary accepts only exact active-session events, canonicalizes protected paths and default-workspace aliases before +optional sandbox validation, distinguishes unavailable quota probes from valid empty entitlements, requires +unique provider bindings and GUI-session-only Desktop metadata, and disables caller-tool capability +for every combo containing a ZCode target. +Managed host Bash policy, close-until-exit profile fencing, alias-aware account removal, visibility-aware activation, continuation ownership, tool-free compaction and serialized-frame admission follow the +[canonical adapter contract](../adapters/registry.md#zcode-saved-accounts). diff --git a/tests/adapters/adapter-buffered-tool-conformance.test.ts b/tests/adapters/adapter-buffered-tool-conformance.test.ts index fd56fc6e25..6270338ab5 100644 --- a/tests/adapters/adapter-buffered-tool-conformance.test.ts +++ b/tests/adapters/adapter-buffered-tool-conformance.test.ts @@ -24,6 +24,7 @@ const WIRE_MODELS: Record = { "openai-responses": "deepseek-v4-flash", cursor: "cursor/auto", codebuddy: "glm-5.3", + zcode: "test/model", }; function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { @@ -37,6 +38,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi "openai-responses": "https://api.deepseek.com", cursor: "https://api2.cursor.sh", codebuddy: "https://www.codebuddy.ai", + zcode: "https://zcode.z.ai", }; const baseUrl = adapterId === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index b0b43c8b19..61a0bb8603 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -10,6 +10,7 @@ import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { + zcode: "zcode", codebuddy: "codebuddy", "command-code": "command-code", "openai-chat": "openai-chat", diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 9471581107..1f6a3c09c2 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -35,6 +35,7 @@ const WIRE_MODELS: Record = { "openai-responses": "deepseek-v4-flash", cursor: "cursor/auto", codebuddy: "glm-5.3", + zcode: "test/model", }; function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { @@ -48,6 +49,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi "openai-responses": "https://api.deepseek.com", cursor: "https://api2.cursor.sh", codebuddy: "https://www.codebuddy.ai", + zcode: "https://zcode.z.ai", }; // Semantic wrappers with provider-specific URL shapes must override the wire-family default here. const baseUrl = adapterId === "mimo-free" @@ -419,8 +421,9 @@ describe("registry-derived routed tool conformance", () => { } }); - const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); - // The Devin adapter is runTurn-only: it streams Connect-RPC from runTurn, so + const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "zcode"]); + // Both Devin providers are runTurn-only: devin-cli drives a local CLI over ACP + // stdio and devin streams Connect-RPC from runTurn, so for both of them // buildRequest returns a placeholder and tools never travel the wire path. // Both Devin provider rows share it and differ only in where the credential // came from. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 9a11c696ac..b98a3bedf3 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -325,6 +325,14 @@ describe("headless GUI parity CLI", () => { ["/api/usage", "ocx observe usage"], ["/api/v2", "ocx v2/agent"], ["/api/windows-tray", "ocx tray"], + // Connecting the official ZCode Desktop runtime and adding its separately + // authenticated accounts are explicit user-consent actions. They require + // a dashboard session by design; the admin-token CLI transport cannot + // authorize them. Folder discovery exists only to support that picker. + // `ocx zcode` is intentionally not listed here: it manages ZCode's client + // config, not OpenCodex's ZCode Desktop provider or account sessions. + ["/api/zcode-desktop", "(none — dashboard-only ZCode Desktop consent flow)"], + ["/api/zcode-accounts", "(none — dashboard-only ZCode account consent flow)"], ]; const uncovered = [...endpoints].filter(endpoint => !coverage.some(([prefix]) => endpoint === prefix || endpoint.startsWith(prefix))); expect(uncovered).toEqual([]); diff --git a/tests/codex-integration/slug-codec.test.ts b/tests/codex-integration/slug-codec.test.ts index 5248b75020..1886347069 100644 --- a/tests/codex-integration/slug-codec.test.ts +++ b/tests/codex-integration/slug-codec.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // 260718: Codex-facing slug codec for providers whose NATIVE model ids contain "/" // (zenmux `moonshotai/kimi-k3-free`, openrouter `anthropic/...`, nvidia `moonshotai/...`). // Codex's models-manager metadata lookup tolerates exactly one "/", so two-slash slugs @@ -404,3 +407,38 @@ describe("#2491 one selection resolver reports what it actually matched", () => expect(match.exact).toBe("turbo"); }); }); + +test("managed ZCode published slugs route with a cold cache and no configured models", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-zcode-routing-")); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + const ids = ["builtin:zai-coding-plan/GLM-5.3", "builtin:zai-coding-plan/GLM-5.3-Flash"]; + const directory = join(home, "zcode-desktop"); mkdirSync(directory); + const connection = { version: 1, connected: true, + generation: "11111111-1111-1111-1111-111111111111", runtime: "/not-launched", + workspace: "/not-opened", models: ids.map(id => ({ + id, providerId: "builtin:zai-coding-plan", modelId: id.split("/")[1], label: id, + })) }; + writeFileSync(join(directory, "connection.json"), JSON.stringify(connection), { mode: 0o600 }); + const config: OcxConfig = { port: 10100, defaultProvider: "openai", + providers: { zcode: { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai" } } }; + const before = JSON.stringify(config); + for (const id of ids) { + clearModelCache(); + const slug = routedSlug("zcode", id); + const route = routeModel(config, slug); + expect(route.providerName).toBe("zcode"); + expect(route.modelId).toBe(id); + } + expect(JSON.stringify(config)).toBe(before); + config.providers.zcode!.models = ["builtin:zai-coding-plan-GLM-5.3"]; + expect(() => routeModel(config, routedSlug("zcode", ids[0]!))).toThrow("ambiguous model id"); + connection.connected = false; + writeFileSync(join(directory, "connection.json"), JSON.stringify(connection), { mode: 0o600 }); + expect(knownModelIdsForProvider("zcode", { adapter: "zcode", baseUrl: "https://zcode.z.ai" })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 145cbfc22b..0762e40085 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1241,7 +1241,12 @@ "yaml-fragment-source.test.ts": "config", "z-fulfill.test.ts": "images", "z-handler-activation.test.ts": "images", + "zcode-adapter.test.ts": "providers", "zcode-client.test.ts": "providers", + "zcode-desktop-routes.test.ts": "providers", + "zcode-desktop.test.ts": "providers", + "zcode-routing.test.ts": "providers", + "zcode-transport.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", @@ -1253,5 +1258,6 @@ "usage-log-ws-stage.test.ts": "usage", "main-device-reauth.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration", - "main-device-reauth-ui.test.ts": "gui" + "main-device-reauth-ui.test.ts": "gui", + "zcode-quota.test.ts": "providers" } diff --git a/tests/helpers/adapter-conformance/wire-drivers.ts b/tests/helpers/adapter-conformance/wire-drivers.ts index d99ea81a6d..0a179c6536 100644 --- a/tests/helpers/adapter-conformance/wire-drivers.ts +++ b/tests/helpers/adapter-conformance/wire-drivers.ts @@ -318,6 +318,11 @@ export const TOOL_WIRE_DRIVERS = { } }, }, + zcode: { + async observeOutbound(): Promise { + throw new Error("ZCode owns native tools and does not forward client tool catalogs."); + }, + }, codebuddy: { // CodeBuddy v1 runs the vendor CLI with `--tools ""` so Codex keeps tool ownership; it forwards // no client tool catalog and is exempt from routed-tool conformance, so this driver is never diff --git a/tests/providers/zcode-adapter.test.ts b/tests/providers/zcode-adapter.test.ts new file mode 100644 index 0000000000..448d6ed82f --- /dev/null +++ b/tests/providers/zcode-adapter.test.ts @@ -0,0 +1,472 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createZcodeAdapter } from "../../src/adapters/zcode/adapter"; +import { loadZcodeSettings, readZcodeModels, type JsonObject, type ZcodeSettings } from "../../src/adapters/zcode/settings"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { catalogRowSupportsToolUse, modelCapabilityFields } from "../../src/server/models-capabilities"; +import { deriveProviderPresets } from "../../src/providers/derive"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture(): ZcodeSettings { + const home = mkdtempSync(join(tmpdir(), "ocx-zcode-test-")); roots.push(home); + const dir = join(home, ".zcode", "cli"); mkdirSync(dir, { recursive: true }); + const settingsPath = join(dir, "config.json"); + writeFileSync(settingsPath, JSON.stringify({ provider: { + test: { name: "Test", kind: "anthropic", options: { baseURL: "https://example.invalid", apiKey: "never-log-this" }, + models: { model: { limit: { context: 1000 } } } }, + opencodex: { models: { recursive: {} } }, + alias: { options: { baseURL: "https://127.0.0.1/v1" }, models: { recursive: {} } }, + linkLocal: { options: { baseURL: "https://169.254.1.2/v1" }, models: { metadata: {} } }, + disabled: { enabled: false, models: { hidden: {} } }, + } })); + return { command: ["/isolated-launcher"], home, workspace: "/workspace", settingsPath, lockKey: home, scope: home }; +} +class FakeClient { + onEvent: (event: JsonObject) => void = () => {}; + onFailure: (error: Error) => void = () => {}; + calls: Array<{ method: string; params: JsonObject }> = []; + closed = false; + private nativeTools = true; + constructor(private outcome: "ok" | "failed" | "hang" | "session-noise" | "answerless" | "tool-during-compaction" = "ok") {} + async request(method: string, params: JsonObject): Promise { + this.calls.push({ method, params }); + if (method === "session/create") { + this.nativeTools = !(Array.isArray(params.toolAllowlist) && params.toolAllowlist.length === 0); + return { session: { sessionId: "sess_test-1" } }; + } + if (method === "session/send") { + if (this.outcome === "session-noise") { + queueMicrotask(() => { + this.onEvent({ method: "session/event", params: { type: "turn.completed", payload: { response: "wrong session" } } }); + this.onEvent({ method: "session/event", params: { sessionId: "sess_other", type: "turn.completed", payload: { response: "wrong session" } } }); + }); + setTimeout(() => { + this.event("model.streaming", { kind: "text_delta", delta: "Hello" }); + this.event("turn.completed", { response: "Hello" }); + }, 5); + return {}; + } + queueMicrotask(() => { + if (this.outcome === "hang") return; + this.event("model.streaming", { kind: "reasoning_delta", delta: "Thinking" }); + if (this.nativeTools || this.outcome === "tool-during-compaction") { + this.event("tool.updated", { kind: "started", toolCallId: "tool-1" }); + this.event("tool.updated", { kind: "result", toolCallId: "tool-1" }); + } + if (this.outcome === "failed") return this.event("turn.failed", { error: { message: "secret" } }); + if (this.outcome === "answerless") return this.event("turn.completed", {}); + this.event("model.streaming", { kind: "text_delta", delta: "Hello" }); + this.event("turn.completed", { response: "Hello" }); + }); + } + return {}; + } + event(type: string, payload: JsonObject) { + this.onEvent({ method: "session/event", params: { sessionId: "sess_test-1", type, payload } }); + } + async close() { this.closed = true; } +} +const provider: OcxProviderConfig = { adapter: "zcode", baseUrl: "https://zcode.z.ai", authMode: "local" }; +const request = (): OcxParsedRequest => ({ modelId: "test/model", stream: true, options: {}, + context: { messages: [{ role: "user", content: "Say hello", timestamp: 0 }] } }); +async function run(settings: ZcodeSettings, client: FakeClient, parsed = request(), signal?: AbortSignal, timeoutMs = 40) { + const events: AdapterEvent[] = []; + const adapter = createZcodeAdapter(provider, { settings: () => settings, client: () => client, timeoutMs }); + await adapter.runTurn!(parsed, { headers: new Headers(), abortSignal: signal, + translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + return events; +} + +describe("ZCode local agent", () => { + test("managed requests select a public model without carrying Desktop secrets", async () => { + const settings = { ...fixture(), hostExecution: true, nativePermissionMode: "yolo" as const, + desktopModels: [{ id: "test/model", providerId: "test", modelId: "model", label: "Model" }] }; + const client = new FakeClient(); + expect((await run(settings, client)).at(-1)?.type).toBe("done"); + for (const call of client.calls.filter(c => ["session/create", "session/send"].includes(c.method))) { + expect(call.params._zcodeModel).toEqual({ providerId: "test", modelId: "model" }); + expect(call.params.runtimeModel).toBeUndefined(); + const serialized = JSON.stringify(call.params); + expect(serialized).not.toContain("never-log-this"); + expect(serialized).not.toContain("apiKey"); + } + expect(client.calls.find(call => call.method === "session/create")?.params.mode).toBe("yolo"); + expect(String(client.calls.find(call => call.method === "session/send")?.params.content)) + .toContain("configured through ZCode's official hook"); + expect(String(client.calls.find(call => call.method === "session/send")?.params.content)) + .toMatch(/^\[OpenCodex bridge capability:.*\[OpenCodex bridge reminder:/s); + const advancedClient = new FakeClient(); + expect((await run(fixture(), advancedClient)).find(event => event.type === "text_delta" + && event.phase === "commentary")?.text).toContain("configured launcher"); + expect(String(advancedClient.calls.find(call => call.method === "session/send")?.params.content)) + .not.toContain("managed host-execution setting"); + expect((await run(settings, new FakeClient())).find(event => event.type === "text_delta" + && event.phase === "commentary")?.text).toContain("host-user access"); + }); + test("maps Codex effort labels to the official GLM-5.3 thought levels", async () => { + const settings = { ...fixture(), desktopModels: [{ + id: "builtin:zai-coding-plan/GLM-5.3", providerId: "builtin:zai-coding-plan", + modelId: "GLM-5.3", label: "GLM-5.3", + }] }; + for (const [requested, expected] of [["low", "low"], ["medium", "high"], ["high", "high"], + ["xhigh", "max"], ["max", "max"], ["ultra", "max"]] as const) { + const client = new FakeClient(); + const parsed = request(); parsed.modelId = settings.desktopModels[0]!.id; parsed.options.reasoning = requested; + expect((await run(settings, client, parsed)).at(-1)?.type).toBe("done"); + expect(client.calls.find(call => call.method === "session/create")?.params.thoughtLevel).toBe(expected); + expect(client.calls.find(call => call.method === "session/send")?.params.thoughtLevel).toBeUndefined(); + } + }); + test("a revoked or changed managed connection cannot start a queued child", async () => { + const settings = { ...fixture(), desktopModels: [{ id: "test/model", providerId: "test", modelId: "model", label: "Model" }] }; + let reads = 0; let children = 0; + const adapter = createZcodeAdapter(provider, { settings: () => ({ ...settings, scope: ++reads === 1 ? "before" : "after" }), client: () => { children++; return new FakeClient(); } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + expect(children).toBe(0); + expect(events.at(-1)?.type).toBe("error"); + }); + test("saved accounts refresh again after waiting in the physical-profile queue", async () => { + const before = fixture(); + const after = { ...before, scope: before.scope + ":refreshed" }; + const first = new FakeClient("hang"); + const firstController = new AbortController(); + const firstTurn = run(before, first, request(), firstController.signal, 2_000); + await Bun.sleep(5); + expect(first.calls.some(call => call.method === "session/send")).toBe(true); + + let refreshCalls = 0; + let refreshed = false; + let dispatchedScope = ""; + const second = new FakeClient(); + const adapter = createZcodeAdapter({ ...provider, zcodeAccountId: crypto.randomUUID() }, { + settings: () => refreshed ? after : before, + refreshAccount: async () => { + refreshCalls++; + if (refreshCalls === 2) refreshed = true; + return refreshed; + }, + client: settings => { dispatchedScope = settings.scope; return second; }, + timeoutMs: 2_000, + }); + const events: AdapterEvent[] = []; + const secondTurn = adapter.runTurn!(request(), { headers: new Headers(), + translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Bun.sleep(10); + expect(refreshCalls).toBe(1); + expect(second.calls).toEqual([]); + + firstController.abort(); + expect((await firstTurn).at(-1)?.type).toBe("incomplete"); + await secondTurn; + expect(refreshCalls).toBe(2); + expect(dispatchedScope).toBe(after.scope); + expect(events.at(-1)?.type).toBe("done"); + }); + test("has no direct HTTP inference path or client-tool capability", () => { + const adapter = createZcodeAdapter(provider); + expect(adapter.fetchResponse).toBeUndefined(); + expect(adapter.replaySafe).toBe(false); + expect(adapter.allowExternalSidecars).toBe(false); + expect(adapter.allowVisionSidecar).toBe(true); + expect(() => adapter.buildRequest(request(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() })) + .toThrow("not HTTP"); + expect(modelCapabilityFields({ supportsToolUse: false }).capabilities.supports_tool_use).toBe(false); + const comboSupportsTools = catalogRowSupportsToolUse(undefined, { targets: [{ provider: "zcode", model: "model" }] }, { zcode: provider }); + expect(comboSupportsTools).toBe(false); + expect(modelCapabilityFields({ supportsToolUse: comboSupportsTools }).capabilities.supports_tool_use).toBe(false); + expect(catalogRowSupportsToolUse(undefined, { targets: [{ provider: "other", model: "model" }] }, { + other: { adapter: "openai-chat", baseUrl: "https://example.invalid" }, + })).toBe(true); + expect(deriveProviderPresets().find(p => p.id === "zcode")).toMatchObject({ auth: "local", adapter: "zcode" }); + }); + test("requires explicit operator opt-in and an argv launcher, never a shell string", () => { + expect(() => loadZcodeSettings({})).toThrow("disabled"); + expect(() => loadZcodeSettings({ OCX_ZCODE_NATIVE_TOOLS: "1", OCX_ZCODE_COMMAND: "echo unsafe" })).toThrow("JSON argv"); + }); + test("managed host policy cannot turn an empty request into a native dispatch", async () => { + const settings = { ...fixture(), hostExecution: true, + desktopModels: [{ id: "test/model", providerId: "test", modelId: "model", label: "Model" }] }; + const client = new FakeClient(); + const parsed = request(); parsed.context.messages = []; + const events = await run(settings, client, parsed); + expect(events.at(-1)).toMatchObject({ type: "error", message: "ZCode input is empty." }); + expect(client.calls).toEqual([]); + }); + test("saved-account refresh failures emit only bounded public codes", async () => { + const events: AdapterEvent[] = []; + const adapter = createZcodeAdapter({ ...provider, zcodeAccountId: crypto.randomUUID() }, { + refreshAccount: async () => { throw new Error("/private/config/zcode-accounts/identity/account.json"); }, + }); + await adapter.runTurn!(request(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", message: "account_refresh_failed", retryable: false }); + expect(JSON.stringify(events)).not.toContain("/private/"); + expect(JSON.stringify(events)).not.toContain("account.json"); + }); + test("saved-account refresh preserves actionable public codes", async () => { + for (const code of ["account_login_required", "account_identity_mismatch", "native_oauth_failed"]) { + const events: AdapterEvent[] = []; + const adapter = createZcodeAdapter({ ...provider, zcodeAccountId: crypto.randomUUID() }, { + refreshAccount: async () => { throw new Error(code); }, + }); + await adapter.runTurn!(request(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", message: code, retryable: false }); + } + }); + test("saved-account cancellation stops only the caller waiting on a shared refresh", async () => { + const shared = Promise.withResolvers(); + let refreshFinished = false; let children = 0; + const work = shared.promise.finally(() => { refreshFinished = true; }); + const adapter = createZcodeAdapter({ ...provider, zcodeAccountId: crypto.randomUUID() }, { + refreshAccount: async () => work, + client: () => { children++; return new FakeClient(); }, + }); + const events: AdapterEvent[] = []; + const controller = new AbortController(); + const pending = adapter.runTurn!(request(), { headers: new Headers(), abortSignal: controller.signal, + translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + controller.abort(); + const stopped = await Promise.race([pending.then(() => true), Bun.sleep(50).then(() => false)]); + expect(stopped).toBe(true); + expect(refreshFinished).toBe(false); + expect(children).toBe(0); + expect(events.at(-1)).toMatchObject({ type: "error", message: "ZCode request cancelled before dispatch.", retryable: false }); + shared.resolve(false); await work; + expect(refreshFinished).toBe(true); + }); + test("advanced settings allow an isolated home when the proxy HOME is absent", () => { + const settings = fixture(); + const env = { OCX_ZCODE_NATIVE_TOOLS: "1", OCX_ZCODE_COMMAND: JSON.stringify(["/isolated-launcher"]), + OCX_ZCODE_HOME: settings.home, OCX_ZCODE_WORKSPACE: settings.workspace }; + expect(loadZcodeSettings(env)).toMatchObject({ home: settings.home, workspace: settings.workspace }); + expect(() => loadZcodeSettings({ ...env, HOME: settings.home })).toThrow("separate home"); + }); + test("advanced settings fence sessions across in-place credential changes", () => { + const settings = fixture(); + const env = { OCX_ZCODE_NATIVE_TOOLS: "1", OCX_ZCODE_COMMAND: JSON.stringify(["/isolated-launcher"]), + OCX_ZCODE_HOME: settings.home, OCX_ZCODE_WORKSPACE: settings.workspace }; + const before = loadZcodeSettings(env); + const contents = readFileSync(settings.settingsPath, "utf8"); + writeFileSync(settings.settingsPath, contents.replace("never-log-this", "other-key-here")); + const after = loadZcodeSettings(env); + expect(after.lockKey).toBe(before.lockKey); + expect(after.scope).not.toBe(before.scope); + expect(after.profileGeneration).not.toBe(before.profileGeneration); + expect(() => readZcodeModels(before)).toThrow("unavailable, invalid"); + expect(readZcodeModels(after).map(model => model.id)).toEqual(["test/model"]); + }); + test("catalog excludes disabled, recursive and link-local entries and preserves canonical model identity", () => { + const models = readZcodeModels(fixture()); + expect(models.map(m => m.id)).toEqual(["test/model"]); + expect(models[0]?.contextWindow).toBe(1000); + }); + test("settings parse errors and escaping symlinks disclose no file content", () => { + const settings = fixture(); + writeFileSync(settings.settingsPath, "{secret-value"); + expect(() => readZcodeModels(settings)).toThrow("unavailable, invalid"); + if (process.platform !== "win32") { + const outside = fixture(); unlinkSync(settings.settingsPath); symlinkSync(outside.settingsPath, settings.settingsPath); + expect(() => readZcodeModels(settings)).toThrow("unavailable, invalid"); + } + }); + test("native execution streams progress without asking the client to execute tools", async () => { + const settings = fixture(); const client = new FakeClient(); + const events = await run(settings, client); + expect(events.some(e => e.type.startsWith("tool_call"))).toBe(false); + expect(events.filter(e => e.type === "text_delta" && e.text === "Hello")).toHaveLength(1); + expect(events.at(-1)?.type).toBe("done"); + expect(JSON.stringify(events)).not.toContain("never-log-this"); + expect(client.closed).toBe(true); + expect(client.calls.map(c => c.method)).toEqual(["session/create", "session/subscribe", "session/send", "session/stop"]); + }); + test("compaction uses a fresh tool-disabled session and does not replace the main continuation", async () => { + const settings = fixture(); + const parsed = request(); + parsed._compactionRequest = true; + parsed._providerContinuation = { zcode: { sessionId: "sess_existing", scope: settings.scope } }; + const client = new FakeClient(); + const events = await run(settings, client, parsed); + expect(events.at(-1)?.type).toBe("done"); + const done = events.find((event): event is Extract => event.type === "done"); + expect(done?.providerState).toBeUndefined(); + expect(events.some(event => event.type === "text_delta" && event.text.includes("without native tools"))).toBe(true); + expect(client.calls.some(call => call.method === "session/resume")).toBe(false); + const created = client.calls.find(call => call.method === "session/create")?.params; + expect(created?.toolAllowlist).toEqual([]); + expect(created?.toolDenylist).toBeUndefined(); + }); + test("compaction fails closed if the official runtime emits a native tool event", async () => { + const parsed = request(); parsed._compactionRequest = true; + const events = await run(fixture(), new FakeClient("tool-during-compaction"), parsed); + expect(events.at(-1)).toMatchObject({ type: "incomplete", retryable: false }); + expect(JSON.stringify(events)).toContain("compaction attempted native tool execution"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + test("rejects an oversized serialized send frame before starting the native client", async () => { + const parsed = request(); + parsed.context.messages = [{ role: "user", content: "\0".repeat(175_000), timestamp: 0 }]; + const client = new FakeClient(); + const events = await run(fixture(), client, parsed, undefined, 2_000); + expect(events.at(-1)).toMatchObject({ + type: "error", message: "ZCode input exceeds the native bridge serialization limit.", retryable: false, + }); + expect(client.calls).toHaveLength(0); + expect(client.closed).toBe(false); + }); + test("answerless completion is reported as an interrupted accepted turn", async () => { + const events = await run(fixture(), new FakeClient("answerless")); + expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "zcode_agent_interrupted", + retryable: false, endTurn: true }); + expect(JSON.stringify(events)).toContain("completed without a model answer"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + test("ignores terminal events without the active session identity", async () => { + const events = await run(fixture(), new FakeClient("session-noise")); + expect(events.filter(e => e.type === "text_delta" && e.text === "wrong session")).toHaveLength(0); + expect(events.filter(e => e.type === "text_delta" && e.text === "Hello")).toHaveLength(1); + expect(events.at(-1)?.type).toBe("done"); + }); + test("continuation resumes only owner-fenced state and sends the current delta", async () => { + const settings = fixture(); + const first = await run(settings, new FakeClient()); + const done = first.find(e => e.type === "done"); + const next = request(); next._providerContinuation = done?.providerState; + next.context.messages.push({ role: "user", content: "Follow up", timestamp: 1 }); + const client = new FakeClient(); await run(settings, client, next); + expect(client.calls[0]?.method).toBe("session/resume"); + const sent = String(client.calls.find(c => c.method === "session/send")?.params.content); + expect(sent).toContain("Follow up"); expect(sent).not.toContain("Say hello"); + const other = new FakeClient(); await run(fixture(), other, next); + expect(other.calls[0]?.method).toBe("session/create"); + }); + test("a fresh ZCode session safely projects replayed reasoning and tool history", async () => { + const parsed = request(); + parsed.context.messages = [ + { role: "user", content: "Inspect the repository", timestamp: 0 }, + { role: "assistant", timestamp: 1, content: [ + { type: "thinking", thinking: "private reasoning must not cross providers" }, + { type: "toolCall", id: "call_old", name: "exec_command", arguments: { command: "pwd" } }, + ] }, + { role: "toolResult", toolCallId: "call_old", toolName: "exec_command", + content: "/workspace", isError: false, timestamp: 2 }, + { role: "assistant", content: [{ type: "text", text: "The repository is available." }], timestamp: 3 }, + { role: "user", content: "Continue with the fix", timestamp: 4 }, + ]; + const client = new FakeClient(); + expect((await run(fixture(), client, parsed)).at(-1)?.type).toBe("done"); + const sent = String(client.calls.find(call => call.method === "session/send")?.params.content); + expect(sent).toContain("[historical tool call: exec_command]"); + expect(sent).toContain("tool exec_command result: /workspace"); + expect(sent).toContain("assistant: The repository is available."); + expect(sent).toContain("user: Continue with the fix"); + expect(sent).not.toContain("private reasoning"); + expect(sent).not.toContain('"command":"pwd"'); + }); + test("a fresh session truncates oldest replay history without dropping the current turn", async () => { + const parsed = request(); + parsed._continuationConversationMessageIndex = 2; + parsed.context.systemPrompt = ["SYSTEM_SENTINEL"]; + parsed.context.messages = [ + { role: "user", content: `OLDEST_${"a".repeat(150_000)}`, timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: `NEWEST_${"b".repeat(70_000)}` }], timestamp: 1 }, + { role: "user", content: "CURRENT_REQUEST_SENTINEL", timestamp: 2 }, + ]; + const client = new FakeClient(); + expect((await run(fixture(), client, parsed)).at(-1)?.type).toBe("done"); + const sent = String(client.calls.find(call => call.method === "session/send")?.params.content); + expect(sent.length).toBeLessThanOrEqual(200_000); + expect(sent).toContain("SYSTEM_SENTINEL"); + expect(sent).toContain("CURRENT_REQUEST_SENTINEL"); + expect(sent).toContain("NEWEST_"); + expect(sent).not.toContain("OLDEST_"); + expect(sent).toContain("Earlier OpenCodex conversation history truncated"); + }); + test("post-send failures are non-retryable incomplete, not failover candidates", async () => { + const client = new FakeClient("failed"); const events = await run(fixture(), client); + expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "zcode_agent_interrupted", retryable: false }); + expect(JSON.stringify(events)).not.toContain("secret"); + expect(client.calls.filter(c => c.method === "session/send")).toHaveLength(1); + }); + test("timeout closes the owned app-server and does not resend", async () => { + const client = new FakeClient("hang"); const events = await run(fixture(), client); + expect(events.at(-1)?.type).toBe("incomplete"); expect(client.closed).toBe(true); + expect(client.calls.filter(c => c.method === "session/send")).toHaveLength(1); + }); + test("a running cancellation closes only its child and never resends", async () => { + const controller = new AbortController(); const client = new FakeClient("hang"); + const pending = run(fixture(), client, request(), controller.signal); + await new Promise(resolve => setTimeout(resolve, 5)); controller.abort(); + expect((await pending).at(-1)).toMatchObject({ type: "incomplete", retryable: false }); + expect(client.closed).toBe(true); + expect(client.calls.filter(c => c.method === "session/send")).toHaveLength(1); + }); + test("profile turns serialize and cancelled waiters never start a child", async () => { + const settings = fixture(); const first = new FakeClient("hang"); const second = new FakeClient(); + const pending = run(settings, first); + await new Promise(resolve => setTimeout(resolve, 5)); + const controller = new AbortController(); const waiting = run(settings, second, request(), controller.signal); + controller.abort(); + expect((await waiting).at(-1)?.type).toBe("error"); + expect(second.calls).toHaveLength(0); + await pending; + expect((await run(settings, new FakeClient())).at(-1)?.type).toBe("done"); + }); + test("profile generations share one physical-profile serialization queue", async () => { + const settings = fixture(); + const env = { OCX_ZCODE_NATIVE_TOOLS: "1", OCX_ZCODE_COMMAND: JSON.stringify(["/isolated-launcher"]), + OCX_ZCODE_HOME: settings.home, OCX_ZCODE_WORKSPACE: settings.workspace }; + const before = loadZcodeSettings(env); + const first = new FakeClient("hang"); + const firstController = new AbortController(); + const firstTurn = run(before, first, request(), firstController.signal, 2_000); + await Bun.sleep(5); + expect(first.calls.some(call => call.method === "session/send")).toBe(true); + + const contents = readFileSync(settings.settingsPath, "utf8"); + writeFileSync(settings.settingsPath, contents.replace("never-log-this", "other-key-here")); + const after = loadZcodeSettings(env); + expect(after.lockKey).toBe(before.lockKey); + expect(after.scope).not.toBe(before.scope); + + const second = new FakeClient(); + const secondTurn = run(after, second, request(), undefined, 2_000); + await Bun.sleep(10); + expect(second.calls).toEqual([]); + + firstController.abort(); + expect((await firstTurn).at(-1)?.type).toBe("incomplete"); + expect((await secondTurn).at(-1)?.type).toBe("done"); + expect(second.calls.some(call => call.method === "session/send")).toBe(true); + }); + test("one saturated profile leaves reservation capacity for another profile", async () => { + const settings = fixture(); + const controllers = Array.from({ length: 24 }, () => new AbortController()); + const pending = controllers.map(controller => run( + settings, new FakeClient("hang"), request(), controller.signal, 2_000, + )); + const rejected = await run(settings, new FakeClient(), request(), undefined, 2_000); + expect(rejected.at(-1)).toMatchObject({ type: "error", message: "ZCode profile queue is full." }); + const other = await run({ ...settings, lockKey: settings.lockKey + ":other", scope: settings.scope + ":other" }, + new FakeClient(), request(), undefined, 2_000); + expect(other.at(-1)?.type).toBe("done"); + for (const controller of controllers) controller.abort(); + await Promise.all(pending); + }); + test("pre-aborted requests never spawn or send", async () => { + const client = new FakeClient(); const events = await run(fixture(), client, request(), AbortSignal.abort()); + expect(events.at(-1)?.type).toBe("error"); expect(client.calls).toHaveLength(0); + }); + test("rejects residual media that escaped text-only normalization", async () => { + const parsed = request(); parsed.context.messages = [{ role: "user", timestamp: 0, + content: [{ type: "image", imageUrl: "https://example.invalid/image.png" }] }]; + const client = new FakeClient(); + expect((await run(fixture(), client, parsed)).at(-1)).toMatchObject({ + type: "error", message: "ZCode image input was not converted to text before native dispatch.", + }); + expect(client.calls).toHaveLength(0); + }); +}); diff --git a/tests/providers/zcode-desktop-routes.test.ts b/tests/providers/zcode-desktop-routes.test.ts new file mode 100644 index 0000000000..b4ae252cc3 --- /dev/null +++ b/tests/providers/zcode-desktop-routes.test.ts @@ -0,0 +1,736 @@ +import { handleZcodeAccountRoutes, resetZcodeAccountJobsForTests } from "../../src/server/management/zcode-account-routes"; +import { allocateAccount, listAccounts, accountProfile, writeAccount } from "../../src/adapters/zcode/accounts"; +import { defaultDesktopWorkspace } from "../../src/adapters/zcode/desktop"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { handleZcodeDesktopRoutes } from "../../src/server/management/zcode-desktop-routes"; +import type { ManagementContext } from "../../src/server/management/context"; + +import { mkdtempSync, mkdirSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig } from "../../src/config"; +import { activateDesktopProvider, desktopActivation } from "../../src/server/management/zcode-desktop-activation"; +import { handleProviderRoutes } from "../../src/server/management/provider-routes"; +let home: string; +let previousHome: string | undefined; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-zcode-activation-")); + previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; +}); +afterEach(() => { + resetZcodeAccountJobsForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); +}); +const models = [{ id: "builtin:zai-coding-plan/glm-5.3", providerId: "builtin:zai-coding-plan", modelId: "glm-5.3", label: "GLM-5.3" }, + { id: "builtin:zai-coding-plan/glm-5.3-flash", providerId: "builtin:zai-coding-plan", modelId: "glm-5.3-flash", label: "GLM-5.3-Flash" }]; +const status = { connected: false, issue: undefined, runtimes: ["/installed/ZCode"], runtime: "/installed/ZCode", workspace: "/project", models, platform: "linux" as const }; +function context(path: string, body: unknown, principal?: ManagementContext["principal"], method = "POST"): ManagementContext { + const req = new Request(`http://localhost${path}`, { method, headers: { "content-type": "application/json", origin: "http://localhost", "x-opencodex-gui-session": "forged" }, + ...(method === "GET" ? {} : { body: JSON.stringify(body) }) }); + const config = getDefaultConfig(); + const catalog = join(home, "catalog.json"); + return { req, url: new URL(req.url), principal, config, + deps: { saveConfigPreservingClaudeCode: c => writeFileSync(join(home, "config.json"), JSON.stringify(c)) }, + convergeCodexCatalog: async () => { + const slugs = Object.entries(config.providers).filter(([, provider]) => provider.adapter === "zcode" && provider.disabled !== true) + .flatMap(([name]) => models.map(model => name + "/" + model.id.replaceAll("/", "-"))); + writeFileSync(catalog, JSON.stringify(slugs)); + return { status: "committed", changed: true, degraded: false, notices: [] }; + }, + } as ManagementContext; +} +function fixture() { + let calls = 0; + const probed: string[] = []; + const deps = { desktopStatus: () => status, connectDesktop: async () => { calls++; return { ...status, connected: true }; }, + disconnectDesktop: async () => { calls++; }, verifyDesktopProtocol: async (model: string) => { probed.push(model); }, + readDesktopCatalogSlugs: () => { try { return JSON.parse(readFileSync(join(home, "catalog.json"), "utf8")); } catch { return []; } } }; + return { deps, calls: () => calls, probed: () => probed }; +} +describe("ZCode Desktop management consent", () => { + for (const principal of [undefined, "admin-token"] as const) { + test(`rejects native execution configuration from ${principal ?? "missing"} principal`, async () => { + const f = fixture(); + for (const action of ["connect", "activate", "disconnect", "test"]) { + const response = await handleZcodeDesktopRoutes(context(`/api/zcode-desktop/${action}`, { consent: true, runtime: "/installed/ZCode", workspace: "/project" }, principal), f.deps); + expect(response?.status).toBe(403); + } + expect(f.calls()).toBe(0); + }); + test(`rejects Desktop path discovery from ${principal ?? "missing"} principal`, async () => { + const f = fixture(); + for (const path of ["/api/zcode-desktop", "/api/zcode-desktop/folders"]) { + const response = await handleZcodeDesktopRoutes(context(path, {}, principal, "GET"), f.deps); + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ error: "dashboard_required" }); + } + expect(f.calls()).toBe(0); + }); + } + test("GUI sessions can inspect Desktop status without triggering native execution", async () => { + const f = fixture(); + const response = await handleZcodeDesktopRoutes(context("/api/zcode-desktop", {}, "gui-session", "GET"), f.deps); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ connected: false, activation: "disconnected" }); + expect(f.calls()).toBe(0); + }); + test("requires an explicit consent checkbox, not just a GUI session", async () => { + const f = fixture(); const response = await handleZcodeDesktopRoutes(context("/api/zcode-desktop/connect", { runtime: "/installed/ZCode", workspace: "/project" }, "gui-session"), f.deps); + expect(response?.status).toBe(400); expect(f.calls()).toBe(0); + }); + test("connection enables the provider and publishes models without changing defaults", async () => { + const f = fixture(); const ctx = context("/api/zcode-desktop/connect", { consent: true, runtime: "/installed/ZCode", workspace: "/project" }, "gui-session"); + const before = ctx.config.defaultProvider; + const response = await handleZcodeDesktopRoutes(ctx, f.deps); + expect(await response?.json()).toMatchObject({ connected: true, activation: "ready", providerRegistered: true }); + expect(f.calls()).toBe(1); + expect(ctx.config.defaultProvider).toBe(before); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).providers.zcode).toMatchObject({ adapter: "zcode", disabled: false, authMode: "local" }); + expect(f.deps.readDesktopCatalogSlugs()).toEqual(models.map(m => "zcode/" + m.id.replaceAll("/", "-"))); + }); + test("oversized setup payload is rejected before runtime execution", async () => { + const f = fixture(); const response = await handleZcodeDesktopRoutes(context("/api/zcode-desktop/connect", { consent: true, runtime: "x".repeat(14000), workspace: "/project" }, "gui-session"), f.deps); + expect(response?.status).toBe(400); expect(f.calls()).toBe(0); + }); + test("optional verification uses protocol metadata without starting an inference turn", async () => { + const f = fixture(); + f.deps.desktopStatus = () => ({ ...status, connected: true }); + const response = await handleZcodeDesktopRoutes(context("/api/zcode-desktop/test", { + consent: true, model: models[0]!.id, + }, "gui-session"), f.deps); + expect(await response?.json()).toEqual({ ok: true }); + expect(f.probed()).toEqual([models[0]!.id]); + expect(f.calls()).toBe(0); + }); + test("protocol verification failures stay bounded and never expose runtime details", async () => { + const f = fixture(); + f.deps.desktopStatus = () => ({ ...status, connected: true }); + f.deps.verifyDesktopProtocol = async () => { throw new Error("private runtime and profile path"); }; + const response = await handleZcodeDesktopRoutes(context("/api/zcode-desktop/test", { + consent: true, model: models[0]!.id, + }, "gui-session"), f.deps); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ error: "protocol_failed" }); + }); +}); + +test("generic ZCode provider test reports an empty local catalog as a failure", async () => { + const zcodeHome = join(home, "advanced-home"); + const workspace = join(home, "workspace"); + mkdirSync(join(zcodeHome, ".zcode/cli"), { recursive: true }); mkdirSync(workspace); + writeFileSync(join(zcodeHome, ".zcode/cli/config.json"), JSON.stringify({ provider: {} })); + const keys = ["OCX_ZCODE_NATIVE_TOOLS", "OCX_ZCODE_COMMAND", "OCX_ZCODE_HOME", "OCX_ZCODE_WORKSPACE"] as const; + const previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.OCX_ZCODE_NATIVE_TOOLS = "1"; + process.env.OCX_ZCODE_COMMAND = JSON.stringify(["/fixture-launcher"]); + process.env.OCX_ZCODE_HOME = zcodeHome; + process.env.OCX_ZCODE_WORKSPACE = workspace; + const ctx = context("/api/providers/test?name=zcode", {}, "gui-session"); + ctx.config.providers.zcode = { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai" }; + const result = await (await handleProviderRoutes(ctx))?.json(); + expect(result).toEqual({ ok: false, models: 0, latencyMs: 0, error: "ZCode local catalog has no available models." }); + expect(result).not.toHaveProperty("message"); + } finally { + for (const key of keys) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } +}); + +test("reconnection preserves custom settings and restart observes actual persisted catalog", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/connect", {}, "gui-session"); + const custom = { adapter: "zcode" as const, authMode: "local" as const, baseUrl: "https://zcode.z.ai", disabled: true, + defaultModel: models[0]!.id, note: "custom note", contextWindow: 64000 }; + ctx.config.providers.zcode = custom; + const connected = { ...status, connected: true }; + await activateDesktopProvider(ctx, connected, f.deps.readDesktopCatalogSlugs); + await activateDesktopProvider(ctx, connected, f.deps.readDesktopCatalogSlugs); + expect(ctx.config.providers.zcode).toEqual({ ...custom, disabled: false }); + expect(Object.values(ctx.config.providers).filter(p => p.adapter === "zcode")).toHaveLength(1); + ctx.config = JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + expect(desktopActivation(ctx, connected, f.deps.readDesktopCatalogSlugs).activation).toBe("ready"); + rmSync(join(home, "catalog.json")); + expect(desktopActivation(ctx, connected, f.deps.readDesktopCatalogSlugs).activation).toBe("catalog_pending"); +}); + +test("activation readiness follows selected and disabled model visibility", () => { + const ctx = context("/api/zcode-desktop/activate", {}, "gui-session"); + const connected = { ...status, connected: true }; + ctx.config.providers.zcode = { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", + selectedModels: [models[0]!.id] }; + const firstSlug = "zcode/" + models[0]!.id.replaceAll("/", "-"); + const secondSlug = "zcode/" + models[1]!.id.replaceAll("/", "-"); + expect(desktopActivation(ctx, connected, () => [firstSlug])).toMatchObject({ activation: "ready" }); + + delete ctx.config.providers.zcode.selectedModels; + ctx.config.disabledModels = [firstSlug]; + expect(desktopActivation(ctx, connected, () => [secondSlug])).toMatchObject({ activation: "ready" }); +}); + +test("registration failure rolls back config, preserves protocol state and allows retry", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/connect", {}, "gui-session"); + const save = ctx.deps.saveConfigPreservingClaudeCode; + ctx.deps.saveConfigPreservingClaudeCode = () => { throw new Error("private profile content"); }; + const connected = { ...status, connected: true }; + const result = await activateDesktopProvider(ctx, connected, f.deps.readDesktopCatalogSlugs); + expect(result).toMatchObject({ connected: true, activation: "provider_pending", error: "provider_registration_failed" }); + expect(JSON.stringify(result)).not.toContain("private profile content"); + expect(ctx.config.providers.zcode).toBeUndefined(); + ctx.deps.saveConfigPreservingClaudeCode = save; + expect((await activateDesktopProvider(ctx, connected, f.deps.readDesktopCatalogSlugs)).activation).toBe("ready"); +}); + +test("disconnect disables the provider, preserves customization and removes its catalog rows", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/disconnect", {}, "gui-session"); + const beforeDefault = ctx.config.defaultProvider; + ctx.config.providers.zcode = { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", + disabled: false, defaultModel: models[0]!.id, note: "keep custom settings", contextWindow: 64_000 }; + await ctx.convergeCodexCatalog(); + expect(f.deps.readDesktopCatalogSlugs()).toHaveLength(2); + const response = await handleZcodeDesktopRoutes(ctx, f.deps); + expect(await response?.json()).toMatchObject({ connected: false, activation: "disconnected", providerRegistered: false }); + expect(ctx.config.providers.zcode).toEqual({ adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", + disabled: true, defaultModel: models[0]!.id, note: "keep custom settings", contextWindow: 64_000 }); + expect(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).providers.zcode.disabled).toBe(true); + expect(f.deps.readDesktopCatalogSlugs()).toEqual([]); + expect(ctx.config.defaultProvider).toBe(beforeDefault); +}); + +test("Desktop disconnect holds the activation transition lock until cleanup finishes", async () => { + const f = fixture(); + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + f.deps.disconnectDesktop = async () => { entered(); await gate; }; + const disconnect = handleZcodeDesktopRoutes( + context("/api/zcode-desktop/disconnect", {}, "gui-session"), f.deps, + ); + await started; + const overlapping = await handleZcodeDesktopRoutes(context("/api/zcode-desktop/connect", { + consent: true, runtime: "/installed/ZCode", workspace: "/project", + }, "gui-session"), f.deps); + expect(overlapping?.status).toBe(409); + expect(await overlapping?.json()).toEqual({ error: "busy" }); + expect(f.calls()).toBe(0); + release(); + expect((await disconnect)?.status).toBe(200); +}); + +test("partial disconnect cleanup is explicit and idempotently retryable", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/disconnect", {}, "gui-session"); + ctx.config.providers.zcode = { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", disabled: false }; + await ctx.convergeCodexCatalog(); + const converge = ctx.convergeCodexCatalog; + ctx.convergeCodexCatalog = async () => ({ status: "failed", reason: "disk", phase: "commit", retryable: true, partialWrite: false }); + expect(await (await handleZcodeDesktopRoutes(ctx, f.deps))?.json()).toMatchObject({ + connected: false, providerRegistered: false, error: "catalog_update_failed", + }); + expect(f.deps.readDesktopCatalogSlugs()).toHaveLength(2); + ctx.convergeCodexCatalog = converge; + const retry = context("/api/zcode-desktop/disconnect", {}, "gui-session"); + ctx.req = retry.req; ctx.url = retry.url; + expect(await (await handleZcodeDesktopRoutes(ctx, f.deps))?.json()).toMatchObject({ + connected: false, providerRegistered: false, activation: "disconnected", + }); + expect(f.deps.readDesktopCatalogSlugs()).toEqual([]); +}); + +test("failed or skipped catalog and committed-but-missing models remain partial", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/connect", {}, "gui-session"); + const converge = ctx.convergeCodexCatalog, connected = { ...status, connected: true }; + for (const outcome of [ + { status: "skipped", reason: "busy", retryable: true }, + { status: "failed", reason: "disk", phase: "commit", retryable: true, partialWrite: true }, + { status: "committed", changed: false, degraded: true, notices: [] }, + ] as const) { + ctx.convergeCodexCatalog = async () => outcome; + expect(await activateDesktopProvider(ctx, connected, f.deps.readDesktopCatalogSlugs)).toMatchObject({ + connected: true, providerRegistered: true, activation: "catalog_pending", error: "catalog_update_failed", + }); + } + ctx.convergeCodexCatalog = converge; + expect((await activateDesktopProvider(ctx, connected, f.deps.readDesktopCatalogSlugs)).activation).toBe("ready"); +}); + +test("a different provider occupying zcode is never overwritten", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/connect", {}, "gui-session"); + ctx.config.providers.zcode = { adapter: "openai-chat", baseUrl: "https://example.invalid" }; + const before = structuredClone(ctx.config.providers); + expect((await activateDesktopProvider(ctx, { ...status, connected: true }, f.deps.readDesktopCatalogSlugs)).activation).toBe("provider_pending"); + expect(ctx.config.providers).toEqual(before); +}); + +test("ambiguous legacy ZCode bindings are not collapsed or overwritten", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/connect", {}, "gui-session"); + const first = { adapter: "zcode" as const, authMode: "local" as const, baseUrl: "https://zcode.z.ai", disabled: true, note: "canonical" }; + const second = { ...first, note: "legacy alias" }; + ctx.config.providers.zcode = first; + ctx.config.providers["zcode-legacy"] = second; + const before = structuredClone(ctx.config.providers); + expect(await activateDesktopProvider(ctx, { ...status, connected: true }, f.deps.readDesktopCatalogSlugs)).toMatchObject({ + activation: "provider_pending", error: "provider_registration_failed", + }); + expect(ctx.config.providers).toEqual(before); +}); + +test("activation retry is consent-gated and never repeats the protocol probe", async () => { + const f = fixture(), ctx = context("/api/zcode-desktop/activate", { + consent: true, runtime: "/installed/ZCode", workspace: "/project", + }, "gui-session"); + f.deps.desktopStatus = () => ({ ...status, connected: true }); + const response = await handleZcodeDesktopRoutes(ctx, f.deps); + expect(await response?.json()).toMatchObject({ activation: "ready" }); + expect(f.calls()).toBe(0); +}); + +test("an existing renamed ZCode provider is reused without adding canonical zcode", async () => { + const ctx = context("/api/zcode-desktop/activate", {}, "gui-session"); + ctx.config.providers.desktop = { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", note: "keep" }; + const slugs = models.map(m => "desktop/" + m.id.replaceAll("/", "-")); + const result = await activateDesktopProvider(ctx, { ...status, connected: true }, () => slugs); + expect(result).toMatchObject({ activation: "ready", providerName: "desktop" }); + expect(ctx.config.providers.zcode).toBeUndefined(); + expect(ctx.config.providers.desktop?.note).toBe("keep"); +}); + +function accountFixture() { + const ctx = context("/api/zcode-accounts/login", {}, "gui-session"); + const connected = new Set(); + let hash = "a".repeat(64), failCatalog = false, active = false; + let slugs: string[] = [], catalogReads = 0; + const workspaceValidations: Array<{ path: string; accountId?: string }> = []; + const statusRuntimeHints: Array = []; + const deps = { + resolveDesktopRuntime: (path: string) => path, + validateDesktopWorkspace: (path: string, accountId?: string) => { workspaceValidations.push({ path, accountId }); return path; }, + accountRuntimeBusy: () => active, + desktopStatus: (accountId?: string, detectedRuntimes?: readonly string[]) => { + statusRuntimeHints.push(detectedRuntimes); + return { ...status, runtimes: detectedRuntimes ? [...detectedRuntimes] : status.runtimes, + sandbox: false, accountId, connected: !!accountId && connected.has(accountId) }; + }, + connectDesktop: async (_r: string, _w: string, accountId?: string) => { + connected.add(accountId!); return { ...status, sandbox: false, accountId, connected: true }; + }, + disconnectDesktop: async (id?: string) => { connected.delete(id!); }, + readDesktopCatalogSlugs: () => { catalogReads++; return slugs; }, + runNativeOAuth: async (options: { onEvent: (e: any) => void }) => { + options.onEvent({ type: "authenticated", subjectHash: hash }); + }, + }; + ctx.convergeCodexCatalog = async () => { + if (failCatalog) throw new Error("fixture catalog failure"); + slugs = Object.entries(ctx.config.providers).filter(([, p]) => p.adapter === "zcode") + .flatMap(([name]) => models.map(m => name + "/" + m.id.replaceAll("/", "-"))); + return { status: "committed", changed: true, degraded: false, notices: [] }; + }; + const call = async (action: string, body: object, principal: ManagementContext["principal"] = "gui-session") => { + const request = context("/api/zcode-accounts/" + action, { consent: true, ...body }, principal); + ctx.req = request.req; ctx.url = request.url; ctx.principal = principal; + return (await handleZcodeAccountRoutes(ctx, deps))!; + }; + const login = async (extra = {}) => { + const result = await (await call("login", { label: "Personal", runtime: "/runtime", workspace: "/project", ...extra })).json(); + await Promise.resolve(); await Promise.resolve(); + return result; + }; + return { ctx, deps, call, login, slugs: () => slugs, catalogReads: () => catalogReads, + statusRuntimeHints: () => statusRuntimeHints, workspaceValidations: () => workspaceValidations, + hash: (h: string) => { hash = h; }, + failCatalog: (v: boolean) => { failCatalog = v; }, active: (v: boolean) => { active = v; } }; +} +test("new accounts validate the displayed managed workspace in their eventual account scope", async () => { + const f = accountFixture(); + const account = await f.login({ workspace: defaultDesktopWorkspace() }); + expect(f.workspaceValidations()).toEqual([{ path: defaultDesktopWorkspace(account.accountId), accountId: account.accountId }]); + expect(await (await f.call("complete", { jobId: account.jobId })).json()).toMatchObject({ activation: "ready" }); +}); + +test("account workspace remapping recognizes the canonical target of a symlinked config root", async () => { + if (process.platform === "win32") return; + const realConfig = join(home, "real-config"); + const linkedConfig = join(home, "linked-config"); + mkdirSync(realConfig); + symlinkSync(realConfig, linkedConfig); + process.env.OPENCODEX_HOME = linkedConfig; + mkdirSync(defaultDesktopWorkspace(), { recursive: true }); + const displayedCanonicalWorkspace = realpathSync(defaultDesktopWorkspace()); + const f = accountFixture(); + const account = await f.login({ workspace: displayedCanonicalWorkspace }); + expect(f.workspaceValidations()).toEqual([{ + path: defaultDesktopWorkspace(account.accountId), accountId: account.accountId, + }]); +}); + +test("workspace validation failure removes the newly allocated draft account", async () => { + const f = accountFixture(); + f.deps.validateDesktopWorkspace = () => { throw new Error("workspace_invalid"); }; + expect(await (await f.call("login", { label: "Invalid", runtime: "/runtime", workspace: "/denied" })).json()) + .toEqual({ error: "workspace_invalid" }); + expect(listAccounts()).toEqual([]); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([]); +}); +test("reconnect validation failure removes only its draft and preserves the saved account", async () => { + const f = accountFixture(); + const account = await f.login(); + expect(await (await f.call("complete", { jobId: account.jobId })).json()).toMatchObject({ activation: "ready" }); + writeFileSync(join(accountProfile(account.accountId), "sentinel"), "original"); + f.deps.validateDesktopWorkspace = () => { throw new Error("workspace_invalid"); }; + expect(await f.login({ accountId: account.accountId, workspace: "/denied" })) + .toEqual({ error: "workspace_invalid" }); + expect(listAccounts().map(saved => saved.id)).toEqual([account.accountId]); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([account.accountId]); + expect(readFileSync(join(accountProfile(account.accountId), "sentinel"), "utf8")).toBe("original"); +}); +test("account listing after restart removes only orphaned hidden reconnect drafts", async () => { + const f = accountFixture(); + const account = await f.login(); + expect(await (await f.call("complete", { jobId: account.jobId })).json()).toMatchObject({ activation: "ready" }); + const reconnect = await f.login({ accountId: account.accountId }); + expect(reconnect).not.toHaveProperty("error"); + expect(readdirSync(join(home, "zcode-accounts"))).toHaveLength(2); + + const liveRequest = context("/api/zcode-accounts", {}, "gui-session", "GET"); + f.ctx.req = liveRequest.req; f.ctx.url = liveRequest.url; + expect(await (await handleZcodeAccountRoutes(f.ctx, f.deps))!.json()).toMatchObject({ + accounts: [{ id: account.accountId }], + }); + expect(readdirSync(join(home, "zcode-accounts"))).toHaveLength(2); + + resetZcodeAccountJobsForTests(); + const restartedRequest = context("/api/zcode-accounts", {}, "gui-session", "GET"); + f.ctx.req = restartedRequest.req; f.ctx.url = restartedRequest.url; + expect(await (await handleZcodeAccountRoutes(f.ctx, f.deps))!.json()).toMatchObject({ + accounts: [{ id: account.accountId }], + }); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([account.accountId]); +}); +test("account listing after restart removes an orphaned hidden new-account draft", async () => { + const f = accountFixture(); + const account = await f.login(); + expect(listAccounts()).toEqual([]); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([account.accountId]); + + const liveRequest = context("/api/zcode-accounts", {}, "gui-session", "GET"); + f.ctx.req = liveRequest.req; f.ctx.url = liveRequest.url; + expect(await (await handleZcodeAccountRoutes(f.ctx, f.deps))!.json()).toEqual({ accounts: [] }); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([account.accountId]); + + resetZcodeAccountJobsForTests(); + const restartedRequest = context("/api/zcode-accounts", {}, "gui-session", "GET"); + f.ctx.req = restartedRequest.req; f.ctx.url = restartedRequest.url; + expect(await (await handleZcodeAccountRoutes(f.ctx, f.deps))!.json()).toEqual({ accounts: [] }); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([]); +}); +test("manual accounts require GUI consent; account login enables an independent provider/catalog", async () => { + const f = accountFixture(), originalDefault = f.ctx.config.defaultProvider; + expect((await f.call("login", {}, "admin-token")).status).toBe(403); + expect((await f.call("login", { consent: false })).status).toBe(400); + expect(listAccounts()).toHaveLength(0); + const a = await f.login(); + expect(listAccounts()).toHaveLength(0); + expect(await (await f.call("complete", { jobId: a.jobId })).json()).toMatchObject({ activation: "ready", accountId: a.accountId }); + expect(listAccounts()).toHaveLength(1); + f.hash("b".repeat(64)); + const b = await f.login({ label: "Work" }); + expect(await (await f.call("complete", { jobId: b.jobId })).json()).toMatchObject({ activation: "ready", accountId: b.accountId }); + const providers = Object.values(f.ctx.config.providers).filter(p => p.adapter === "zcode"); + expect(providers.map(p => p.zcodeAccountId).sort()).toEqual([a.accountId, b.accountId].sort()); + expect(accountProfile(a.accountId)).not.toBe(accountProfile(b.accountId)); + expect(f.slugs()).toHaveLength(4); + expect(f.ctx.config.defaultProvider).toBe(originalDefault); + expect(JSON.stringify(await (await f.call("complete", { jobId: a.jobId })).json())).not.toContain("subjectHash"); +}); +test("account listing shares one host discovery and one catalog read", async () => { + const f = accountFixture(); + const first = await f.login(); + await f.call("complete", { jobId: first.jobId }); + f.hash("b".repeat(64)); + const second = await f.login({ label: "Work" }); + await f.call("complete", { jobId: second.jobId }); + const statusCalls = f.statusRuntimeHints().length; + const catalogReads = f.catalogReads(); + + const request = context("/api/zcode-accounts", {}, "gui-session", "GET"); + f.ctx.req = request.req; f.ctx.url = request.url; + const listed = await (await handleZcodeAccountRoutes(f.ctx, f.deps))!.json(); + + expect(listed.accounts).toHaveLength(2); + expect(f.statusRuntimeHints().slice(statusCalls)).toEqual([undefined, status.runtimes]); + expect(f.catalogReads() - catalogReads).toBe(1); +}); +test("duplicate identity is rejected and reconnect preserves provider settings and account id", async () => { + const f = accountFixture(), a = await f.login(); + const ready = await (await f.call("complete", { jobId: a.jobId })).json(); + f.ctx.config.providers[ready.providerName].contextWindow = 64000; + const duplicate = await f.login(); + expect(await (await f.call("complete", { jobId: duplicate.jobId })).json()).toMatchObject({ error: "account_duplicate", accountId: a.accountId }); + await f.call("cancel", { jobId: duplicate.jobId }); + const reconnect = await f.login({ accountId: a.accountId }); + expect(await (await f.call("complete", { jobId: reconnect.jobId })).json()).toMatchObject({ activation: "ready", accountId: a.accountId }); + expect(listAccounts()).toHaveLength(1); + expect(f.ctx.config.providers[ready.providerName].contextWindow).toBe(64000); +}); +test("reconnect reserves a hidden draft when twenty accounts are already saved", async () => { + const f = accountFixture(); + let firstId = ""; + for (let i = 0; i < 20; i++) { + f.hash(i.toString(16).padStart(64, "0")); + const account = await f.login({ label: `Account ${i}` }); + await f.call("complete", { jobId: account.jobId }); + firstId ||= account.accountId; + } + expect(listAccounts()).toHaveLength(20); + f.hash("0".repeat(64)); + const reconnect = await f.login({ accountId: firstId }); + expect(reconnect).not.toHaveProperty("error"); + expect(listAccounts()).toHaveLength(20); + expect(await (await f.call("complete", { jobId: reconnect.jobId })).json()).toMatchObject({ activation: "ready", accountId: firstId }); + expect(listAccounts()).toHaveLength(20); + expect(await (await f.call("login", { label: "Overflow", runtime: "/runtime", workspace: "/project" })).json()) + .toMatchObject({ error: "account_limit" }); +}); +test("a hidden new-account draft reserves the last account slot only while its job is active", async () => { + const f = accountFixture(); + for (let i = 0; i < 19; i++) { + f.hash(i.toString(16).padStart(64, "0")); + const account = await f.login({ label: `Account ${i}` }); + await f.call("complete", { jobId: account.jobId }); + } + expect(listAccounts()).toHaveLength(19); + + f.hash("f".repeat(64)); + const pending = await f.login({ label: "Pending" }); + expect(listAccounts()).toHaveLength(19); + expect(await (await f.call("login", { label: "Overflow", runtime: "/runtime", workspace: "/project" })).json()) + .toMatchObject({ error: "account_limit" }); + + await f.call("cancel", { jobId: pending.jobId }); + expect(await f.login({ label: "Replacement" })).not.toHaveProperty("error"); +}); +test("account enumeration considers saved accounts after more than one hundred reconnect drafts", () => { + const target = "11111111-1111-4111-8111-111111111111"; + for (let i = 0; i < 100; i++) { + writeAccount({ + id: `00000000-0000-4000-8000-${i.toString().padStart(12, "0")}`, + label: `Reconnect ${i}`, + draftFor: target, + }); + } + for (let i = 0; i < 20; i++) { + writeAccount({ + id: `ffffffff-ffff-4fff-8fff-${i.toString().padStart(12, "0")}`, + label: `Saved ${i}`, + subjectHash: i.toString(16).padStart(64, "0"), + }); + } + expect(listAccounts()).toHaveLength(20); + expect(() => allocateAccount("Overflow")).toThrow("account_limit"); +}); +test("partial catalog activation retries without login; busy and referenced accounts cannot be removed", async () => { + const f = accountFixture(), a = await f.login(); + f.failCatalog(true); + const partial = await (await f.call("complete", { jobId: a.jobId })).json(); + expect(partial).toMatchObject({ activation: "catalog_pending", error: "catalog_update_failed" }); + f.failCatalog(false); + expect(await (await f.call("activate", { accountId: a.accountId })).json()).toMatchObject({ activation: "ready" }); + f.active(true); + expect(await (await f.call("remove", { accountId: a.accountId })).json()).toMatchObject({ error: "account_busy" }); + f.active(false); + f.ctx.config.defaultProvider = partial.providerName; + expect(await (await f.call("remove", { accountId: a.accountId })).json()).toMatchObject({ error: "account_referenced" }); +}); + +test("account removal recognizes case-insensitive provider and model aliases in routed selectors", async () => { + const f = accountFixture(), account = await f.login(); + const ready = await (await f.call("complete", { jobId: account.jobId })).json(); + f.ctx.config.providers[ready.providerName].alias = "personal"; + f.ctx.config.subagentModels = [`PeRsOnAl/${models[0]!.id}`]; + expect(await (await f.call("remove", { accountId: account.accountId })).json()) + .toEqual({ error: "account_referenced" }); + expect(listAccounts().map(saved => saved.id)).toEqual([account.accountId]); + expect(f.ctx.config.providers[ready.providerName]).toBeDefined(); + + f.ctx.config.providers[ready.providerName].modelAliases = { [models[0]!.id]: "personal-glm" }; + f.ctx.config.subagentModels = ["PeRsOnAl-GlM"]; + expect(await (await f.call("remove", { accountId: account.accountId })).json()) + .toEqual({ error: "account_referenced" }); + expect(listAccounts().map(saved => saved.id)).toEqual([account.accountId]); + expect(f.ctx.config.providers[ready.providerName]).toBeDefined(); + + f.ctx.config.subagentModels = []; + expect(await (await f.call("remove", { accountId: account.accountId })).json()).toEqual({ ok: true }); + expect(listAccounts()).toEqual([]); +}); + +test("account removal ignores alias-shaped prose outside routing selector fields", async () => { + const f = accountFixture(), account = await f.login(); + const ready = await (await f.call("complete", { jobId: account.jobId })).json(); + f.ctx.config.providers[ready.providerName].modelAliases = { [models[0]!.id]: "fast" }; + f.ctx.config.providers.reviewer = { + adapter: "openai-responses", baseUrl: "https://reviewer.example.invalid/v1", note: "fast", + }; + + expect(await (await f.call("remove", { accountId: account.accountId })).json()).toEqual({ ok: true }); + expect(listAccounts()).toEqual([]); + expect(f.ctx.config.providers.reviewer?.note).toBe("fast"); +}); + +test("account removal scans routed selectors in providers that will remain configured", async () => { + const f = accountFixture(), account = await f.login(); + const ready = await (await f.call("complete", { jobId: account.jobId })).json(); + const target = f.ctx.config.providers[ready.providerName]; + target.alias = "personal"; + target.modelAliases = { [models[0]!.id]: "personal-glm" }; + f.ctx.config.providers.reviewer = { + adapter: "openai-responses", baseUrl: "https://reviewer.example.invalid/v1", + }; + for (const selector of [ + `${ready.providerName}/${models[0]!.id}`, + `PeRsOnAl/${models[0]!.id}`, + "PeRsOnAl-GlM", + ]) { + f.ctx.config.providers.reviewer.autoReviewModel = selector; + expect(await (await f.call("remove", { accountId: account.accountId })).json()) + .toEqual({ error: "account_referenced" }); + expect(f.ctx.config.providers[ready.providerName]).toBeDefined(); + } + delete f.ctx.config.providers.reviewer.autoReviewModel; + f.ctx.config.providers.reviewer.autoReviewModelOverrides = { + [models[1]!.id]: `${ready.providerName}/${models[1]!.id}`, + }; + expect(await (await f.call("remove", { accountId: account.accountId })).json()) + .toEqual({ error: "account_referenced" }); + delete f.ctx.config.providers.reviewer.autoReviewModelOverrides; + expect(await (await f.call("remove", { accountId: account.accountId })).json()).toEqual({ ok: true }); + expect(f.ctx.config.providers.reviewer).toBeDefined(); +}); + +test("failed provider save after account revocation is explicit and idempotently retryable", async () => { + const f = accountFixture(), account = await f.login(); + const ready = await (await f.call("complete", { jobId: account.jobId })).json(); + const save = f.ctx.deps.saveConfigPreservingClaudeCode; + f.ctx.deps.saveConfigPreservingClaudeCode = () => { throw new Error("private config path and token"); }; + + const partial = await (await f.call("remove", { accountId: account.accountId })).json(); + expect(partial).toEqual({ error: "account_removal_partial" }); + expect(JSON.stringify(partial)).not.toContain("private config path and token"); + expect(f.deps.desktopStatus(account.accountId).connected).toBe(false); + expect(listAccounts().map(saved => saved.id)).toEqual([account.accountId]); + expect(f.ctx.config.providers[ready.providerName]).toBeDefined(); + + f.ctx.deps.saveConfigPreservingClaudeCode = save; + expect(await (await f.call("remove", { accountId: account.accountId })).json()).toEqual({ ok: true }); + expect(listAccounts()).toEqual([]); + expect(f.ctx.config.providers[ready.providerName]).toBeUndefined(); +}); + +test("thrown catalog cleanup after account revocation stays explicit and retryable", async () => { + const f = accountFixture(), account = await f.login(); + const ready = await (await f.call("complete", { jobId: account.jobId })).json(); + f.failCatalog(true); + + expect(await (await f.call("remove", { accountId: account.accountId })).json()) + .toEqual({ error: "catalog_update_failed" }); + expect(f.deps.desktopStatus(account.accountId).connected).toBe(false); + expect(listAccounts().map(saved => saved.id)).toEqual([account.accountId]); + expect(f.ctx.config.providers[ready.providerName]).toBeUndefined(); + + f.failCatalog(false); + expect(await (await f.call("remove", { accountId: account.accountId })).json()).toEqual({ ok: true }); + expect(listAccounts()).toEqual([]); + expect(f.slugs()).toEqual([]); +}); + +test("rename preserves custom model labels and saved accounts survive config reload", async () => { + const f = accountFixture(), a = await f.login(); + const ready = await (await f.call("complete", { jobId: a.jobId })).json(); + const provider = f.ctx.config.providers[ready.providerName]; + provider.modelDisplayNames![models[1]!.id] = "Custom flash"; + expect(await (await f.call("rename", { accountId: a.accountId, label: "Work" })).json()).toMatchObject({ activation: "ready" }); + f.ctx.config = JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + expect(listAccounts().map(a => a.label)).toEqual(["Work"]); + expect(f.ctx.config.providers[ready.providerName].modelDisplayNames).toEqual({ + [models[0]!.id]: "Work / GLM-5.3", [models[1]!.id]: "Custom flash", + }); + expect(await (await f.call("activate", { accountId: a.accountId })).json()).toMatchObject({ activation: "ready" }); + expect(await (await f.call("remove", { accountId: a.accountId })).json()).toEqual({ ok: true }); + expect(listAccounts()).toHaveLength(0); + expect(f.ctx.config.providers[ready.providerName]).toBeUndefined(); + expect(f.slugs()).toHaveLength(0); +}); +test("reconnecting a different identity or failed protocol preserves the original account", async () => { + const f = accountFixture(), a = await f.login(); + const ready = await (await f.call("complete", { jobId: a.jobId })).json(); + writeFileSync(join(accountProfile(a.accountId), "sentinel"), "original"); + const before = structuredClone(f.ctx.config.providers); + f.hash("b".repeat(64)); + const wrong = await f.login({ accountId: a.accountId }); + expect(await (await f.call("complete", { jobId: wrong.jobId })).json()).toMatchObject({ error: "account_identity_mismatch" }); + await f.call("cancel", { jobId: wrong.jobId }); + f.hash("a".repeat(64)); + const retry = await f.login({ accountId: a.accountId }); + f.deps.connectDesktop = async () => { throw new Error("runtime_failed"); }; + expect(await (await f.call("complete", { jobId: retry.jobId })).json()).toMatchObject({ error: "runtime_failed" }); + expect(readFileSync(join(accountProfile(a.accountId), "sentinel"), "utf8")).toBe("original"); + expect(f.ctx.config.providers).toEqual(before); + expect(f.ctx.config.providers[ready.providerName].zcodeAccountId).toBe(a.accountId); + await f.call("cancel", { jobId: retry.jobId }); + expect(listAccounts()).toHaveLength(1); +}); +test("failed official OAuth has a safe error and cancel removes the draft only", async () => { + const f = accountFixture(); + f.deps.runNativeOAuth = async () => { throw new Error("private vendor token"); }; + const a = await f.login(); + const poll = context("/api/zcode-accounts/login?jobId=" + a.jobId, {}, "gui-session"); + poll.req = new Request(poll.url); + const result = await (await handleZcodeAccountRoutes(poll, f.deps))!.json(); + expect(result).toMatchObject({ phase: "failed", error: "native_oauth_failed" }); + expect(JSON.stringify(result)).not.toContain("private vendor token"); + expect(await (await f.call("cancel", { jobId: a.jobId })).json()).toEqual({ ok: true }); + expect(listAccounts()).toHaveLength(0); +}); + +test("failed reconnect retains one hidden draft and blocks retry until explicit cancel", async () => { + const f = accountFixture(); + const account = await f.login(); + expect(await (await f.call("complete", { jobId: account.jobId })).json()).toMatchObject({ activation: "ready" }); + f.deps.runNativeOAuth = async () => { throw new Error("private reconnect failure"); }; + const failed = await f.login({ accountId: account.accountId }); + const poll = context("/api/zcode-accounts/login?jobId=" + failed.jobId, {}, "gui-session", "GET"); + const result = await (await handleZcodeAccountRoutes(poll, f.deps))!.json(); + expect(result).toMatchObject({ phase: "failed", error: "native_oauth_failed", accountId: account.accountId }); + expect(readdirSync(join(home, "zcode-accounts"))).toHaveLength(2); + + expect(await f.login({ accountId: account.accountId })).toEqual({ error: "account_busy" }); + expect(readdirSync(join(home, "zcode-accounts"))).toHaveLength(2); + expect(await (await f.call("cancel", { jobId: failed.jobId })).json()).toEqual({ ok: true }); + expect(readdirSync(join(home, "zcode-accounts"))).toEqual([account.accountId]); + + f.deps.runNativeOAuth = async options => { + options.onEvent({ type: "authenticated", subjectHash: "a".repeat(64) }); + }; + const retry = await f.login({ accountId: account.accountId }); + expect(retry).not.toHaveProperty("error"); + await f.call("cancel", { jobId: retry.jobId }); +}); + +test("concurrent completions cannot register the same identity twice", async () => { + const f = accountFixture(), a = await f.login(), b = await f.login(); + const connect = f.deps.connectDesktop; + let release!: () => void, entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const firstEntered = new Promise(resolve => { entered = resolve; }); + f.deps.connectDesktop = async (...args) => { entered(); await gate; return connect(...args); }; + const first = f.call("complete", {jobId:a.jobId}); + await firstEntered; + expect(await (await f.call("complete", {jobId:b.jobId})).json()).toMatchObject({error:"account_busy"}); + release(); + expect(await (await first).json()).toMatchObject({activation:"ready"}); + expect(await (await f.call("complete", {jobId:b.jobId})).json()).toMatchObject({error:"account_duplicate"}); + expect(Object.values(f.ctx.config.providers).filter(p => p.adapter === "zcode")).toHaveLength(1); +}); diff --git a/tests/providers/zcode-desktop.test.ts b/tests/providers/zcode-desktop.test.ts new file mode 100644 index 0000000000..e683f2e063 --- /dev/null +++ b/tests/providers/zcode-desktop.test.ts @@ -0,0 +1,515 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultDesktopWorkspace, desktopStatus, disconnectDesktop, isDefaultDesktopWorkspace, loadDesktopSettings, resolveDesktopRuntime, validateDesktopWorkspace } from "../../src/adapters/zcode/desktop"; +import { readZcodeModels } from "../../src/adapters/zcode/settings"; +import { accountProfile, accountRoot, allocateAccount } from "../../src/adapters/zcode/accounts"; +import { fetchProviderModels } from "../../src/codex/catalog/provider-fetch"; +import { repoRoot } from "../helpers/repo-root"; + +import { parseNativeOAuthEvent, nativeOAuthCommand } from "../../src/adapters/zcode/native-oauth"; +import { resolveDesktopNode } from "../../src/adapters/zcode/desktop-node"; + +const require = createRequire(import.meta.url); +const { normalizeDesktopConfig, desktopModelCatalog } = require("../../src/adapters/zcode/desktop-bootstrap.cjs"); +const { forceHostBashInput } = require("../../src/adapters/zcode/desktop-host-tool-hook.cjs"); +const { materializeSession, subjectHash } = require("../../src/adapters/zcode/oauth-bootstrap.cjs"); +let root: string; +let previousHome: string | undefined; +let previousSandbox: string | undefined; +beforeEach(() => { + previousSandbox = process.env.OCX_ZCODE_SANDBOX; process.env.OCX_ZCODE_SANDBOX = "1"; + root = mkdtempSync(join(tmpdir(), "ocx-desktop-test-")); + previousHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = join(root, "proxy"); +}); +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousSandbox === undefined) delete process.env.OCX_ZCODE_SANDBOX; else process.env.OCX_ZCODE_SANDBOX = previousSandbox; + rmSync(root, { recursive: true, force: true }); +}); +const provider = () => ({ enabled: true, name: "Z.AI", kind: "anthropic", options: { + baseURL: "https://api.z.ai/api/anthropic", apiKey: "fixture-private-value", apiKeyRequired: true, +}, models: { model: { name: "Model", limit: { context: 10000 } } } }); + +describe("managed ZCode Desktop", () => { + test("normalizes only configured official Z.AI profiles, not custom/direct proxy routes", () => { + const config = normalizeDesktopConfig({ provider: { + "builtin:zai-coding-plan": provider(), + "builtin:zai-empty": { ...provider(), options: { ...provider().options, apiKey: "" } }, + "builtin:zai-disabled": { ...provider(), enabled: false }, + "builtin:zai-local": { ...provider(), options: { baseURL: "https://localhost/v1" } }, + "builtin:zai-alias": { ...provider(), options: { baseURL: "https://api.z.ai.evil.invalid/v1" } }, + custom: provider(), opencodex: provider(), + } }); + expect(Object.keys(config.provider)).toEqual(["builtin:zai-coding-plan"]); + const catalog = desktopModelCatalog(config); + expect(catalog).toEqual([{ id: "builtin:zai-coding-plan/model", providerId: "builtin:zai-coding-plan", modelId: "model", label: "Z.AI / Model", contextWindow: 10000 }]); + expect(JSON.stringify(catalog)).not.toContain("fixture-private-value"); + expect(JSON.stringify(catalog)).not.toContain("apiKey"); + }); + test("filters invalid model entries before applying the public catalog cap", () => { + const invalid = Object.fromEntries(Array.from({ length: 210 }, (_, i) => [`invalid ${i}`, {}])); + const config = normalizeDesktopConfig({ provider: { "builtin:zai-coding-plan": { + ...provider(), models: { ...invalid, valid: { name: "Valid" } }, + } } }); + expect(desktopModelCatalog(config).map((model: { modelId: string }) => model.modelId)).toContain("valid"); + expect(config.model).toEqual({ main: "builtin:zai-coding-plan/valid", lite: "builtin:zai-coding-plan/valid" }); + }); + test("managed host config deterministically disables only ZCode's Bash sandbox", () => { + const input = { provider: { "builtin:zai-coding-plan": provider() } }; + const isolated = normalizeDesktopConfig(input); + expect(isolated.hooks).toBeUndefined(); + + const host = normalizeDesktopConfig(input, { hostExecution: true }); + expect(host.hooks.events.PreToolUse).toMatchObject([{ + matcher: "^Bash$", hooks: [{ type: "process", timeoutMs: 2_000 }], + }]); + const hook = host.hooks.events.PreToolUse[0].hooks[0]; + expect(hook.command).toBe(process.execPath); + expect(hook.args).toEqual([require.resolve("../../src/adapters/zcode/desktop-host-tool-hook.cjs")]); + const original = { + command: "cat /outside/workspace", description: "read fixture", + dangerouslyDisableSandbox: false, + }; + expect(forceHostBashInput({ + hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: original, + })).toEqual({ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { + ...original, dangerouslyDisableSandbox: true, + } } }); + expect(original.dangerouslyDisableSandbox).toBeFalse(); + expect(() => forceHostBashInput({ + hook_event_name: "PreToolUse", tool_name: "Read", tool_input: original, + })).toThrow("invalid hook input"); + }); + test("runtime selection requires a Desktop resources layout, not arbitrary commands", () => { + expect(() => resolveDesktopRuntime("node -e malicious")).toThrow("desktop_missing"); + const glm = join(root, "app/resources/glm"); mkdirSync(glm, { recursive: true }); + writeFileSync(join(glm, "zcode.cjs"), ""); + expect(() => resolveDesktopRuntime(join(root, "app"))).toThrow("desktop_missing"); + writeFileSync(join(root, "app/resources/app.asar"), "fixture"); + expect(resolveDesktopRuntime(join(root, "app"))).toBe(join(glm, "zcode.cjs")); + }); + test("workspace rejects system roots and secret stores, follows aliases before checking", () => { + expect(() => validateDesktopWorkspace("/")).toThrow("workspace_invalid"); + expect(() => validateDesktopWorkspace("relative")).toThrow("workspace_invalid"); + mkdirSync(process.env.OPENCODEX_HOME!, { recursive: true }); + expect(() => validateDesktopWorkspace(process.env.OPENCODEX_HOME!)).toThrow("workspace_invalid"); + const project = join(root, "project"); mkdirSync(project); + expect(validateDesktopWorkspace(project)).toBe(project); + if (process.platform !== "win32") { + symlinkSync(process.env.OPENCODEX_HOME!, join(root, "alias")); + expect(() => validateDesktopWorkspace(join(root, "alias"))).toThrow("workspace_invalid"); + } + }); + test("sandbox checks canonical config paths while allowing only its canonical managed workspace", () => { + if (process.platform === "win32") return; + const realConfig = join(root, "real-config"); + const linkedConfig = join(root, "linked-config"); + mkdirSync(join(realConfig, "zcode-desktop"), { recursive: true }); + symlinkSync(realConfig, linkedConfig); + process.env.OPENCODEX_HOME = linkedConfig; + expect(() => validateDesktopWorkspace(linkedConfig)).toThrow("workspace_invalid"); + expect(() => validateDesktopWorkspace(realConfig)).toThrow("workspace_invalid"); + const protectedChild = join(realConfig, "zcode-desktop", "private"); + mkdirSync(protectedChild); + expect(() => validateDesktopWorkspace(protectedChild)).toThrow("workspace_invalid"); + const managed = join(linkedConfig, "zcode-desktop", "workspace"); + expect(validateDesktopWorkspace(managed)).toBe(join(realConfig, "zcode-desktop", "workspace")); + expect(isDefaultDesktopWorkspace(managed)).toBe(true); + expect(isDefaultDesktopWorkspace(join(realConfig, "zcode-desktop", "workspace"))).toBe(true); + expect(isDefaultDesktopWorkspace(join(realConfig, "zcode-desktop", "other"))).toBe(false); + expect(defaultDesktopWorkspace()).toBe(managed); + }); + test("sandbox allows only the matching account-managed workspace below the config root", async () => { + const { allocateAccount } = await import("../../src/adapters/zcode/accounts"); + const account = allocateAccount("Personal"); + const managed = join(process.env.OPENCODEX_HOME!, "zcode-accounts", account.id, "workspace"); + expect(validateDesktopWorkspace(managed, account.id)).toBe(managed); + expect(() => validateDesktopWorkspace(managed)).toThrow("workspace_invalid"); + const other = allocateAccount("Work"); + expect(() => validateDesktopWorkspace(managed, other.id)).toThrow("workspace_invalid"); + }); + test("disconnect persists revocation instead of falling back to operator env", async () => { + expect(loadDesktopSettings()).toBeUndefined(); + await disconnectDesktop(); + const path = join(process.env.OPENCODEX_HOME!, "zcode-desktop/connection.json"); + expect(JSON.parse(readFileSync(path, "utf8")).connected).toBe(false); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600); + expect(() => loadDesktopSettings()).toThrow("disconnected"); + }); + test("status retains configured state when prerequisites make the connection unusable", () => { + const directory = join(process.env.OPENCODEX_HOME!, "zcode-desktop"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "connection.json"), JSON.stringify({ + version: 1, connected: true, generation: crypto.randomUUID(), runtime: "/missing/runtime", + workspace: root, models: [], + }), { mode: 0o600 }); + expect(desktopStatus()).toMatchObject({ configured: true, connected: false }); + expect(desktopStatus().issue).toBeDefined(); + }); + test("managed model discovery reads only the public cache, not Desktop credentials", () => { + const result = readZcodeModels({ command: [], home: root, workspace: "/workspace", settingsPath: "/does/not/exist", + scope: "desktop:test", desktopModels: [{ id: "builtin:zai/model", providerId: "builtin:zai", modelId: "model", label: "Model" }] }); + expect(result[0]?.id).toBe("builtin:zai/model"); + expect(result[0]?.runtimeModel).toEqual({}); + }); + test("managed discovery preserves a configured account model display name", async () => { + process.env.OCX_ZCODE_SANDBOX = "0"; + const account = allocateAccount("Personal"); + const profile = join(accountProfile(account.id), ".zcode/v2"); + mkdirSync(profile, { recursive: true }); + writeFileSync(join(profile, "config.json"), "{}", { mode: 0o600 }); + const resources = join(root, "catalog-app/resources"); + mkdirSync(join(resources, "glm"), { recursive: true }); + writeFileSync(join(resources, "app.asar"), "fixture"); + const runtime = join(resources, "glm/zcode.cjs"); + writeFileSync(runtime, ""); + const workspace = defaultDesktopWorkspace(account.id); + mkdirSync(workspace, { recursive: true }); + const model = { id: "builtin:zai-coding-plan/GLM-5.3", providerId: "builtin:zai-coding-plan", + modelId: "GLM-5.3", label: "Z.AI / GLM-5.3" }; + writeFileSync(join(accountRoot(account.id), "connection.json"), JSON.stringify({ + version: 1, connected: true, generation: crypto.randomUUID(), runtime, workspace, models: [model], + }), { mode: 0o600 }); + const discovered = await fetchProviderModels("zcode-personal", { + adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", zcodeAccountId: account.id, + modelDisplayNames: { [model.id]: "Personal / Custom GLM" }, + }, 0); + expect(discovered).toHaveLength(1); + expect(discovered[0]?.displayName).toBe("Personal / Custom GLM"); + }); +}); + +test("Node selection skips an incompatible nvm prefix and fails safely", () => { + if (process.platform === "win32") return; + const oldPath = process.env.PATH; + const old = join(root, "old/bin"), modern = join(root, "modern/bin"); + mkdirSync(old, { recursive: true }); mkdirSync(modern, { recursive: true }); + // Executable fixtures exercise selection independently of host Node installations. + writeFileSync(join(old, "node"), "#!/bin/sh\necho private-vendor-diagnostic >&2\nexit 1\n", { mode: 0o755 }); + writeFileSync(join(modern, "node"), "#!/bin/sh\nprintf compatible\n", { mode: 0o755 }); + try { + expect(resolveDesktopNode(old + ":" + modern)).toBe(join(modern, "node")); + expect(() => resolveDesktopNode(old)).toThrow("node_incompatible"); + expect(() => resolveDesktopNode(join(root, "absent"))).toThrow("node_missing"); + expect(() => resolveDesktopNode(".")).toThrow("node_missing"); + const bwrap = Bun.which("bwrap"); + if (bwrap) { + symlinkSync(bwrap, join(old, "bwrap")); + process.env.PATH = old; + expect(desktopStatus().issue).toBe("node_incompatible"); + } + writeFileSync(join(modern, "node"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + expect(() => resolveDesktopNode(modern)).toThrow("node_incompatible"); + } finally { + if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; + } +}); + +test("native OAuth emits only bounded public events, never tokens or vendor errors", () => { + expect(parseNativeOAuthEvent({ type: "authenticated", subjectHash: "a".repeat(64), accessToken: "fixture-private-token" })) + .toEqual({ type: "authenticated", subjectHash: "a".repeat(64) }); + expect(parseNativeOAuthEvent({ type: "error", code: "private-profile-detail", stack: "secret" })) + .toEqual({ type: "error", code: "native_oauth_failed" }); + expect(parseNativeOAuthEvent({ type: "authorization", url: "https://chat.z.ai/api/oauth/authorize?state=fixture" })) + .toEqual({ type: "authorization", url: "https://chat.z.ai/api/oauth/authorize?state=fixture" }); + const credentialUrl = new URL("https://chat.z.ai/"); credentialUrl.username = "fixture"; + for (const url of ["http://chat.z.ai/", "https://chat.z.ai.evil.invalid/", credentialUrl.href, "not a url"]) { + expect(() => parseNativeOAuthEvent({ type: "authorization", url })).toThrow("native_oauth_failed"); + } + expect(() => parseNativeOAuthEvent({ type: "authenticated", subjectHash: "not-an-identity" })).toThrow("native_oauth_failed"); + expect(() => parseNativeOAuthEvent({ type: "tokens", accessToken: "secret" })).toThrow("native_oauth_failed"); +}); + +test("refresh validates restored identity before model-provider profile mutation", async () => { + const profile = join(root, "refresh-profile"); mkdirSync(profile, { mode: 0o700 }); + const sentinel = join(profile, "sentinel"); writeFileSync(sentinel, "unchanged"); + const result = { kind: "session", provider: "zai", userInfo: { id: "different-user" } }; + const calls: string[] = []; + const models = { call: async (method: string) => { calls.push(method); writeFileSync(sentinel, "mutated"); } }; + await expect(materializeSession(result, models, "a".repeat(64))).rejects.toThrow("account_identity_mismatch"); + expect(calls).toEqual([]); + expect(readFileSync(sentinel, "utf8")).toBe("unchanged"); + expect(subjectHash(result)).toMatch(/^[a-f0-9]{64}$/); +}); + +test("native OAuth sandbox refuses the live HOME and symlink profiles", () => { + if (process.platform !== "linux") return; + expect(() => nativeOAuthCommand("/not/used", homedir(), "login")).toThrow("profile_invalid"); + const privateHome = join(root, "private"); + mkdirSync(privateHome, { mode: 0o700 }); + symlinkSync(privateHome, join(root, "profile-link")); + expect(() => nativeOAuthCommand("/not/used", join(root, "profile-link"), "login")).toThrow("profile_invalid"); +}); + + +test("sandbox preflight executes a child and hides uid-map diagnostics", async () => { + if (process.platform !== "linux") return; + const { verifyDesktopSandbox } = await import("../../src/adapters/zcode/desktop-sandbox"); + const executable = join(root, "bwrap-probe"); + writeFileSync(executable, "#!/bin/sh\necho 'bwrap: setting up uid map: Permission denied' >&2\nexit 1\n", { mode: 0o755 }); + expect(() => verifyDesktopSandbox(executable)).toThrow("sandbox_unavailable"); + // A failed probe is not cached: an operator correction can be retried immediately. + writeFileSync(executable, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + expect(() => verifyDesktopSandbox(executable)).not.toThrow(); + writeFileSync(executable, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + expect(() => verifyDesktopSandbox(executable)).toThrow("sandbox_unavailable"); +}); + +test("host execution is the default; a workspace is not a filesystem boundary", async () => { + const { desktopSandboxEnabled } = await import("../../src/adapters/zcode/desktop"); + delete process.env.OCX_ZCODE_SANDBOX; + expect(desktopSandboxEnabled()).toBe(false); + expect(validateDesktopWorkspace(homedir())).toBe(homedir()); + process.env.OCX_ZCODE_SANDBOX = "1"; + expect(desktopSandboxEnabled()).toBe(true); + expect(() => validateDesktopWorkspace(homedir())).toThrow("workspace_invalid"); +}); + +test("host bootstrap reads and writes outside workspace without touching the source profile", async () => { + if (process.platform !== "linux") return; // Managed Desktop prerequisites currently support Linux only. + const { ZcodeClient } = await import("../../src/adapters/zcode/client"); + const { fileURLToPath } = await import("node:url"); + const home = join(root, "state"), workspace = join(root, "project"); + mkdirSync(join(home, ".zcode/cli/db"), { recursive: true }); + mkdirSync(workspace); + const external = join(root, "outside-report.txt"); + writeFileSync(external, "report fixture"); + const config = join(root, "desktop-config.json"); + const original = JSON.stringify({ provider: { "builtin:zai-coding-plan": provider() } }); + writeFileSync(config, original); + const runtime = join(root, "official-runtime-fixture.cjs"); + writeFileSync(runtime, ` + const fs=require("node:fs"); + const os=require("node:os"); + const path=require("node:path"); + const {spawnSync}=require("node:child_process"); + require("node:readline").createInterface({input:process.stdin}).on("line",line=>{ + const r=JSON.parse(line); + const external=${JSON.stringify(external)}; + const value=fs.readFileSync(external,"utf8"); + fs.writeFileSync(external,value); + const runtimeHome=os.homedir(); + const settings=JSON.parse(fs.readFileSync(path.join(runtimeHome,".zcode/cli/config.json"),"utf8")); + const nativeHome=spawnSync(process.execPath,["-e","process.stdout.write(require('node:os').homedir())"], + {encoding:"utf8",env:process.env}).stdout; + process.stdout.write(JSON.stringify({id:r.id,result:{read:value,written:true,cwd:process.cwd(), + hostHome:process.env.HOME,runtimeHome,nativeHome,dataBase:process.env.ZCODE_DATA_BASE_DIR, + runtimeMarker:Object.hasOwn(process.env,"OCX_ZCODE_RUNTIME_HOME"),args:process.argv.slice(2), + providerIds:Object.keys(settings.provider),mainModel:settings.model.main, + storageDir:settings.storage.dir}})+"\\n"); + }); + `); + const settings = { command: [resolveDesktopNode(), fileURLToPath(new URL("../../src/adapters/zcode/desktop-bootstrap.cjs", import.meta.url)), + "--host", runtime, config, workspace, home], home, workspace, settingsPath: "", scope: "desktop:test-host", + hostExecution: true, nativePermissionMode: "yolo" as const }; + // Repeated processes must not fail on an existing config, or overwrite Desktop's file. + for (let i = 0; i < 2; i++) { + const client = new ZcodeClient(settings); + try { + const models = await client.request("opencodex/desktopModels", {}, 3000); + expect(models.models).toHaveLength(1); + const state = await client.request("workspace/readState", {}, 3000); + const runtimeArgs = state.args as string[]; + expect(state).toMatchObject({ read: "report fixture", written: true, cwd: workspace, + hostHome: homedir(), nativeHome: homedir(), dataBase: home, runtimeMarker: false, + providerIds: ["builtin:zai-coding-plan"], mainModel: "builtin:zai-coding-plan/model", + storageDir: join(home, ".zcode") }); + expect(runtimeArgs).toEqual(["app-server"]); + expect(state.runtimeHome).toStartWith(join(home, "turn-")); + expect(state.runtimeHome).not.toBe(homedir()); + } finally { await client.close(); } + } + expect(readdirSync(home).filter(name => name.startsWith("turn-"))).toEqual([]); + expect(readFileSync(config, "utf8")).toBe(original); +}); + +test("host cancellation terminates the official runtime tree and cleans its turn home", async () => { + if (process.platform !== "linux") return; // Managed Desktop prerequisites currently support Linux only. + const { ZcodeClient } = await import("../../src/adapters/zcode/client"); + const { fileURLToPath } = await import("node:url"); + const home = join(root, "cancel-state"), workspace = join(root, "cancel-project"); + mkdirSync(join(home, ".zcode/cli/db"), { recursive: true }); + mkdirSync(workspace); + const config = join(root, "cancel-desktop-config.json"); + writeFileSync(config, JSON.stringify({ provider: { "builtin:zai-coding-plan": provider() } })); + const runtime = join(root, "uncooperative-runtime-fixture.cjs"); + writeFileSync(runtime, ` + const {spawn}=require("node:child_process"); + process.on("SIGTERM",()=>{}); + const tool=spawn(process.execPath,["-e","process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"],{stdio:"ignore"}); + require("node:readline").createInterface({input:process.stdin}).on("line",line=>{ + const request=JSON.parse(line); + process.stdout.write(JSON.stringify({id:request.id,result:{runtimePid:process.pid,toolPid:tool.pid}})+"\\n"); + }); + setInterval(()=>{},1000); + `); + const settings = { command: [resolveDesktopNode(), fileURLToPath(new URL("../../src/adapters/zcode/desktop-bootstrap.cjs", import.meta.url)), + "--host", runtime, config, workspace, home], home, workspace, settingsPath: "", scope: "desktop:test-cancel", + hostExecution: true, nativePermissionMode: "yolo" as const }; + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + let runtimePid = 0, toolPid = 0; + const client = new ZcodeClient(settings); + try { + const state = await client.request("workspace/readState", {}, 3000); + runtimePid = state.runtimePid as number; toolPid = state.toolPid as number; + expect(alive(runtimePid)).toBe(true); expect(alive(toolPid)).toBe(true); + await client.close(); + await Bun.sleep(100); + expect(alive(runtimePid)).toBe(false); + expect(alive(toolPid)).toBe(false); + expect(readdirSync(home).filter(name => name.startsWith("turn-"))).toEqual([]); + } finally { + await client.close(); + for (const pid of [runtimePid, toolPid]) if (pid && alive(pid)) try { process.kill(pid, "SIGKILL"); } catch { /* already exited */ } + } +}); + +test("missing or denied optional sandbox never falls back, while default host mode needs no bwrap", () => { + if (process.platform !== "linux") return; + const path = process.env.PATH; + const bin = join(root, "host-bin"); mkdirSync(bin); + writeFileSync(join(bin, "node"), "#!/bin/sh\nprintf compatible\n", { mode: 0o755 }); + try { + process.env.PATH = bin; + delete process.env.OCX_ZCODE_SANDBOX; + expect(desktopStatus().issue).not.toBe("sandbox_missing"); + expect(desktopStatus().sandbox).toBe(false); + process.env.OCX_ZCODE_SANDBOX = "1"; + expect(desktopStatus().issue).toBe("sandbox_missing"); + writeFileSync(join(bin, "bwrap"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + expect(desktopStatus().issue).toBe("sandbox_unavailable"); + expect(desktopStatus().sandbox).toBe(true); + } finally { + if (path === undefined) delete process.env.PATH; else process.env.PATH = path; + } +}); + +test("saved account routing and settings are explicit and never fall back to Desktop", async () => { + const { allocateAccount, accountRoot, accountProfile } = await import("../../src/adapters/zcode/accounts"); + const { desktopRoutingModelIds } = await import("../../src/adapters/zcode/desktop"); + const { loadZcodeSettings } = await import("../../src/adapters/zcode/settings"); + const { knownModelIdsForProvider } = await import("../../src/router"); + const a = allocateAccount("Personal"), b = allocateAccount("Work"); + for (const [account, modelId] of [[a, "personal-model"], [b, "work-model"]] as const) { + writeFileSync(join(accountRoot(account.id), "connection.json"), JSON.stringify({ + version: 1, connected: true, generation: crypto.randomUUID(), runtime: "/not-launched", workspace: root, + models: [{ id: "builtin:zai/" + modelId, providerId: "builtin:zai", modelId, label: modelId }], + }), { mode: 0o600 }); + } + expect(desktopRoutingModelIds(a.id)).toEqual(["builtin:zai/personal-model"]); + expect(desktopRoutingModelIds(b.id)).toEqual(["builtin:zai/work-model"]); + expect(knownModelIdsForProvider("personal", { adapter: "zcode", baseUrl: "https://zcode.z.ai", zcodeAccountId: a.id })) + .not.toContain("builtin:zai/work-model"); + expect(accountProfile(a.id)).not.toBe(accountProfile(b.id)); + expect(() => loadZcodeSettings(process.env, "../escape")).toThrow(); + expect(() => loadZcodeSettings(process.env, null as never)).toThrow(); + expect(() => loadZcodeSettings(process.env, crypto.randomUUID())).toThrow(); + await disconnectDesktop(a.id); + expect(desktopRoutingModelIds(a.id)).toEqual([]); + expect(desktopRoutingModelIds(b.id)).toEqual(["builtin:zai/work-model"]); +}); + +test("Desktop connection generations share a stable physical-profile lock", () => { + if (process.platform !== "linux") return; + const oldPath = process.env.PATH; + const oldSandbox = process.env.OCX_ZCODE_SANDBOX; + process.env.PATH = "/usr/bin:/bin"; + process.env.OCX_ZCODE_SANDBOX = "0"; + try { + const account = allocateAccount("Lock fixture"); + const profile = join(accountProfile(account.id), ".zcode/v2"); + mkdirSync(profile, { recursive: true }); + writeFileSync(join(profile, "config.json"), "{}", { mode: 0o600 }); + const resources = join(root, "lock-app/resources"); + mkdirSync(join(resources, "glm"), { recursive: true }); + writeFileSync(join(resources, "app.asar"), "fixture"); + const runtime = join(resources, "glm/zcode.cjs"); + writeFileSync(runtime, ""); + const workspace = join(root, "lock-workspace"); + mkdirSync(workspace); + const connectionPath = join(accountRoot(account.id), "connection.json"); + const connection = { version: 1, connected: true, generation: crypto.randomUUID(), runtime, workspace, + models: [{ id: "builtin:zai/model", providerId: "builtin:zai", modelId: "model", label: "Model" }] }; + writeFileSync(connectionPath, JSON.stringify(connection), { mode: 0o600 }); + const before = loadDesktopSettings(account.id)!; + writeFileSync(connectionPath, JSON.stringify({ ...connection, generation: crypto.randomUUID() }), { mode: 0o600 }); + const after = loadDesktopSettings(account.id)!; + expect(after.lockKey).toBe(before.lockKey); + expect(after.scope).not.toBe(before.scope); + } finally { + if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; + if (oldSandbox === undefined) delete process.env.OCX_ZCODE_SANDBOX; else process.env.OCX_ZCODE_SANDBOX = oldSandbox; + } +}); + +test("ZCode catalog advertises sidecar-backed attachments for legacy and account providers", async () => { + const {applyProviderConfigHints} = await import("../../src/codex/catalog/provider-fetch"); + for(const name of ["zcode","work-account"]) { + const result = applyProviderConfigHints(name,{adapter:"zcode",authMode:"local",baseUrl:"https://zcode.z.ai"},{provider:name,id:"builtin:zai-coding-plan/GLM-5.3-Flash",inputModalities:["text"]}); + expect(result.inputModalities).toContain("image"); + } +}); + +test("ZCode GLM-5.3 catalog exposes only real levels plus Codex ultra", async () => { + const runtimeRoot = join(root, "app"), runtime = join(runtimeRoot, "resources/glm/zcode.cjs"); + mkdirSync(join(runtimeRoot, "resources/glm"), { recursive: true }); + writeFileSync(join(runtimeRoot, "resources/app.asar"), "fixture"); writeFileSync(runtime, ""); + const opencodexHome = join(root, "proxy"); + const desktopHome = join(root, "desktop-home"); + mkdirSync(join(desktopHome, ".zcode/v2"), { recursive: true }); + writeFileSync(join(desktopHome, ".zcode/v2/config.json"), "{}"); + const workspace = join(root, "workspace"), connectionDir = join(opencodexHome, "zcode-desktop"); + mkdirSync(workspace); mkdirSync(connectionDir, { recursive: true }); + const ids = ["builtin:zai-coding-plan/GLM-5.3", "builtin:zai-coding-plan/GLM-5.3-Flash"]; + writeFileSync(join(connectionDir, "connection.json"), JSON.stringify({ + version: 1, connected: true, generation: crypto.randomUUID(), runtime, workspace, + models: ids.map(id => ({ id, providerId: "builtin:zai-coding-plan", modelId: id.split("/")[1], label: id })), + }), { mode: 0o600 }); + const providerName = `zcode-fixture-${crypto.randomUUID()}`; + // Catalog discovery reads process-global managed Desktop state. Keep this assertion in a child + // so unrelated parallel files that exercise a different OPENCODEX_HOME cannot replace its input. + const script = ` + const { gatherRoutedModels } = await import("./src/codex/catalog/provider-fetch.ts"); + const providerName = ${JSON.stringify(providerName)}; + const models = await gatherRoutedModels({ port: 0, defaultProvider: providerName, providers: { + [providerName]: { adapter: "zcode", authMode: "local", baseUrl: "https://zcode.z.ai", + liveModels: true, contextWindow: 8192 }, + } }); + console.log(JSON.stringify(models)); + `; + const child = Bun.spawnSync([process.execPath, "-e", script], { + cwd: repoRoot(), + env: { + ...process.env, + HOME: desktopHome, + OPENCODEX_HOME: opencodexHome, + OCX_ZCODE_SANDBOX: "0", + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(child.exitCode, child.stderr.toString()).toBe(0); + const models = JSON.parse(child.stdout.toString()) as Array<{ + provider: string; + id: string; + contextWindow?: number; + inputModalities?: string[]; + reasoningEfforts?: string[]; + }>; + const { buildCatalogEntries } = await import("../../src/codex/catalog/sync"); + for (const modelId of ids) { + const model = models.find(row => row.provider === providerName && row.id === modelId); + expect(model?.contextWindow, JSON.stringify(models)).toBe(8192); + expect(model?.inputModalities).toContain("image"); + expect(model?.reasoningEfforts).toEqual(["low", "high", "max"]); + const [entry] = buildCatalogEntries(null, [], [model!]); + const efforts = (entry?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort); + expect(efforts).toEqual(["low", "high", "max", "ultra"]); + expect(efforts).not.toContain("medium"); + expect(efforts).not.toContain("xhigh"); + expect(entry?.default_reasoning_level).toBe("max"); + } +}); diff --git a/tests/providers/zcode-quota.test.ts b/tests/providers/zcode-quota.test.ts new file mode 100644 index 0000000000..670bc01410 --- /dev/null +++ b/tests/providers/zcode-quota.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { parseZcodeQuota, parseZcodeQuotaSnapshot, readZcodeQuota, type QuotaContext, type ZcodeQuotaSnapshot } from "../../src/adapters/zcode/quota"; +import { getCachedProviderQuota, replaceCachedProviderQuotas } from "../../src/providers/quota-routing-cache"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +const now = 1_789_112_000_000; +const window = (unit = 3, number = 5) => ({ type: "CREDIT_LIMIT", unit, number, usage: 1000, remaining: 750, percentage: 25, nextResetTime: now + 3600000 }); +const snapshot = (limits: unknown[]) => ({ generatedAt: now, limits }); +const provider = { adapter: "zcode", baseUrl: "https://zcode.z.ai", authMode: "local" as const }; +const context = (identity = "account-a"): QuotaContext => ({ identity, runtimeRoot: "/official", config: "/isolated/config.json", managed: true, sourceProvider: "builtin:zai-coding-plan" }); + +describe("ZCode native subscription quota", () => { + test("maps real remaining credits and resets into the shared OpenAI quota contract", () => { + expect(parseZcodeQuota(snapshot([window(), { ...window(6, 1), remaining: 100 }]), now)).toEqual({ + updatedAt: now, fiveHourPercent: 25, fiveHourResetAt: now + 3600000, + weeklyPercent: 90, weeklyResetAt: now + 3600000, + }); + expect(parseZcodeQuota(snapshot([{ ...window(), remaining: 997.5, percentage: 1 }]), now)?.fiveHourPercent).toBeCloseTo(0.25); + }); + test("zero balance is exhausted; missing, impossible or stale data never becomes full balance", () => { + expect(parseZcodeQuota(snapshot([{ ...window(), remaining: 0 }]), now)?.fiveHourPercent).toBe(100); + for (const limits of [[], [null], [{ ...window(), remaining: -1 }], [{ ...window(), usage: 0 }], [{ ...window(), remaining: 1001 }], [{ ...window(), remaining: NaN }], [window(), window()]]) { + expect(parseZcodeQuota(snapshot(limits), now)).toBeNull(); + } + expect(parseZcodeQuota(snapshot([window()]), now + 31 * 60_000)).toBeNull(); + expect(parseZcodeQuota({ generatedAt: now + 120_000, limits: [window()] }, now)).toBeNull(); + }); + test("does not guess unnamed windows or invent missing weekly limits", () => { + expect(parseZcodeQuota(snapshot([{ ...window(), unit: 7 }, { type: "TIME_LIMIT", remaining: 100 }]), now)).toBeNull(); + expect(parseZcodeQuotaSnapshot(snapshot([{ ...window(), unit: 7 }, { type: "TIME_LIMIT", remaining: 100 }]), now)).toEqual({ kind: "empty" }); + expect(parseZcodeQuotaSnapshot(snapshot([null]), now)).toBeNull(); + expect(parseZcodeQuotaSnapshot(snapshot([ + ...Array.from({ length: 20 }, () => ({ type: "TIME_LIMIT" })), window(), + ]), now)).toMatchObject({ kind: "quota", quota: { fiveHourPercent: 25 } }); + expect(parseZcodeQuotaSnapshot({ generatedAt: now - 31 * 60_000, limits: [] }, now)).toBeNull(); + expect(parseZcodeQuota(snapshot([window()]), now)?.weeklyPercent).toBeUndefined(); + expect(parseZcodeQuota(snapshot([{ type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 40 }]), now)?.weeklyPercent).toBe(40); + }); + test("coalesces native reads and discards snapshots after account change", async () => { + let calls = 0; let identity = "account-a"; + let finish!: (value: ZcodeQuotaSnapshot | null) => void; + const deps = { context: () => context(identity), probe: async () => { calls++; return new Promise(resolve => { finish = resolve; }); } }; + const first = readZcodeQuota(provider, deps); const second = readZcodeQuota(provider, deps); + expect(calls).toBe(1); + identity = "account-b"; + finish(parseZcodeQuotaSnapshot(snapshot([window()]), now)); + expect(await first).toBeNull(); expect(await second).toBeNull(); + }); + test("unavailable runtime has no direct API fallback", async () => { + expect(await readZcodeQuota(provider, { context: () => { throw new Error("not configured"); } })).toBeNull(); + const bootstrap = readFileSync(repoPath("src/adapters/zcode/quota-bootstrap.cjs"), "utf8"); + expect(bootstrap).toContain("getEntitlementSnapshot"); + expect(bootstrap).not.toMatch(/\bfetch\s*\(|https\.request|useCodingPlanReset|session\/send/); + const quotaSource = readFileSync(repoPath("src/adapters/zcode/quota.ts"), "utf8"); + expect(quotaSource).toContain("settings.desktopRuntime"); + expect(quotaSource).not.toMatch(/\bdesktopStatus\s*\(/); + }); + test("preserves unavailable and authoritative-empty probe states separately", async () => { + expect(await readZcodeQuota(provider, { context: () => context(), probe: async () => null })).toBeNull(); + expect(await readZcodeQuota(provider, { context: () => context(), probe: async () => ({ kind: "empty" }) })).toEqual({ + identity: "account-a", kind: "empty", + }); + }); + test("display snapshots do not change automatic routing policy", () => { + replaceCachedProviderQuotas([{ provider: "zcode", label: "ZCode", source: "zcode-desktop", updatedAt: now, quota: { updatedAt: now, fiveHourPercent: 100 } }]); + expect(getCachedProviderQuota("zcode", now)).toBeNull(); + }); +}); + +test("concurrent saved accounts receive only their own quota snapshots", async () => { + const a = { ...provider, zcodeAccountId: "account-a" }, b = { ...provider, zcodeAccountId: "account-b" }; + let calls = 0; + const deps = { context: (p: { zcodeAccountId?: string }) => context(p.zcodeAccountId!), probe: async (c: QuotaContext) => { + calls++; await new Promise(r => setTimeout(r, 5)); + return { kind: "quota" as const, quota: { updatedAt: now, fiveHourPercent: c.identity === "account-a" ? 10 : 90 } }; + } }; + const [first, repeated, second] = await Promise.all([readZcodeQuota(a, deps), readZcodeQuota(a, deps), readZcodeQuota(b, deps)]); + expect(calls).toBe(2); + expect(first).toEqual(repeated); + expect(first).toMatchObject({ identity: "account-a", kind: "quota", quota: { fiveHourPercent: 10 } }); + expect(second).toMatchObject({ identity: "account-b", kind: "quota", quota: { fiveHourPercent: 90 } }); +}); diff --git a/tests/providers/zcode-routing.test.ts b/tests/providers/zcode-routing.test.ts new file mode 100644 index 0000000000..1333f3ea7f --- /dev/null +++ b/tests/providers/zcode-routing.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest } from "../../src/types"; + +const vision = await import("../../src/vision"); +const search = await import("../../src/web-search"); +const images = await import("../../src/images"); +const resolver = await import("../../src/server/adapter-resolve"); +const forbidden = mock(() => { throw new Error("A native ZCode turn must not plan direct-API sidecars."); }); +const describeImages = mock(async (parsed: OcxParsedRequest, plan: {backend: string; routedModel?: string}) => { + expect(plan).toMatchObject({ backend: "routed", routedModel: "eyes/vision-model" }); + vision.stripImagesInPlace(parsed); + parsed.context.messages.push({ role: "user", content: "Vision caption: a red square", timestamp: 0 }); +}); +mock.module("../../src/vision", () => ({ ...vision, describeImagesInPlace: describeImages })); +mock.module("../../src/web-search", () => ({ ...search, + shouldResolveOpenAiWebSearchSidecar: forbidden, planWebSearch: forbidden })); +mock.module("../../src/images", () => ({ ...images, planImageBridge: forbidden, planVideoBridge: forbidden })); +let sends = 0; +let received: OcxParsedRequest | undefined; +let terminal: AdapterEvent = { type: "done" }; +mock.module("../../src/server/adapter-resolve", () => ({ ...resolver, + resolveAdapter: () => ({ name: "zcode", replaySafe: false, allowExternalSidecars: false, allowVisionSidecar: true, + buildRequest: forbidden, async *parseStream() { throw new Error("Unexpected HTTP transport"); }, + async runTurn(_parsed: OcxParsedRequest, _incoming: unknown, emit: (event: AdapterEvent) => void) { + received = _parsed; sends++; emit(terminal); + }, + }), +})); +const { handleResponses } = await import("../../src/server/responses"); +beforeEach(() => { received = undefined; describeImages.mockClear(); sends = 0; forbidden.mockClear(); terminal = { type: "done" }; }); + +function config(): OcxConfig { + return { port: 0, emptyCompletionRetry: true, defaultProvider: "zcode", providers: { + zcode: { adapter: "zcode", baseUrl: "https://zcode.z.ai", authMode: "local", noVisionModels: ["test/model"] }, + } } as OcxConfig; +} +async function request(stream: boolean) { + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "zcode/test/model", stream, input: "hello", + tools: [{ type: "web_search" }, { type: "image_generation" }] }), + }), config(), { model: "", provider: "" }); +} + +describe("ZCode routing keeps inference inside the official agent transport", () => { + for (const stream of [false, true]) { + test(`no helper inference or empty-completion resend (stream=${stream})`, async () => { + const response = await request(stream); + expect(response.status).toBe(200); + await response.text(); + expect(sends).toBe(1); + expect(forbidden).not.toHaveBeenCalled(); + }); + } + test("accepted failure remains incomplete rather than becoming an HTTP retry error", async () => { + terminal = { type: "incomplete", reason: "zcode_agent_interrupted", retryable: false }; + const response = await request(false); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ status: "incomplete", incomplete_details: { reason: "zcode_agent_interrupted" } }); + expect(sends).toBe(1); + expect(forbidden).not.toHaveBeenCalled(); + }); +}); + +for (const model of ["builtin:zai-coding-plan/GLM-5.3", "builtin:zai-coding-plan/GLM-5.3-Flash"]) { + for (const enabled of [true, false]) { + test(`ZCode image adaptation uses configured vision only: ${model}, enabled=${enabled}`, async () => { + const c = config(); + c.providers.zcode!.noVisionModels = []; + c.providers.eyes = { adapter: "openai-chat", baseUrl: "https://example.invalid" }; + c.visionSidecar = { enabled, backend: "routed", model: "eyes/vision-model" }; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "zcode/" + model, stream: false, input: [{ role: "user", content: [ + {type:"input_text",text:"Describe this"}, {type:"input_image",image_url:"data:image/png;base64,aGVsbG8="}, + ] }], tools: [{type:"web_search"}, {type:"image_generation"}] }), + }), c, { model:"", provider:"" }); + await response.text(); + expect(response.status).toBe(200); + expect(sends).toBe(1); + expect(describeImages).toHaveBeenCalledTimes(enabled ? 1 : 0); + expect(JSON.stringify(received?.context)).not.toContain('"type":"image"'); + expect(JSON.stringify(received?._rawBody)).not.toContain('"type":"input_image"'); + if (enabled) expect(JSON.stringify(received?.context)).toContain("Vision caption: a red square"); + expect(forbidden).not.toHaveBeenCalled(); + }); + } +} diff --git a/tests/providers/zcode-transport.test.ts b/tests/providers/zcode-transport.test.ts new file mode 100644 index 0000000000..30d3c20631 --- /dev/null +++ b/tests/providers/zcode-transport.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { homedir } from "node:os"; +import { ZcodeClient, closeZcodeDesktopClients, hasZcodeAccountClients, waitForZcodeAccountClients, type ZcodeSpawn } from "../../src/adapters/zcode/client"; +import { accountRuntimeBusy, refreshAccount } from "../../src/adapters/zcode/account-runtime"; +import type { JsonObject, ZcodeSettings } from "../../src/adapters/zcode/settings"; + +const settings: ZcodeSettings = { command: ["/isolated/launcher", "argument with spaces"], home: "/isolated/home", + workspace: "/workspace", settingsPath: "/isolated/config.json", lockKey: "profile", scope: "test" }; +function fixture(managed = false, hostExecution = false, accountId?: string, autoExit = true) { + const child = new EventEmitter() as EventEmitter & { pid: number; stdin: Writable; stdout: PassThrough; stderr: PassThrough; kill: (signal?: string) => boolean }; + const writes: JsonObject[] = []; + child.pid = 1234; + child.stdin = new Writable({ write(chunk, _encoding, callback) { writes.push(JSON.parse(chunk.toString())); callback(); } }); + child.stdout = new PassThrough(); child.stderr = new PassThrough(); + let exited = false; + const exit = () => { + if (exited) return; + exited = true; child.stdout.end(); child.stderr.end(); child.emit("exit", 0); + }; + child.kill = () => { if (autoExit) queueMicrotask(exit); return true; }; + let invocation: unknown[] = []; + const spawn = ((...args: unknown[]) => { invocation = args; return child; }) as ZcodeSpawn; + const client = new ZcodeClient(managed ? { ...settings, desktopModels: [], hostExecution, accountId } : settings, spawn); + return { child, client, writes, exit, invocation: () => invocation }; +} +const tick = () => new Promise(resolve => setTimeout(resolve, 5)); + +describe("ZCode NDJSON transport", () => { + test("Desktop disconnect notifies an active turn even without pending RPCs", async () => { + const managed = fixture(true); const advanced = fixture(); + let failures = 0; let advancedFailures = 0; + managed.client.onFailure = () => { failures++; }; + advanced.client.onFailure = () => { advancedFailures++; }; + await closeZcodeDesktopClients(); + expect(failures).toBe(1); + expect(advancedFailures).toBe(0); + await expect(managed.client.request("session/create", {})).rejects.toThrow("disconnected"); + await advanced.client.close(); + }); + test("saved-account idle wait completes only after its active child closes", async () => { + const id = crypto.randomUUID(); + const active = fixture(true, false, id); + let idle = false; + const waiting = waitForZcodeAccountClients(id).then(() => { idle = true; }); + await tick(); expect(idle).toBe(false); + await active.client.close(); await waiting; + expect(idle).toBe(true); + }); + test("closing account clients stay busy and share shutdown until the child exits", async () => { + const id = crypto.randomUUID(); + const active = fixture(true, false, id, false); + const first = active.client.close(); + const second = active.client.close(); + expect(second).toBe(first); + let firstClosed = false, secondClosed = false, lateIdle = false; + void first.then(() => { firstClosed = true; }); + void second.then(() => { secondClosed = true; }); + const waiting = waitForZcodeAccountClients(id).then(() => { lateIdle = true; }); + await tick(); + expect(hasZcodeAccountClients(id)).toBe(true); + expect([firstClosed, secondClosed, lateIdle]).toEqual([false, false, false]); + active.child.emit("error", new Error("signal failed")); + await tick(); + expect(hasZcodeAccountClients(id)).toBe(true); + expect([firstClosed, secondClosed, lateIdle]).toEqual([false, false, false]); + active.exit(); + await Promise.all([first, second, waiting]); + expect(hasZcodeAccountClients(id)).toBe(false); + expect([firstClosed, secondClosed, lateIdle]).toEqual([true, true, true]); + }); + test("an expired saved-account refresh reserves the queue and waits out an active turn", async () => { + const id = crypto.randomUUID(); + const active = fixture(true, false, id); + let settled = false; + const refreshing = refreshAccount(id).finally(() => { settled = true; }); + void refreshing.catch(() => {}); + // The missing fixture account would reject immediately if busy accounts were still skipped. + await tick(); + expect(settled).toBe(false); + expect(accountRuntimeBusy(id)).toBe(true); + await active.client.close(); + await expect(refreshing).rejects.toBeInstanceOf(Error); + expect(settled).toBe(true); + expect(accountRuntimeBusy(id)).toBe(false); + }); + test("uses argv without shell and does not inherit provider secrets", async () => { + const f = fixture(); const invocation = f.invocation(); + expect(invocation[0]).toBe("/isolated/launcher"); + expect(invocation[1]).toEqual(["argument with spaces", "app-server"]); + const options = invocation[2] as { shell: boolean; env: Record }; + expect(options.shell).toBe(false); + expect(Object.keys(options.env).sort()).toEqual(["HOME", "PATH", "XDG_CACHE_HOME", "XDG_CONFIG_HOME"]); + await f.client.close(); + }); + test("host tools inherit user configuration but not unrelated provider secrets", async () => { + const token = process.env.OPENCODEX_API_AUTH_TOKEN; + const ghToken = process.env.GH_TOKEN; + try { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-secret"; + process.env.GH_TOKEN = "github-secret"; + const f = fixture(true, true); + const options = f.invocation()[2] as { env: Record }; + expect(options.env.HOME).toBe(homedir()); + expect(options.env.ZCODE_DATA_BASE_DIR).toBe(settings.home); + expect(options.env.PATH).toBe(process.env.PATH); + expect(options.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(options.env.GH_TOKEN).toBeUndefined(); + await f.client.close(); + } finally { + if (token === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = token; + if (ghToken === undefined) delete process.env.GH_TOKEN; else process.env.GH_TOKEN = ghToken; + } + }); + test("correlates fragmented Unicode responses and services runtime preferences", async () => { + const f = fixture(); const pending = f.client.request("session/create", {}); + const bytes = Buffer.from(JSON.stringify({ id: 1, result: { text: "世界" } }) + "\n"); + for (const byte of bytes) f.child.stdout.write(Buffer.from([byte])); + expect(await pending).toEqual({ text: "世界" }); + f.child.stdout.write(JSON.stringify({ id: "server-1", method: "session/requestRuntimePreferences", params: {} }) + "\n"); + await tick(); + expect(f.writes.at(-1)).toMatchObject({ id: "server-1", result: { askUserQuestionAutoResolutionEnabled: false } }); + await f.client.close(); + }); + test("denies interactive permissions rather than silently auto-approving", async () => { + const f = fixture(); + f.child.stdout.write(JSON.stringify({ id: "server-2", method: "interaction/requestPermission", params: {} }) + "\n"); + await tick(); expect(f.writes.at(-1)).toMatchObject({ result: { decision: "deny" } }); + await f.client.close(); + }); + test("malformed protocol rejects pending requests without exposing payload", async () => { + const f = fixture(); const pending = f.client.request("session/create", {}); + f.child.stdout.write("secret-value-not-json\n"); + await expect(pending).rejects.toThrow("invalid or oversized"); + await expect(f.client.request("session/create", {})).rejects.not.toThrow("secret-value"); + await f.client.close(); + }); + test("vendor errors never leak raw messages or stack traces", async () => { + const f = fixture(); const pending = f.client.request("session/create", {}); + f.child.stdout.write(JSON.stringify({ id: 1, error: { message: "secret", data: { stack: "private" } } }) + "\n"); + await expect(pending).rejects.toThrow("ZCode rejected"); + await f.client.close(); + }); + test("EOF and request deadlines cannot leave promises pending", async () => { + const f = fixture(); const pending = f.client.request("session/create", {}); + f.child.stdout.end(); await expect(pending).rejects.toThrow("closed"); await f.client.close(); + const timed = fixture(); await expect(timed.client.request("session/create", {}, 10)).rejects.toThrow("timed out"); + await timed.client.close(); + }); +}); diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 1906d6b1e1..99dc0d09a3 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -847,9 +847,10 @@ describe("Responses previous_response_id state", () => { .toBe("cursor_conv_force_1"); }); - test("adapterNeedsForcedContinuation covers exactly kiro and cursor", () => { + test("adapterNeedsForcedContinuation covers native provider sessions", () => { expect(adapterNeedsForcedContinuation("kiro")).toBe(true); expect(adapterNeedsForcedContinuation("cursor")).toBe(true); + expect(adapterNeedsForcedContinuation("zcode")).toBe(true); expect(adapterNeedsForcedContinuation("openai")).toBe(false); expect(adapterNeedsForcedContinuation("claude")).toBe(false); expect(adapterNeedsForcedContinuation("")).toBe(false); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 59400ff921..cd8e755a14 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -74,9 +74,11 @@ mock.module("../../src/server/adapter-resolve", () => ({ provider.adapter === "test-run-turn" || provider.adapter === "test-kiro" || provider.adapter === "test-owned" + || provider.adapter === "test-zcode" ) { const adapter: ProviderAdapter = { - name: provider.adapter === "test-kiro" ? "kiro" : provider.adapter, + name: provider.adapter === "test-kiro" ? "kiro" + : provider.adapter === "test-zcode" ? "zcode" : provider.adapter, buildRequest: () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), async *parseStream(): AsyncGenerator { yield { type: "error", message: "test runTurn adapter does not use parseStream" }; @@ -2830,6 +2832,49 @@ describe("server combo failover 030 activation matrix", () => { expect(seen).toEqual([undefined, "kiro-owned-conversation"]); }); + test("headerless local ZCode continuation stays owner-fenced and survives store false", async () => { + const { previousResponseProviderState } = await import("../../src/responses/state"); + const accountId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const seen: Array = []; + customRunTurn = async (parsed, _incoming, emit) => { + const sessionId = parsed._providerContinuation?.zcode?.sessionId as string | undefined; + seen.push(sessionId); + emit({ type: "text_delta", text: "continued" }); + emit({ type: "done", providerState: { zcode: { sessionId: sessionId ?? "sess_zcode-owned", scope: "scope-a" } } }); + }; + const config = comboConfig({ + z: provider("test-zcode", "https://zcode.z.ai", "unused", { authMode: "local", zcodeAccountId: accountId }), + }, [{ provider: "z", model: "m1" }]); + + const first = await post(config, { store: false, input: "first" }); + expect(first.status).toBe(200); + const firstJson = await first.json() as { id: string }; + const stored = previousResponseProviderState(firstJson.id); + expect(stored?.__ocxOwner?.credentialIdentity).toMatch(/^local:[0-9a-f]{64}$/); + expect(JSON.stringify(stored)).not.toContain(accountId); + + const second = await post(config, { + store: false, + previous_response_id: firstJson.id, + input: "second", + }); + expect(second.status).toBe(200); + expect(seen).toEqual([undefined, "sess_zcode-owned"]); + + const otherAccount = comboConfig({ + z: provider("test-zcode", "https://zcode.z.ai", "unused", { + authMode: "local", zcodeAccountId: "ffffffff-eeee-4ddd-8ccc-bbbbbbbbbbbb", + }), + }, [{ provider: "z", model: "m1" }]); + const mismatched = await post(otherAccount, { + store: false, + previous_response_id: firstJson.id, + input: "different account", + }); + expect(mismatched.status).toBe(200); + expect(seen).toEqual([undefined, "sess_zcode-owned", undefined]); + }); + test("same Cursor combo target without a parent-thread header retains its conversation id", async () => { const seen: string[] = []; customCursorTransportFactory = () => ({ diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index fc43a0d2f3..bb21ef0016 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -304,3 +304,19 @@ test("explicit routed image declarations outrank stale candidate metadata", () = expect(modelAcceptsImageInput(config, { provider: "custom", id: "modela", inputModalities: ["text"] })).toBe(false); expect(modelAcceptsImageInput(config, { provider: "custom", id: "ModelA:variant", inputModalities: ["text"] })).toBe(false); }); + +test("account-bound ZCode models cannot recursively describe their own images", () => { + const config = configWithProviders({ + personal: { + adapter: "zcode", + baseUrl: "https://zcode.z.ai", + authMode: "local", + zcodeAccountId: "account-test", + }, + }); + expect(modelAcceptsImageInput(config, { + provider: "personal", + id: "builtin:zai-coding-plan/GLM-5.3-Flash", + inputModalities: ["text", "image"], + })).toBe(false); +}); diff --git a/tests/vision/vision-text-only-predicate.test.ts b/tests/vision/vision-text-only-predicate.test.ts index e6f8c6e719..3bc5f236cd 100644 --- a/tests/vision/vision-text-only-predicate.test.ts +++ b/tests/vision/vision-text-only-predicate.test.ts @@ -53,3 +53,12 @@ test("explicit capability keys are exact and take precedence over legacy declara delete config.modelCapabilities!.ModelA; expect(isModelTextOnly(config, "ModelA")).toBe(true); }); + +test("ZCode transport requires vision adaptation for every model even with native image metadata", () => { + for (const id of ["builtin:zai-coding-plan/GLM-5.3", "builtin:zai-coding-plan/GLM-5.3-Flash", "future-model"]) { + expect(isModelTextOnly(provider({ + adapter: "zcode", + modelInputModalities: { [id]: ["text", "image"] }, + }), id)).toBe(true); + } +});