diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fbced..09f9f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,52 @@ product overview. > | Makeitfuture Sustainable Use License 1.1 | 2026-08-20 | never published | > | Makeitfuture Sustainable Use License 1.0 | 2026-08-06 | never published | +## Unreleased + +## 0.7.0 — 2026-10-01 + +- Channel Runtime now shows only engine and model controls for the shared login. Dedicated Codex + sign-in uses a method dropdown: choosing ChatGPT starts the device flow and clicking its code + copies it; the API key field appears only for that method. The shared sign-in moved to Settings. + Slack Settings names the dedicated login and its channel defaults. Codex Cloud MCP discovery + and launch now use that channel account when selected. +- ChatGPT sign-in displays the device link and complete code from current Codex CLI output; + proxied hosts pass the public TLS CA bundle to the login process. +- A channel using its own Codex login now stays on Codex across settings, `/model`, and existing + threads. The settings page hides the redundant engine picker and + explains login storage without showing the internal path. +- **The agent can send any file into the thread.** New gateway tool `slack_share_file` posts a file + from the channel's working folder — PDF, Word, Excel, PowerPoint, ZIP, images, HTML — into the + current thread as the bot, up to 25 MB, exactly like the file explorer's Share button. "Trimite + fișierul" no longer ends in a "which Slack account?" question card: a Composio upload is only for + other channels or DMs now. Files outside the channel folder (including the mounted operator home) + and symlinks out of it are refused. + +- Completed nested background reports stranded by the earlier `invalid_thread_ts` error get one + bounded recovery attempt from their saved output after restart. Their work is not rerun. +- Channel Runtime settings now ask for the Codex login source first. Shared gateway Codex sign-in + is available from the admin Settings page. +- **Nested background agent reports reach their Slack thread.** Delivery now turns an agent's + synthetic session key into the launching thread timestamp before posting the report and menu. + Scheduled reports with synthetic keys post at channel level. This prevents `invalid_thread_ts` + from stranding a completed report. +- Admin channel settings now start ChatGPT device code sign-in or accept an OpenAI API key for a + dedicated Codex login, show its status, and select it after success. +- Channels can select a separate host-side Codex login for ChatGPT subscription access or an + OpenAI API key. Proxy-mode containers receive only channel-bound credential placeholders. + +- **Automatic engine switches now stay with the conversation.** When Claude reaches its limit and + Codex answers, the next message resumes that Codex session instead of retrying Claude. The same + holds when Codex switches to Claude. Existing threads with a successful fallback session adopt + it on their next message; a manual thread engine or model choice still takes precedence. + +- Overview now draws separate stacked columns for each time bucket, shows model totals when a + source, channel or user bar is hovered or focused, and arranges usage sources beside the three + time charts above Models, Channels, Users and Skills. + +- **The folder-generator test no longer scatters projects across the operator's home.** Its custom-workdir fixtures now live under one `ChannelGate Testing` folder, are removed when the test process exits, and stale runs are swept before the next test suite. +- **A provider usage limit no longer blocks an update.** Before and after updating, the updater sends each engine a tiny test prompt inside a throwaway container. A reply like "You've hit your weekly limit" used to refuse the update, even though it proves the engine starts, logs in and reaches its provider. It now counts as reachable. A broken login or engine still stops the update. + ## 0.6.0 — 2026-09-28 - **Claude model choices now show exact versions.** The `/model` picker and admin selectors list diff --git a/FEATURES.md b/FEATURES.md index 3203cb9..71d18de 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,36 @@ # ChannelGate — Features +## Channel-specific Codex authentication + +- Runtime settings ask which Codex login a channel uses. The shared choice shows the channel's + engine, model and effort controls; gateway Codex sign-in lives under Settings → Agent defaults. + The channel choice shows its dedicated Codex login and fixes its engine to Codex. Slack and + Claude continue using the gateway connection. +- Admins can select the gateway Codex login or a dedicated host-side `CODEX_HOME` for a channel + from Runtime → Codex authentication. The dedicated directory is keyed to the conversation ID + and does not fall back to another account when no login exists. +- A sign-in method dropdown starts ChatGPT device code sign-in as soon as it is selected, or + reveals the OpenAI API key field. The code copies when clicked. The editor polls sign-in state, + lets an admin cancel a pending device flow, + and selects the channel login after success. The API returns only the method and device code; + the key enters the Codex CLI through standard input and never appears in a response or argv. +- Device code parsing handles the current Codex CLI's terminal coloring and full code length; + the login process receives the public TLS CA bundle when the host uses a proxy. The browser + shows the sign-in link and code as soon as the CLI supplies them. +- The operator can sign in to that directory with ChatGPT subscription access or an OpenAI + Platform API key. In proxy-mode containers, both methods use a channel-bound placeholder: + ChatGPT access tokens are relayed as JWT-shaped values, while API keys are relayed only to + `api.openai.com`. The host `auth.json` and any refresh token stay outside the container. +- The selected login also applies to an admin's direct-host Codex turn. The authentication + source is recorded in channel policy audits; no credential value is included. +- Selecting a channel's own Codex login fixes that channel and its threads to the Codex engine. + Runtime settings hide the redundant engine picker, and `/model` goes straight from scope to + Codex model and effort. Older Claude sessions switch to fresh Codex sessions on their next turn. + The admin UI describes the separate login storage without showing its internal host path. +- Slack channel Settings shows the channel's own Codex login status, model and effort when the + dedicated login is selected. Its engine control disappears. Codex Cloud MCP discovery and + selected MCP launch policy use that channel's login; the gateway catalog remains separate. + ## Optional isolated VPN database service - The host operator can provision a per-channel OpenVPN 3 Linux service and an unprivileged MySQL @@ -310,7 +341,13 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. Its script refuses non-hosted or occupied hosts. A separate KVM guest workflow exercises an actual OS reboot, service autostart and persistent database/container-volume fixtures. Authenticated engine update smoke and conversation/session acceptance remain separate live - gates. → TEST-PLAN: Disposable Linux lifecycle workflow. + gates. → TEST-PLAN: Disposable Linux lifecycle workflow. The update's engine smoke (a + throwaway container, one fixed prompt per configured engine, before the update and after the + restart) counts a provider **usage limit** (weekly/session plan cap, spent credits, rate limit) + as REACHABLE, noted "reachable but usage-limited": the CLI started in the new container, + authenticated and reached its provider, which is what the smoke proves (2026-09-28, an update on + Atlas was refused over a Claude weekly limit). A bad login, a missing CLI or a wrong answer still + refuses the update or rolls it back. - **Service-account image provisioning** uses the same explicit environment as the systemd daemon so operator XDG/container storage settings cannot redirect a fresh build into another user's private Podman store, and runs from the service-owned checkout so an operator-private @@ -757,6 +794,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. (capped 12k chars) directly through the sanitized/chunked unattended-delivery path. A completed report never depends on a second model turn (and therefore cannot be stranded by that model's usage limit); shell jobs and failed/incomplete agents still use an interpreted continuation. + Nested agents keep their synthetic session keys for engine isolation, while Slack report delivery + resolves the original thread timestamp. Synthetic scheduled keys post at channel level. Recovery + gives an already completed nested report whose old delivery attempts were exhausted one bounded + retry from its saved output; it never reruns the agent or a completed continuation. Allowed in every channel mode (the run enforces the channel's own permissions; approval prompts still surface in-thread). Both job kinds post a "started" note in-thread with a *Check status* button (ephemeral live @@ -1352,19 +1393,28 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. links, mentions, emoji, lists, and line boundaries. A table-only message counts as content (while the normal channel mention gate still applies). No extra Slack scope is needed. Any allowed user. → TEST-PLAN: Native Slack data tables. -- Slack **file snippets** for sharing a file and for big/wide tables: `slack_upload_snippet` (gateway +- Slack **file snippets** for big/wide tables and generated text: `slack_upload_snippet` (gateway control MCP) uploads `content` (CSV/TSV/markdown/code) as a FILE into the current channel + thread, so a CSV/TSV renders as a **scrollable spreadsheet grid** — the right shape for a large read-only table/export vs. a cramped message code block or a 100-row Slack List. Daemon-side via the workspace bot token (`files:write`) using Slack's external-upload flow (`files.getUploadURLExternal` → POST bytes → `files.completeUploadExternal`), so it needs no Bash/network in the channel; hard-scoped to the current channel. The filename extension drives - rendering (`.csv`/`.tsv` = grid; text/code = plain snippet). The tool description and the - `gateway-usage` skill also route a **direct request for a file** ("send it here", "share the - file", "attach it") to this tool for any UTF-8 text file — `.md`, `.txt`, `.json`, `.html`, - `.yaml`, code, logs — uploaded under its real name, while images keep the automatic - `![alt](path.png)` upload path and binaries (PDF, PPTX, XLSX, ZIP) stay inline-code paths served by - the 📄 file-explorer button. Any allowed user. → TEST-PLAN: Slack file snippets. + rendering (`.csv`/`.tsv` = grid; text/code = plain snippet). Any allowed user. + → TEST-PLAN: Slack file snippets. +- **Agent file sharing into the thread**: `slack_share_file path: [comment]` (gateway + control MCP) posts an EXISTING file from the channel's working folder into the current thread as + a real Slack file, any type (PDF, DOCX, XLSX, PPTX, ZIP, images, HTML), up to 25 MB — the + agent-side twin of the file explorer's Share button. The daemon opens the file with + `openConfinedFile` (lexical + realpath confinement, `O_NOFOLLOW`, descriptor re-proved through + `/proc/self/fd`) and uploads from that proven descriptor, never a reopened path, so neither the + mounted operator home nor a symlink out of the folder can be shared. Posts as the bot with the + bot token, so no Composio account is chosen and no "which account?" question arises; a scheduled + run posts top-level. Audited as `channel_file_shared` with `via: "agent"`. Any allowed user, like + the explorer's Share button. The `gateway-usage` skill (rule 3, the capability map, + `writing-replies.md`, `sharing-files.md`) routes "send it", "attach the PDF", "trimite fișierul" + here, keeps Composio Slack uploads for OTHER channels/DMs, and `slack_upload_snippet` for text + the agent generates. → TEST-PLAN: Agent file sharing into the thread. - Native Slack **charts**: `slack_post_chart` (gateway control MCP) posts Block Kit `data_visualization` blocks into the current channel + thread using the workspace bot token and existing `chat:write` scope. Supports line/bar/area charts (1–12 series, 1–20 shared category @@ -1445,8 +1495,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. (OpenAI Codex CLI — one-shot per message, MCP via `-c` overrides + an HTTP bridge). Selection precedence is **per-thread directive → per-channel → global default** — but an EXISTING thread sticks to the engine that minted its session: changing the channel/global harness only affects - new threads; live conversations keep resuming on their own engine (only the automatic usage-limit - failover runs a different engine, under a suffixed session key). The exception is a harness turned + new threads; live conversations keep resuming on their own engine. A successful automatic + cross-engine failover makes the answering engine the thread's live session, so its next turn + resumes there even after the failed engine's cooldown ends. Older fallback-only sessions are + adopted on their next unpinned turn. The exception is a harness turned OFF in Settings: those threads move to an enabled engine. A row minted for a brand-new thread whose turn then dies BEFORE its engine starts (a pre-spawn credential gate, a runtime that cannot come up) is dropped with that turn, so the next message is a first turn again on the channel's @@ -3826,13 +3878,13 @@ are retired, bullet by bullet; everything else stands. active users, active sessions, and total tokens), per-bucket charts for sessions/tokens/cost, a sessions-per-user bar list (descending), and a per-channel sessions+cost bar chart. Pure inline SVG + div bars — no chart library, no build step. → TEST-PLAN: Admin UI. -- **Every Overview chart is stacked by the model that answered.** A cost, run or token line split +- **Every Overview time chart is stacked by the model that answered.** A cost, run or token column split by model answers "did spend rise because we ran more, or because we moved onto a pricier model" — which one undifferentiated line cannot. Attribution is per component, not per run: a Codex turn whose subagent ran a different model contributes a slice to EACH, while the run itself is still counted once (`MODEL_ATTRIBUTION_CTE` in `src/gateway/usage.js`). The split reconciles with the headline by construction — a run whose components are only partly priced contributes no dollars - to any model, exactly as the canonical rollup drops it — so the bands always add up to the total + to any model, exactly as the canonical rollup drops it — so the columns always add up to the total beside them. Model ids are normalised for display (`modelDisplayLabel`): a dated snapshot (`claude-haiku-4-5-20251001`) reads as its family, a context variant keeps its `1M` marker, an OpenAI id becomes `GPT-5.6 Sol`, and an unrecognised id passes through verbatim rather than being @@ -3852,13 +3904,18 @@ are retired, bullet by bullet; everything else stands. - **A dedicated Models chart**, answering "which models are used more" directly: every model in the window as a bar — token cost, its share of spend, runs (and how many of them came from outside the gateway) and tokens. The stacked charts above cap at the seven largest models plus a neutral - "Other" band, because a stacked area with a generated ninth hue stops being readable; this card + "Other" segment, because a generated ninth hue stops being readable; this card lists everything, so nothing is hidden by that cap. Colours come from a categorical palette validated for the admin surface (lightness band, chroma floor, adjacent colourblind separation, normal-vision separation and 3:1 contrast all pass) and are keyed on the MODEL, so changing range, harness or source never repaints the series that survived the filter. Each chart carries a legend and a crosshair tooltip breaking the hovered bucket down per model (pointer or keyboard). → TEST-PLAN: Admin UI. +- **Overview chart order and details:** four compact cards show token cost, runs, tokens and usage + sources. Below them, full-width cards show Models, Channels, Users and Skills in that order. + Time buckets draw stacked columns. Hovering or keyboard-focusing a source, channel or user bar + shows the total and its per-model values for that bar's metric; the same model colours and labels + appear in the time-chart tooltips and legend. → TEST-PLAN: Per-model dashboard stacking. - **The bar lists stack by model too**, in the same colours and the same order as the charts, so one hue means one model across the whole page: Runs per user (stacked by runs), Channels (all three bars — runs, token cost, tokens — each stacked by its own metric), and Where usage came from diff --git a/TEST-PLAN.md b/TEST-PLAN.md index f426354..49794e3 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,67 @@ # ChannelGate — Test Plan +## Channel-specific Codex authentication (2026-09-29) + +- [x] Automated: Chromium confirms the shared-login channel view has no gateway status/sign-in + panel; choosing a channel login hides engine selection, choosing ChatGPT starts device sign-in, + clicking the code copies it, and choosing API key reveals its field. Slack modal tests confirm + a dedicated login's status, Codex-only runtime controls and channel model. Codex discovery + tests confirm channel `CODEX_HOME` and no inherited gateway API key. +- [ ] Live: with a disposable channel account, confirm its Codex Cloud MCP catalog and selected + tools match that account, while a shared-login channel still uses the gateway catalog. Confirm + the shared Codex sign-in is available under Settings → Agent defaults. +- [x] Automated: current colored Codex CLI device output yields the complete code and approved + URL. Chromium selects ChatGPT from the method dropdown, observes its POST and the displayed code, + then clicks the code to copy it. +- [x] Isolated CLI probe (2026-10-01): the real Codex CLI, with a scratch login home and proxy + CA, reached the pending device step with a complete code and URL; the probe was cancelled. +- [x] Automated: channel meta save forces Codex and clears a saved Claude model when the channel + login is selected; the `/model` wizard skips the harness step and rejects a stale Claude button; + thread engine resolution reports Codex even for an old Claude session or thread pin. +- [ ] Live UI acceptance: select **This channel's own login**. The engine picker disappears, + the method dropdown starts ChatGPT sign-in or reveals the API key field, and clicking the code + copies it. In a thread with an old Claude session, `/model` offers only Codex models and the + next message starts a Codex session. Switching back to the shared login restores engine choice. +- [x] Automated: the Codex login service and admin API tests cover both channel and shared + gateway sign-in, including API key delivery through stdin, safe status payloads, authenticated + routes and CSRF refusal. +- [ ] Live admin UI acceptance: open Runtime in a disposable channel. The login source choice + appears before the engine/model controls. **Default gateway login** shows only engine/model/effort; + **This channel's own login** shows the channel Codex sign-in method and status. Switch between them without signing in and save: + a new Codex thread must use the selected source. Sign in to the shared gateway using a + disposable API key and confirm a different channel using gateway default sees that method; + the channel-specific login remains separate. Revoke the test key afterward. +- [x] Automated: `node --test test/channel-codex-login.test.js`. Admin UI device sign-in exposes + only an approved ChatGPT URL and one-time code, then selects the channel after a saved login; + API key sign-in sends the key only through CLI standard input. Failed/cancelled flows do not + select the channel. The authenticated admin API refuses unknown channels and invalid input. +- [ ] Live admin UI acceptance: in a disposable channel open Runtime → Codex authentication, + start ChatGPT sign-in, follow the browser link and code, and observe **Signed in with ChatGPT** + and **This channel's login** selected. Repeat with a disposable API key in a second channel; + observe **Signed in with an API key**, a cleared password field, and no key in the browser + response, gateway logs, or audit. Cancel a third device flow and confirm it remains unsigned. +- [x] Automated: `node --test test/codex-token-relay.test.js + test/container-credentials.test.js test/engine-runtime-isolated.test.js + test/codex-args.test.js test/channel-env.test.js`. A channel-selected login has no gateway + fallback. A synthetic subscription cache yields an access-only channel placeholder and leaves + the refresh token on the host. A synthetic API-key cache yields a separate placeholder swapped + only in the Authorization header at `api.openai.com`; the real key never enters container + `auth.json`. Proxy mode never mounts either host login file. +- [x] CLI shape check: Codex CLI 0.156.1 `login status` accepts a temporary, synthetic API-key + `auth.json` with `auth_mode: "apikey"`; no real credential or provider request was used. +- [ ] Live Codex acceptance: in a disposable channel, select **This channel's login**, sign in + as a different permitted ChatGPT account under the displayed host `CODEX_HOME`, and ask for a + harmless answer. Pass: Codex answers, the host file keeps the refresh token, the container file + has only the channel's placeholder and an empty refresh token, and the egress audit records a + relay on the Codex hosts. Then remove the channel file while the gateway login remains valid: + the channel must fail authentication rather than use the gateway account. Restore afterward. +- [ ] Live Codex API-key acceptance: use a disposable OpenAI project key in a second channel's + displayed host `CODEX_HOME`, send the same harmless prompt, and require a response and an + `api.openai.com` relay audit entry. The container file contains only the placeholder; a copied + placeholder sent to `chatgpt.com` is not swapped. Revoke the test key afterward. +- [ ] Live Claude isolation: with either Codex channel choice selected, run a Claude turn in the + same disposable channel and require the existing Claude login and normal reply. + ## Live-case definitions corrected for the container-secrets contract (2026-09-27 QA campaign) The 2026-09-27 live campaign failed or blocked these registry cases only because their written @@ -1221,9 +1283,23 @@ unchecked live gate above. snapshot, OpenAI id, unknown id, empty). Engine-independent: this is ledger SQL, not harness behaviour. - [x] `test/usage-model-breakdown.test.js` also pins the Overview's chart contract: the categorical - palette hexes (changing one obliges re-running the dataviz validator), the stacked-area + palette hexes (changing one obliges re-running the dataviz validator), the stacked-column renderer, a legend for every multi-series chart, the models bar chart, and that hues are keyed on the model rather than cycled by position in a filtered list. +- [x] `test/dashboard-chart-layout.test.js`: stacked columns occupy separate time buckets and + reconcile their heights with per-model values; source, channel and user bars expose the + matching model breakdown on hover and keyboard focus; the four compact cards and four + full-width cards render in the requested order. Engine-independent: these are browser UI + functions over the already-aggregated dashboard payload. +- [ ] Live acceptance (Overview chart layout): open Overview with a range containing usage from + two or more models. Require four cards across at desktop width: Token est. cost, Runs, + Tokens, Where usage came from. Require separate stacked columns for each time bucket and + verify the hovered column shows that bucket's model values. Below, require full-width Models, + Channels, Runs per user, Top skills in that order. Hover and keyboard-focus one source bar, + one channel metric bar and one user bar; each tooltip must show the bar's total and model + breakdown in the same colours as the legend. Repeat at a narrow viewport to check card + wrapping and that tooltips remain readable. Engine-independent: the UI reads a fixed API + payload and no engine turn is involved. - [x] `test/external-usage.test.js`: Claude transcript parsing into per-hour/per-model aggregates (subagent spend counted, synthetic error replies not, tool results and subagent prompts not counted as turns, dated snapshots collapsed onto the billing id); the cache-write TTL split, @@ -2456,6 +2532,24 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated ## Complete background-agent report delivery +- [x] `test/durable-delivery.test.js`: a completed nested-agent report saved with both delivery + attempts exhausted gets one repair delivery on recovery, retires its row after success, and never + calls the runner. A failed repair records its one-time marker and cannot reset the attempt budget + again on a later restart. Focused delivery suite: 65 passed. +- [ ] Live Claude and Codex, separate private fixtures: stage a completed nested-agent report + with a synthetic key, saved output and two exhausted attempts; restart safely on the fixed beta + revision. Pass when the original report appears once in the root thread, the row retires, and no + engine work is rerun. Preserve the original failed delivery evidence and the exact retest links. +- [x] `test/deliver.test.js`: a nested agent session key with two `::agent-` suffixes delivers + its report and menu into the launching Slack thread; a synthetic scheduled key produces a + channel-level post with no `thread_ts`. `test/durable-delivery.test.js` checks that a completed + agent's report is delivered directly and its durable row retires only after delivery. +- [ ] Live Claude and Codex, separate private Auto fixtures: start a background agent that starts + another background agent returning a short read-only report. Capture the root thread timestamp + and both synthetic session keys. Pass when the nested agent's completed report appears exactly + once in the root thread, no `invalid_thread_ts` warning appears, and its `bg_jobs` row retires. + Repeat with a channel-level scheduled report: it must post without a `thread_ts`. Keep each + engine's observed evidence and any failed attempt for the exact candidate. - [x] `test/durable-delivery.test.js`: both engine result shapes retain a report longer than 12,000 characters through an unavailable transport, persisted state, and recovery. Exact content and the final sentinel survive, known secret values remain redacted, and the completed @@ -3386,6 +3480,15 @@ exercise the `qwen-eu` adapter itself, in a scratch runtime root (no production hours, keeps a live run's directories and any explicitly protected path, and never touches a directory that is not the suite's. Manual: `ls /tmp | grep -c '^cg-'` before and after a full `npm test` must not grow. +- [x] `test/folders-generator-paths.test.js`: the four custom workdir fixtures are created inside + `~/ChannelGate Testing/folders-generator-*`, never as `cg-*` siblings in the account home. + The process-exit cleanup removes the run folder; `pretest` removes only stale + `folders-generator-*` folders in that parent and leaves recent runs and unrelated folders. + The aggregate runner snapshots that parent before and after the suite and fails on a new + leftover run folder. + Run `node --test test/folders-generator-paths.test.js test/test-scratch-cleanup.test.js`; + pass when both files pass, no `cg-{custom,mirror,project,workdir}-*` directories appear + directly under the home, and no `folders-generator-*` directory remains after the run. - [x] `npm run check:static`: every tracked JavaScript source/test/script parses under the supported Node runtime and fails on tabs or trailing whitespace. This is the deliberately incremental, dependency-free static/format gate; repo-wide ESLint/typed-JS adoption remains a future @@ -4486,6 +4589,19 @@ placeholders and `--network none`). authentication failure answers via the OTHER harness with a reason note and observes its per-engine per-channel / gateway-wide ~15-min cooldown; with failover OFF, the engine's own error surfaces. A post-tool failure never replays. +- [x] Automatic failover stays on the answering harness (`test/claude-fallback-e2e.test.js`, + `test/codex-failover-e2e.test.js`): in a Claude-default channel, trigger the fixture's + replay-safe Claude limit and let Codex answer; reset cooldown and send another message in + the same thread. Pass: the second turn runs on Codex with `resume=yes`, with no new failover + note. Repeat in a Codex-default channel with the Codex limit and Claude answer. For an older + session fixture with a Claude main row and a successful Codex fallback row, send an unpinned + continuation; pass: it resumes Codex and moves that session to the main thread key. A manual + thread engine/model choice keeps its existing precedence. Live acceptance on either engine: + use a test account whose primary harness is genuinely limited, observe the fallback answer, + then send a second message in the same thread after its cooldown; pass only if the second + reply footer names the fallback harness and continues its prior context. Private Airtable + live definitions `ENG-11` (Claude→Codex on Atlas) and `ENG-12` (Codex→Claude on Xavier) + are registered and remain unexecuted until their limited-account fixtures are available. - [x] Unit: the Codex runner classifies its plan-limit rejection ("purchase more credits…") as a replay-safe `usage_limit` — as a JSON error event AND on stderr with a nonzero exit — while model rejections keep routing to the same-engine model retry, server/connection errors @@ -5852,18 +5968,31 @@ none` for its cases and live gates. Kept as history. (`thread_ts` = the current thread), not the channel root. - [ ] Empty `content` is refused with a one-line message; no channel context returns a friendly error. - [ ] Hard-scoped to the current channel — it never uploads to an arbitrary channel id. -- [ ] Asking for a file directly ("send me `REPORT.md`", "share that file here", "attach the JSON") - makes the AI upload it with `slack_upload_snippet` under its real name/extension instead of - only naming the path, and the reply is a one-line summary rather than the pasted content. - Engine-independent guidance; verify on Claude and Codex. -- [ ] The injected `gateway-usage` skill (`platforms/slack/writing-replies.md`, the capability map, - and rule 3 in `SKILL.md`) states: any UTF-8 text file may be uploaded; `.html` uploads and - downloads but previews as source, not a rendered page; images keep the `![alt](path.png)` - auto-upload route; binaries (PDF/PPTX/XLSX/ZIP) are refused and named as inline-code paths for - the 📄 file-explorer button; files above roughly 1 MB are offered rather than uploaded by - reflex. -- [ ] The `slack_upload_snippet` tool description itself names the share-a-file use and the UTF-8 - text restriction, so an engine that never loads the skill still picks the right route. + +### Agent file sharing into the thread (control MCP) +Fixture: a Slack channel whose working folder holds `artifacts/Contract.pdf` (a real PDF, a few +hundred KB) and `artifacts/link.txt`, a symlink to a file outside the folder. Run each prompt on +Claude and on Codex. +- [ ] Prompt "send me `artifacts/Contract.pdf` here" → the AI calls `slack_share_file` (not + `stage_file_for_composio`, not a Composio `SLACK_*` upload, no `ask_questions` account card); + the PDF appears in THIS thread as a native Slack PDF with a preview, byte-identical to the file + on disk; the reply is one line and does not paste content. Pass: all four hold on both engines. +- [ ] The file is posted by the bot user, in the current thread (`thread_ts` = the thread), and an + `events` row `channel_file_shared` records channel, author, slug, relative file, bytes and + `via: "agent"`. +- [ ] `slack_share_file` with `../…`, an absolute path, `artifacts/link.txt` (symlink out of the + folder) or a path under the mounted operator home answers `Sharing refused: …` and uploads + nothing. +- [ ] A file over 25 MB is refused with the size limit named; an empty file is refused; a Slack API + error (e.g. missing `files:write`) answers `Couldn't share the file: …`, never "Shared". +- [ ] From a scheduled run (`sched-…` thread key) the file posts top-level in the channel, not an + `invalid_thread_ts` error. +- [ ] The tool is OPEN in the control-plane classification (no approval card), like the explorer's + Share button, and available to any allowed user. +- [ ] `gateway-usage` (SKILL.md rule 3 and capability map, `writing-replies.md`, + `sharing-files.md`) and the `slack_upload_snippet` / `stage_file_for_composio` descriptions all + point "send the file into this thread" at `slack_share_file`; Composio Slack upload is named + only for other channels/DMs. ### Native Slack charts (control MCP) - [ ] `slack_post_chart chart_type:"line" ...` posts a Block Kit `data_visualization` into the @@ -7869,6 +7998,12 @@ the suite runs as an enterprise deployment because it holds a license it actuall ## Container update verification and recovery +- [x] Unit — the update engine smoke counts a provider usage limit (thrown or returned: Claude's + weekly/session limit, Codex's usage limit, a rate limit) as reachable with a + "reachable but usage-limited" note, both before the update and for an engine required after + the restart; an authentication failure, a missing CLI or an unexpected answer still fails it + (`test/update-smoke.test.js`). + - Both Claude and Codex fixtures: configure each login, invoke the internal authenticated update smoke route, require exact CG_UPDATE_SMOKE_OK responses per engine. Verify isolated target, no bypass/MCP injection, no host engine child, and removal of the ephemeral container, HOME volume and work folders. Invalid configured credentials must fail; absent credentials must be explicitly skipped; zero probes fails. - Image recovery: build old pins, change the desired Codex pin without changing imageSpecVersion, make the first build fail, then run Update again on the same checkout revision. Require retry and matching built/desired source digest; a build exiting zero with stale labels fails verification. Existing channels retain HOME and adopt the new image when idle. - Admin container status: require actual built and desired Claude/Codex versions, rebuild-needed status, and count of containers awaiting adoption. A custom image reference must never report a successful default-image rebuild. diff --git a/docs/ENGINE-CAPABILITIES.md b/docs/ENGINE-CAPABILITIES.md index 7344414..efaee2d 100644 --- a/docs/ENGINE-CAPABILITIES.md +++ b/docs/ENGINE-CAPABILITIES.md @@ -15,7 +15,7 @@ the Admin API/UI consume that registry. | Skills | Organization/channel repository skills plus per-author grants | Native organization/channel repository skills plus a per-run personal skill catalog; personal delivery does not register slash commands | Same as Claude (`CLAUDE.md`, `.claude/skills`, plugin dirs) | | Usage/cost | provider-reported cost | token usage with configured rate estimate | token usage only — the CLI's Anthropic-priced figure is dropped and no rate is inferred | | Health | adapter-owned `--version` boot probe | adapter-owned `--version` boot probe | adapter-owned `--version` boot probe plus "is a QwenCloud key configured" | -| Container login | a relay of the host's Claude access token in `CLAUDE_CODE_OAUTH_TOKEN` — behind the egress proxy the channel's `sk-ant-oat01-cgph_r…` placeholder, swapped on `api.anthropic.com`; refreshed by a cheap host turn (`src/gateway/claude-token-relay.js`); never a file | behind the egress proxy an ACCESS-ONLY `auth.json` in the channel HOME, written before each run: a JWT-shaped `cgph_r…` placeholder swapped whole on `api.openai.com`, `chatgpt.com`, `auth.openai.com`, empty refresh token; refreshed by a cheap ephemeral host turn (`src/gateway/codex-token-relay.js`). Legacy bridge mode or an API-key login: the shared read-write file mount | the gateway's provider key in `ANTHROPIC_AUTH_TOKEN`, raw (not relayed) | +| Container login | a relay of the host's Claude access token in `CLAUDE_CODE_OAUTH_TOKEN` — behind the egress proxy the channel's `sk-ant-oat01-cgph_r…` placeholder, swapped on `api.anthropic.com`; refreshed by a cheap host turn (`src/gateway/claude-token-relay.js`); never a file | behind the egress proxy an ACCESS-ONLY `auth.json` in the channel HOME, written before each run: a JWT-shaped placeholder for a ChatGPT login or an API-key placeholder restricted to `api.openai.com`; no refresh token or real API key in the container. The host login can be gateway-wide or channel-specific. Legacy bridge mode keeps a read-write file mount | the gateway's provider key in `ANTHROPIC_AUTH_TOKEN`, raw (not relayed) | The Qwen column covers every harness generated from the Anthropic-compatible **provider table** in `src/engines/qwen.js` — today `qwen` (QwenCloud) and `qwen-eu` (Alibaba Cloud Model Studio, EU diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 219f16c..6899dbe 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -372,8 +372,8 @@ in on the host is all a channel needs. Nothing is copied or mounted. Optionally `claude setup-token` on the gateway host and paste the value into *Claude token for container runs*: that token is then used instead and never needs refreshing. Codex is RELAYED the same way behind the egress proxy (see **Codex login relay** below): keep the host signed in with `codex login`; -nothing is mounted. Only the legacy open-network mode and an API-key Codex login still bind-mount -the gateway's real `auth.json` read-write into every container (Codex rewrites it in place, so a +nothing is mounted. Only the legacy open-network mode still bind-mounts +the selected `auth.json` read-write into a container (Codex rewrites it in place, so a copy would fork the refresh chain). Codex *sessions* and history are per channel either way. **Network (the egress proxy).** Every channel container runs with `--network none`: its only @@ -650,7 +650,7 @@ gone — a finished test run, not a second live gateway sharing this account. A is never removed by it. The space estimate counts image layers once each. To make it routine, schedule the report and read it; schedule `--apply` only if you have decided to. -Codex uses the same shared login file already mounted for chat turns. Claude's rotating credential +Codex uses the channel's selected login, relayed in proxy mode. Claude's rotating credential file is still never copied or mounted: the helper refreshes the gateway's normal subscription access-token relay every 20 minutes and exposes only that access token to interactive `claude` commands. Closing VS Code removes the live token and releases the lease; an interrupted helper is @@ -696,9 +696,9 @@ per-channel or gateway-wide "back to the host" switch: the container is the only - **Per-user Codex skill grants are not delivered in containers.** The per-run Codex skill overlay was built for a synthetic host HOME that a container does not have; a Codex run gets the channel's skills through the mounted workdir, but not that overlay. -- **Codex sessions are per channel; the sign-in is shared only outside the proxy.** Under the - legacy open-network mode (or with an API-key `auth.json`) every container mounts the same - `auth.json` the gateway uses. A `codex login` on the host that *replaces* the file leaves a +- **Codex sessions are per channel; the sign-in is mounted only outside the proxy.** Under the + legacy open-network mode each container mounts its selected host `auth.json`. A `codex login` + on the host that *replaces* the file leaves a running container holding the old inode — `/status` and `/api/health` report the drift; restart the channel's container (or let the reaper stop it) to pick the new one up. Behind the proxy the login is relayed and a new `codex login` takes effect on the next turn. @@ -709,10 +709,8 @@ per-channel or gateway-wide "back to the host" switch: the container is the only dies within hours). A daemon that authenticates Claude with its own `ANTHROPIC_API_KEY` still passes that key through raw — the proxy does not rewrite it. - **Engine keys that still reach a proxy-mode container raw.** A Qwen harness's provider key - (`ANTHROPIC_AUTH_TOKEN` pointed at the provider) and a daemon `CODEX_API_KEY`/`OPENAI_API_KEY` - handed to Codex are real values in the container environment; Codex's own sign-in is the shared - `auth.json` mount (the engine-login broker is a later phase). They are reachable only on their - engine endpoints through the proxy, but a process in the container can read them. + (`ANTHROPIC_AUTH_TOKEN` pointed at the provider) still reaches that engine as a real value. + Codex logins stored in `auth.json`, including API-key logins, are relayed behind the proxy. - **A self-hosted Qwen endpoint on a private address is refused in proxy mode.** The proxy never connects to loopback, private, link-local or CGNAT addresses, and a configured Qwen base URL is no exception: point the harness at a public endpoint, or run that channel under the legacy bridge @@ -727,9 +725,8 @@ per-channel or gateway-wide "back to the host" switch: the container is the only the names of any unprotected (`egressUnprotected`) or withheld (`egressWithheld`) secrets. `networkEnforcedFor(target)` in `src/engines/network-policy.js` is the one question every surface asks. -- **Codex's sign-in is a placeholder in proxy mode.** See **Codex login relay** below. What remains - raw: the legacy bridge mode's shared file, an API-key `auth.json`, and a daemon - `OPENAI_API_KEY`/`CODEX_API_KEY`, which reaches the container env as `CODEX_API_KEY` unchanged. +- **Codex's sign-in is a placeholder in proxy mode.** See **Codex login relay** below. The legacy + bridge mode still mounts the selected `auth.json` file because it has no proxy swap. - **SSH and VS Code sessions** run in the same `--network none` container with the proxy env and hold placeholders like a turn (container-secrets P3, `docs/SSH-ACCESS.md`); SSH `-L` forwards to external hosts do not work without the network. @@ -755,6 +752,43 @@ turn after upgrading recreates each channel's container once (the mount is gone fingerprint), and `cg-init` deletes an old copied Codex login carrying a refresh token from the volume. The legacy open-network mode keeps the shared read-write mount (and says so in `/status`). +**A separate Codex login for one channel.** In the admin channel editor, Runtime → **Codex +login source** → **This channel's own login**. The choice comes before the engine and model +settings. **Default gateway login** shows the shared Slack, Claude and Codex status, with links +to the gateway Slack/Claude settings and direct ChatGPT or API-key sign-in for shared Codex. +Slack and Claude currently use the gateway connection regardless of the Codex choice. Choose +**This channel's own login** to show the dedicated Codex controls; channel connector overrides +remain in MCP Connections and Environment tokens. + +Use **Sign in with ChatGPT** to start a device code +flow in the admin page; open the displayed link, enter its code, and wait for the status to show +the completed ChatGPT login. Device code sign-in may first need enabling in ChatGPT security +settings or workspace permissions. Alternatively, enter a Platform key in the private password +field and choose **Use API key**. A successful sign-in selects and saves **This channel's login**. +The page shows only the method and sign-in status after completion; it never returns the key. + +For a terminal fallback, the editor shows that channel's Codex home directory on the **gateway +host**. Create it under the gateway service account and sign in there. For ChatGPT subscription +access, use `CODEX_HOME= codex login +--device-auth` and complete the browser code flow. For an OpenAI Platform API key, use +`CODEX_HOME= codex login --with-api-key`, supplying the key on standard +input as prompted by the CLI. Never put the key on the command line, in Slack, or in the channel +folder. `CODEX_HOME= codex login status` checks the selected method. OpenAI +bills API-key runs separately from a ChatGPT subscription. The channel login does not fall back +to the gateway login if it is missing or expired. + +For a laptop login, OpenAI documents copying `~/.codex/auth.json` to a headless host as a +fallback when device login is unavailable. Use an encrypted SSH transfer into the **displayed +host directory** with restrictive permissions and run Codex as the gateway service account so +the one host-side file can refresh. The file contains full credentials, including a refresh +token: do not upload it through Slack or the admin UI. Channel containers receive only a +channel-bound placeholder. A ChatGPT login is relayed as the JWT-shaped access token described +above; an API-key login is relayed as a separate placeholder swapped only in the Authorization +header at `api.openai.com`. The channel setting affects new Codex turns; it does not change +Claude authentication. An Admin channel with the optional full operator-home mount can read +host files under that mount, including the gateway's runtime root; use ordinary project channels +for credential isolation. + **Remote MCP relay (container-secrets P1).** Composio (`composio-user`, `composio-agent` in token mode), the MakeItFuture toolbox and the Make toolbox never reach a container with their token. The engine's MCP entry is the image's socket bridge naming the server (`CG_MCP_SERVICE=remote-mcp`); diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 3938648..eeb8c48 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -16,8 +16,9 @@ > checkbox and optional Allowed domains, non-admin turns are read-only while an Admin channel > mounts the operator home, Codex Read mode runs commands again, the Claude model pickers show exact > versions, and Codex usage metrics are switched off on every spawn. The image is spec 1.6.1 -> (bubblewrap, per-channel `/tmp` volumes); every other host must run `npm run build:image` before -> restarting. Live on Xavier before the cut (Claude and Codex through the QA actors, recorded in the +> (bubblewrap, per-channel `/tmp` volumes); the updater (Settings → Update or `scripts/update.sh`) +> rebuilds the default image automatically — `npm run build:image` by hand only if it reports the +> image build failed or the host uses a custom image. Live on Xavier before the cut (Claude and Codex through the QA actors, recorded in the > private QA base): EGR-01/03/04/05/07/09/10, CDX-01..04/08, EN-01 and EN-09 on Codex, CTR-30 on both > engines, SSHP-01..05 from the gateway host, and the hidden-variable first-use approval with the > post-approval swap. Still open for the next release: SSHP-06/07 (need a desktop VS Code), the Slack diff --git a/docs/SSH-ACCESS.md b/docs/SSH-ACCESS.md index f173fda..b2af4a2 100644 --- a/docs/SSH-ACCESS.md +++ b/docs/SSH-ACCESS.md @@ -102,7 +102,8 @@ machine settings — merge-only, and never over a value you set yourself. Inside, you are user `agent` in the channel's work folder (an interactive login starts there; image spec 1.5.1), with the same environment an engine turn gets and the channel's persistent `/home/agent` (installed tools, `gh`/`vercel`/`supabase` logins, Claude and Codex history). -Codex uses the shared sign-in mount, and `codex` in a session gets what a chat turn's Codex gets: +Codex uses the channel's selected login (a proxy placeholder in proxy mode, a host file mount in +legacy bridge mode), and `codex` in a session gets what a chat turn's Codex gets: the gateway tools, your own Composio accounts as `composio-user`, the channel's as `composio-agent`, the channel's selected MCP servers and your channel secrets. Started from your home, `/` or a parent of the channel folder it moves into the channel folder so its `AGENTS.md` @@ -146,7 +147,7 @@ session gets: expiry and rate tier beside it are the real login's — they are facts, not secrets), so the file is useless outside the container. `claude -r ` resumes a thread's own session. -Codex over SSH is unchanged (its login is the shared sign-in mount). "show SSH access" in the +Codex over SSH uses the same selected login as a channel turn. "show SSH access" in the channel prints what a session gets. If a part could not be prepared — no Claude login on the host, a channel whose Composio session is unavailable — the attach still succeeds and the daemon log names the part. diff --git a/package-lock.json b/package-lock.json index a7974d7..6362bcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "channelgate", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "channelgate", - "version": "0.6.0", + "version": "0.7.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@composio/core": "0.19.0", diff --git a/package.json b/package.json index da04f8c..7dd027c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "channelgate", - "version": "0.6.0", + "version": "0.7.0", "private": true, "license": "SEE LICENSE IN LICENSE.md", "author": "Tiberiu Socaci (MAKEITFUTURE S.R.L.)", diff --git a/public/app.js b/public/app.js index f9c92e7..07daf2d 100644 --- a/public/app.js +++ b/public/app.js @@ -64,7 +64,7 @@ let detailDirty = false; // whether the open conversation detail has unsaved edi // Controls that save through their OWN request are never part of a card's "Unsaved changes" state. // Environment secrets and VPN control have independent writes and must never round-trip through // the card's Save or tell the admin the card has edits waiting. -const SELF_SAVING_CONTROLS = ".channel-env-card, .ch-vpn-controls"; +const SELF_SAVING_CONTROLS = ".channel-env-card, .ch-vpn-controls, .ch-codex-login"; const viewLoaded = {}; const EFFORT_OPTIONS = { @@ -683,38 +683,34 @@ function stackValue(point, entry, metric) { return entry.rest.reduce((total, model) => total + (Number(models[model]?.[metric]) || 0), 0); } -// Stacked area chart. Same stretched viewBox and non-scaling strokes as sparkArea, so it drops into -// the existing chart cards unchanged. Bands are separated by a 2px stroke in the CARD's own colour -// rather than a gap in the geometry: at one-pixel bucket widths a geometric gap would swallow thin -// series whole. -function stackedArea(series, keys, metric, opts = {}) { +// One stacked column per time bucket. Keep the columns centered within their buckets so the hover +// target and the visible bar always refer to the same day, hour or month. +function stackedColumns(series, keys, metric, opts = {}) { const W = 300, H = opts.height || 110, pad = 4; const n = series.length; const cls = "spark" + (opts.tall ? " spark-tall" : ""); if (!n || !keys.length) return ``; const totals = series.map((point) => keys.reduce((sum, entry) => sum + stackValue(point, entry, metric), 0)); const max = Math.max(1e-9, ...totals); - const xs = (i) => (n <= 1 ? W / 2 : (i / (n - 1)) * W); const ys = (v) => H - pad - (v / max) * (H - pad * 2); + const step = W / n; + const width = Math.max(1, step - Math.min(2, step * 0.2)); const grid = opts.grid ? [1, 2].map((k) => ``).join("") : ""; - // Cumulative from the baseline up, so each band's lower edge is the previous band's upper edge. const running = new Array(n).fill(0); - const bands = []; + const columns = []; for (const entry of keys) { - const lower = running.map((v) => v); - for (let i = 0; i < n; i++) running[i] += stackValue(series[i], entry, metric); - const upper = running.map((v) => v); - if (upper.every((v, i) => v === lower[i])) continue; // a model with nothing in this window - const top = upper.map((v, i) => `${xs(i).toFixed(1)},${ys(v).toFixed(1)}`); - const bottom = lower.map((v, i) => `${xs(i).toFixed(1)},${ys(v).toFixed(1)}`).reverse(); - bands.push( - `` + - `` - ); + for (let i = 0; i < n; i++) { + const value = stackValue(series[i], entry, metric); + if (value <= 0) continue; + const bottom = ys(running[i]); + running[i] += value; + const top = ys(running[i]); + columns.push(``); + } } - return `${grid}${bands.join("")}`; + return `${grid}${columns.join("")}`; } // Legend for a stacked chart. Always rendered when there is more than one series — identity must @@ -726,13 +722,12 @@ function modelLegend(keys) { .join("")}`; } -// A stacked chart card, with the hover layer the plain sparkline cards do not need: an area chart -// that stacks eight series is unreadable without being able to ask "what is this band, here". +// A stacked chart card with a hover layer for the model totals in each time bucket. function stackedChartCard(id, title, series, keys, metric, peak, axis, fmt, opts = {}) { return `

${escapeHtml(title)}

${peak}
- ${stackedArea(series, keys, metric, opts)} + ${stackedColumns(series, keys, metric, opts)}
@@ -754,7 +749,14 @@ function barTrack(pct, { color = "", stack = null } = {}) { const segments = parts .map((part) => ``) .join(""); - return `${segments}`; + const format = stack.metric === "cost" ? fmtUSD : stack.metric === "tokens" ? fmtCompact : fmtNum; + const metricName = { cost: "Token cost", tokens: "Tokens", runs: "Runs" }[stack.metric] || stack.metric; + const details = parts.map((part) => `${escapeHtml(part.entry.label)}${format(part.value)}`).join(""); + const accessible = `${stack.row.name || "Usage"}, ${metricName}: ${format(total)}. ${parts.map((part) => `${part.entry.label}: ${format(part.value)}`).join(", ")}`; + return ` + ${segments} + ${escapeHtml(metricName)} · ${format(total)}${details} + `; } // Single-metric horizontal bar list, descending. Row: name · bar (width ∝ value) · value. @@ -1100,13 +1102,12 @@ async function loadDashboard() {
`; }).join(""); - // Token cost is the hero (2fr wide, gridlines, peak dated). Runs + tokens ride at 1fr but share the - // hero's chart height so all three axis labels line up along the same bottom edge. + // Four compact cards share one row: cost, runs, tokens and the usage-source breakdown. const costPeak = peakBucket((x) => x.cost); const costPeakLabel = costPeak && costPeak.cost > 0 ? `peak ${fmtUSD(costPeak.cost)}${costPeak.key ? " · " + bucketLabel(costPeak.key, unit) : ""}` : "no value yet"; - // Every hero chart is stacked by the model that actually answered, so a rising cost line can be + // Every time chart is stacked by the model that actually answered, so a rising cost column can be // read as "we moved onto a pricier model" rather than only "we ran more". One colour map and one // key list across all three, so a band means the same thing in each and the legend is shared. const allModels = d.models || []; @@ -1164,15 +1165,9 @@ async function loadDashboard() {
${kpiHtml}
${modelLegend(keys)}
-
${charts}
-
+
${charts}${originsCard}
+
${modelsCard} - ${originsCard} -
-

Runs per user

${users.length} of ${fmtNum(t.users)}
- ${barList(users, (u) => u.runs, (u) => `${fmtNum(u.runs)} runs${fmtCompact(u.tokens)} tokens${fmtUSD(u.cost)} est.`, "#91c9ce", "No user activity yet.", { keys, metric: "runs" })} - ${moreUsers > 0 ? `

+ ${moreUsers} more

` : ""} -

Channels — runs, token cost & tokens

three bars per channel, split by model @@ -1180,6 +1175,11 @@ async function loadDashboard() { ${channelBars(channels, keys)} ${moreChannels > 0 ? `

+ ${moreChannels} more

` : ""}
+
+

Runs per user

${users.length} of ${fmtNum(t.users)}
+ ${barList(users, (u) => u.runs, (u) => `${fmtNum(u.runs)} runs${fmtCompact(u.tokens)} tokens${fmtUSD(u.cost)} est.`, "#91c9ce", "No user activity yet.", { keys, metric: "runs" })} + ${moreUsers > 0 ? `

+ ${moreUsers} more

` : ""} +

Top skills — usage

last 30 days
${barList(skills, (s) => s.uses, (s) => `${fmtNum(s.uses)} uses`, "#6ea6a1", "No skill usage yet.")} @@ -1209,8 +1209,8 @@ async function loadDashboard() { } } -// Crosshair + tooltip for the stacked charts. A stacked area with up to eight bands cannot be read -// without asking "which band is this, and how much"; the legend names the colours, this says the +// Crosshair + tooltip for the stacked time charts. A column with up to eight segments cannot be read +// without asking "which model is this, and how much"; the legend names the colours, this says the // numbers. Pointer-driven and keyboard-reachable (the chart is focusable and arrow keys step // buckets), so the reading is not mouse-only. const STACK_FMT = { usd: (v) => fmtUSD(v), num: (v) => fmtNum(v), compact: (v) => fmtCompact(v) }; @@ -1228,7 +1228,7 @@ function renderStackTip(host, index) { const total = rows.reduce((sum, row) => sum + row.value, 0); const tip = host.querySelector(".spark-tip"); const cursor = host.querySelector(".spark-cursor"); - const pct = data.series.length <= 1 ? 50 : (index / (data.series.length - 1)) * 100; + const pct = ((index + 0.5) / data.series.length) * 100; cursor.style.left = `${pct}%`; cursor.hidden = false; tip.hidden = false; @@ -1262,7 +1262,7 @@ function wireStackHover(root) { if (!n) return; const rect = host.getBoundingClientRect(); if (!rect.width) return; - show(Math.round(((event.clientX - rect.left) / rect.width) * (n - 1))); + show(Math.floor(((event.clientX - rect.left) / rect.width) * n)); }); host.addEventListener("pointerleave", hide); host.addEventListener("focus", () => show(current >= 0 ? current : count() - 1)); @@ -1544,6 +1544,7 @@ function initializeMcpBox(box, { claude = [], codex = [] } = {}) { function captureMcpSelection(box) { if (box.dataset.engine === "both") { + if (box.dataset.mcpLoading) return; const state = mcpBoxState(box); for (const engine of ["claude", "codex"]) { state[engine] = [...box.querySelectorAll(`input[type="checkbox"][data-mcp-engine="${engine}"]:checked`)] @@ -1564,7 +1565,7 @@ function captureMcpSelection(box) { } function catalogWithSavedEntries(box, engine) { - const catalog = AVAILABLE_MCPS[engine] || []; + const catalog = AVAILABLE_MCPS[engine === "codex" ? (box.dataset.mcpCodexKey || "codex") : engine] || []; const state = mcpBoxState(box); const wanted = new Set(state[engine]); const merged = [...catalog]; @@ -1607,34 +1608,37 @@ function paintAllMcpBoxes(box) { } } -async function loadMcpCatalog(engine) { - if (Array.isArray(AVAILABLE_MCPS[engine])) return AVAILABLE_MCPS[engine]; - if (!MCP_CATALOG_LOADS[engine]) { - MCP_CATALOG_LOADS[engine] = api(`/api/mcp/available?engine=${encodeURIComponent(engine)}`) +async function loadMcpCatalog(engine, channelId = "") { + const key = engine === "codex" && channelId ? `codex:${channelId}` : engine; + if (Array.isArray(AVAILABLE_MCPS[key])) return AVAILABLE_MCPS[key]; + if (!MCP_CATALOG_LOADS[key]) { + MCP_CATALOG_LOADS[key] = api(`/api/mcp/available?engine=${encodeURIComponent(engine)}${channelId && engine === "codex" ? `&channelId=${encodeURIComponent(channelId)}` : ""}`) .then((result) => { - AVAILABLE_MCPS[engine] = Array.isArray(result.servers) ? result.servers : []; - return AVAILABLE_MCPS[engine]; + AVAILABLE_MCPS[key] = Array.isArray(result.servers) ? result.servers : []; + return AVAILABLE_MCPS[key]; }) .finally(() => { - delete MCP_CATALOG_LOADS[engine]; + delete MCP_CATALOG_LOADS[key]; }); } - return MCP_CATALOG_LOADS[engine]; + return MCP_CATALOG_LOADS[key]; } -async function renderMcpBoxForEngine(box, engineValue, countEl) { +async function renderMcpBoxForEngine(box, engineValue, countEl, channelId = "") { captureMcpSelection(box); box.dataset.engine = "both"; - const missing = ["claude", "codex"].filter((engine) => !Array.isArray(AVAILABLE_MCPS[engine])); + const codexKey = channelId ? `codex:${channelId}` : "codex"; + box.dataset.mcpCodexKey = codexKey; + const missing = ["claude", "codex"].filter((engine) => !Array.isArray(AVAILABLE_MCPS[engine === "codex" ? codexKey : engine])); if (missing.length) { box.dataset.mcpLoading = "both"; box.classList.add("empty"); box.textContent = "loading Claude and Codex MCP lists…"; updateChecksCount(box, countEl); await Promise.all(missing.map(async (engine) => { - try { await loadMcpCatalog(engine); } catch { AVAILABLE_MCPS[engine] = []; } + try { await loadMcpCatalog(engine, engine === "codex" ? channelId : ""); } catch { AVAILABLE_MCPS[engine === "codex" ? codexKey : engine] = []; } })); - if (!box.isConnected) return; + if (!box.isConnected || box.dataset.mcpCodexKey !== codexKey) return; } delete box.dataset.mcpLoading; paintAllMcpBoxes(box); @@ -1833,6 +1837,92 @@ async function pollDriveSync(channelId, resultEl, stillOpen) { } } +// Both the shared gateway and a channel login use the same private Codex sign-in controls. +// The server returns only a method and the temporary device code; never render raw CLI output. +function mountCodexLoginBox(box, url, { onComplete = () => {} } = {}) { + const statusEl = box.querySelector(".ch-codex-login-status"); + const deviceBox = box.querySelector(".ch-codex-device-code"); + const deviceSection = box.querySelector(".ch-codex-device-section"); + const apiSection = box.querySelector(".ch-codex-api-section"); + const apiNote = box.querySelector(".ch-codex-api-note"); + const methodSelect = box.querySelector(".ch-codex-login-method"); + const apiKeyInput = box.querySelector(".ch-codex-api-key"); + const cancelButton = box.querySelector(".ch-codex-device-cancel"); + let poll = null; + let observedPending = false; + let startedHere = false; + let latestState = null; + const paintMethod = () => { + deviceSection.hidden = methodSelect.value !== "device"; + apiSection.hidden = methodSelect.value !== "api-key"; + apiNote.hidden = methodSelect.value !== "api-key"; + }; + const paint = (state) => { + latestState = state; + if (state.phase === "pending" && methodSelect.value !== "device") methodSelect.value = "device"; + paintMethod(); + statusEl.textContent = state.phase === "pending" ? (state.code && state.url ? "Enter this code to finish signing in:" : "Requesting a ChatGPT sign-in code…") + : state.phase === "failed" ? state.error + : state.authenticated ? `Signed in with ${state.method === "chatgpt" ? "ChatGPT" : "an API key"}` + : "No login yet"; + deviceBox.hidden = !(state.phase === "pending" && state.code && state.url); + if (!deviceBox.hidden) { + box.querySelector(".ch-codex-device-link").href = state.url; + box.querySelector(".ch-codex-device-value").textContent = state.code; + } + cancelButton.hidden = state.phase !== "pending"; + if (state.phase === "pending") observedPending = true; + if (state.phase === "complete" && (observedPending || startedHere)) { + onComplete(); + observedPending = false; + startedHere = false; + } + if (state.phase === "pending" && !poll) { + poll = setInterval(() => { + if (!box.isConnected) { clearInterval(poll); poll = null; return; } + void api(url).then(paint).catch(() => { statusEl.textContent = "Could not check sign-in status"; }); + }, 2000); + } else if (state.phase !== "pending" && poll) { + clearInterval(poll); + poll = null; + } + }; + void api(url).then(paint).catch((error) => { statusEl.textContent = `Could not check sign-in: ${error.message}`; }); + methodSelect.addEventListener("change", async () => { + paintMethod(); + if (methodSelect.value === "api-key" && latestState?.phase === "pending") { + try { await api(url, { method: "DELETE" }); paint(await api(url)); } + catch (error) { statusEl.textContent = error.message; } + } + if (methodSelect.value !== "device" || latestState?.phase === "pending") return; + startedHere = true; + statusEl.textContent = "Starting ChatGPT sign-in…"; + try { paint(await api(url, { method: "POST", body: JSON.stringify({ method: "device" }) })); } + catch (error) { startedHere = false; statusEl.textContent = error.message; } + }); + box.querySelector(".ch-codex-device-value").addEventListener("click", async () => { + const code = box.querySelector(".ch-codex-device-value").textContent; + if (!code) return; + const result = box.querySelector(".ch-codex-copy-state"); + try { await navigator.clipboard.writeText(code); result.textContent = "Copied"; } + catch { result.textContent = "Could not copy code"; } + }); + box.querySelector(".ch-codex-key-save").addEventListener("click", async () => { + const key = apiKeyInput.value; + apiKeyInput.value = ""; + if (!key) { statusEl.textContent = "Enter an OpenAI API key"; return; } + startedHere = true; + statusEl.textContent = "Saving API key sign-in…"; + try { paint(await api(url, { method: "POST", body: JSON.stringify({ method: "api-key", key }) })); } + catch (error) { startedHere = false; statusEl.textContent = error.message; } + }); + cancelButton.addEventListener("click", async () => { + try { await api(url, { method: "DELETE" }); paint(await api(url)); } + catch (error) { statusEl.textContent = error.message; } + }); + paintMethod(); +} + function renderChannelDetail(ch) { const detail = document.getElementById("channel-detail"); detailDirty = false; @@ -2060,7 +2150,31 @@ function renderChannelDetail(ch) { } const engineSelect = card.querySelector(".ch-engine"); engineSelect.value = meta.engine || ""; - renderMcpBoxForEngine(mcpsBox, engineSelect.value, mcpsCount); + const authSource = card.querySelector(".ch-codex-auth-source"); + const channelPanel = card.querySelector(".ch-auth-channel-panel"); + const modelSelect = card.querySelector(".ch-model"); + const effortSelect = card.querySelector(".ch-effort"); + const effortLabel = card.querySelector(".ch-effort-label"); + const paintAuthScope = () => { + const dedicated = authSource.value === "channel"; + channelPanel.hidden = !dedicated; + card.querySelector(".ch-engine-field").hidden = dedicated; + if (dedicated) engineSelect.value = "codex"; + syncModelOptions({ engineSelect, modelSelect, value: modelMatchesEngine(modelSelect.value, effectiveEngine(engineSelect.value)) ? modelSelect.value : "", blankLabel: "gateway default (Settings)" }); + syncEffortOptions({ engineSelect, modelSelect, effortSelect, label: effortLabel }); + renderMcpBoxForEngine(mcpsBox, engineSelect.value, mcpsCount, dedicated ? ch.channelId : ""); + }; + authSource.value = meta.codexAuthSource || "gateway"; + authSource.addEventListener("change", paintAuthScope); + mountCodexLoginBox(channelPanel.querySelector(".ch-codex-login"), `/api/channels/${encodeURIComponent(ch.channelId)}/codex-login`, { + onComplete: () => { + authSource.value = "channel"; + ch.meta = { ...(ch.meta || {}), codexAuthSource: "channel", engine: "codex" }; + delete AVAILABLE_MCPS[`codex:${ch.channelId}`]; + paintAuthScope(); + }, + }); + renderMcpBoxForEngine(mcpsBox, engineSelect.value, mcpsCount, authSource.value === "channel" ? ch.channelId : ""); syncModelOptions({ engineSelect, modelSelect: card.querySelector(".ch-model"), @@ -2074,6 +2188,7 @@ function renderChannelDetail(ch) { label: card.querySelector(".ch-effort-label"), value: meta.effort || "", }); + paintAuthScope(); engineSelect.addEventListener("change", () => { syncModelOptions({ engineSelect, @@ -2191,6 +2306,7 @@ function renderChannelDetail(ch) { memory: card.querySelector(".ch-memory").checked, noDefaultTokens: card.querySelector(".ch-nodefaulttokens").checked, engine: engineSelect.value, + codexAuthSource: card.querySelector(".ch-codex-auth-source").value, workDir: card.querySelector(".ch-workdir").value, syncDriveFolder: card.querySelector(".ch-syncdrive").value, model: card.querySelector(".ch-model").value, @@ -4750,6 +4866,13 @@ async function init() { startActiveRunsStream(); loadUpdateStatus().catch(() => {}); // version chip + update button — off the critical path bindSettings(); + mountCodexLoginBox(document.getElementById("gateway-codex-login"), "/api/gateway/codex-login", { + onComplete: () => { + delete AVAILABLE_MCPS.codex; + const box = document.querySelector("#channel-detail .ch-mcps"); + if (box && box.dataset.mcpCodexKey === "codex") void renderMcpBoxForEngine(box, "codex", document.querySelector("#channel-detail .ch-mcps-count")); + }, + }); const { skills } = await api("/api/skills"); try { SKILL_TEMPLATES = (await api("/api/skills/templates")).templates || []; diff --git a/public/index.html b/public/index.html index 6bbdb7d..70bf4da 100644 --- a/public/index.html +++ b/public/index.html @@ -560,6 +560,18 @@

Engine & runtime

Default Codex model +
Reset all channels to these gateway defaultsClears every channel's engine, model and effort overrides, and optionally the per-thread /model pins that would otherwise survive it. Access, tools, tokens and DM templates stay unchanged. Existing threads keep their current engine session; new threads inherit these defaults. Can't be undone. @@ -1264,8 +1276,38 @@

Environment variables

-
+
+
+ +
+